Files
pyisu/backend/app/crud.py
2026-03-13 14:39:43 +08:00

772 lines
26 KiB
Python

from sqlalchemy.orm import Session, joinedload
from sqlalchemy import func, desc
from typing import Optional
from datetime import datetime
from . import models, schemas, auth
import markdown
from markdown.extensions.tables import TableExtension
from markdown.extensions.fenced_code import FencedCodeExtension
from markdown.extensions.toc import TocExtension
def render_markdown_to_html(md_content: str) -> str:
if not md_content:
return ""
extensions = [
TableExtension(),
FencedCodeExtension(),
TocExtension(),
'nl2br',
'sane_lists',
'smarty'
]
return markdown.markdown(md_content, extensions=extensions)
# User operations
def get_user_by_email(db: Session, email: str):
return db.query(models.User).filter(models.User.email == email).first()
def create_user(db: Session, user: schemas.UserCreate):
hashed_password = auth.get_password_hash(user.password)
db_user = models.User(
email=user.email,
hashed_password=hashed_password
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
def authenticate_user(db: Session, email: str, password: str):
user = get_user_by_email(db, email)
if not user:
return None
if not auth.verify_password(password, user.hashed_password):
return None
return user
# Course operations
def get_courses(db: Session, skip: int = 0, limit: int = 100, level: Optional[str] = None, author_id: Optional[int] = None, published_only: bool = True):
query = db.query(models.Course)
if level:
query = query.filter(models.Course.level == level)
if author_id:
query = query.filter(models.Course.author_id == author_id)
if published_only:
query = query.filter(models.Course.status == 'published')
return query.order_by(models.Course.created_at.desc()).offset(skip).limit(limit).all()
def get_course_by_id(db: Session, course_id: int, include_structure: bool = False):
course = db.query(models.Course).filter(models.Course.id == course_id).first()
if course and include_structure:
# Eager load all related data
course.modules # This will trigger lazy loading
for module in course.modules:
module.lessons
for lesson in module.lessons:
lesson.steps
return course
def create_course(db: Session, course: schemas.CourseCreate, author_id: int):
db_course = models.Course(
title=course.title,
description=course.description,
level=course.level,
duration=course.duration,
author_id=author_id
)
db.add(db_course)
db.commit()
db.refresh(db_course)
return db_course
def update_course(db: Session, course_id: int, course_update: schemas.CourseUpdate):
db_course = get_course_by_id(db, course_id)
if not db_course:
return None
update_data = course_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_course, field, value)
db.commit()
db.refresh(db_course)
return db_course
def delete_course(db: Session, course_id: int):
db_course = get_course_by_id(db, course_id)
if not db_course:
return False
db.delete(db_course)
db.commit()
return True
def create_course_with_content(db: Session, course_data: schemas.CourseCreateRequest, author_id: int):
"""
Create a course with content (markdown) and structure (modules, lessons, steps)
Always creates draft course with real structure in DB
"""
# Create basic course with status='draft'
db_course = models.Course(
title=course_data.title,
description=course_data.description,
level=course_data.level,
duration=course_data.duration,
content=course_data.content,
author_id=author_id,
status='draft',
)
db.add(db_course)
db.commit()
db.refresh(db_course)
# Extract structure from course_data if provided
structure = course_data.structure or {}
modules_data = structure.get('modules', [])
# If no structure provided, create default module, lesson and step with content
if not modules_data:
# Create default module
default_module = models.CourseModule(
course_id=db_course.id,
title="Введение",
description="Основное содержание курса",
order_index=1
)
db.add(default_module)
db.commit()
db.refresh(default_module)
# Create default lesson
lesson = models.Lesson(
module_id=default_module.id,
title="Общее описание",
order_index=1
)
db.add(lesson)
db.commit()
db.refresh(lesson)
# Create step with content
step = models.LessonStep(
lesson_id=lesson.id,
step_type="theory",
title=course_data.title,
content=course_data.content,
order_index=1
)
db.add(step)
db.commit()
else:
# Create modules, lessons and steps from structure
for module_index, module_data in enumerate(modules_data):
# Create module
module = models.CourseModule(
course_id=db_course.id,
title=module_data.get('title', f"Модуль {module_index + 1}"),
description=module_data.get('description', ''),
duration=module_data.get('duration', ''),
order_index=module_index + 1
)
db.add(module)
db.commit()
db.refresh(module)
# Create lessons in module
lessons_data = module_data.get('lessons', [])
for lesson_index, lesson_data in enumerate(lessons_data):
lesson = models.Lesson(
module_id=module.id,
title=lesson_data.get('title', f"Урок {lesson_index + 1}"),
description=lesson_data.get('description', ''),
order_index=lesson_index + 1
)
db.add(lesson)
db.commit()
db.refresh(lesson)
# Create steps in lesson
steps_data = lesson_data.get('steps', [])
for step_index, step_data in enumerate(steps_data):
step = models.LessonStep(
lesson_id=lesson.id,
step_type=step_data.get('step_type', 'theory'),
title=step_data.get('title', f"Шаг {step_index + 1}"),
content=step_data.get('content', ''),
order_index=step_index + 1
)
db.add(step)
db.commit()
return db_course
# Module operations
def get_module_by_id(db: Session, module_id: int):
return db.query(models.CourseModule).filter(models.CourseModule.id == module_id).first()
def get_course_modules(db: Session, course_id: int):
return db.query(models.CourseModule).filter(
models.CourseModule.course_id == course_id
).order_by(models.CourseModule.order_index).all()
def create_module(db: Session, module: schemas.ModuleCreate):
db_module = models.CourseModule(
course_id=module.course_id,
title=module.title,
description=module.description,
duration=module.duration,
order_index=module.order_index
)
db.add(db_module)
db.commit()
db.refresh(db_module)
return db_module
def update_module(db: Session, module_id: int, module_update: schemas.ModuleUpdate):
db_module = get_module_by_id(db, module_id)
if not db_module:
return None
update_data = module_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_module, field, value)
db.commit()
db.refresh(db_module)
return db_module
def delete_module(db: Session, module_id: int):
db_module = get_module_by_id(db, module_id)
if not db_module:
return False
db.delete(db_module)
db.commit()
return True
# Lesson operations
def get_lesson_by_id(db: Session, lesson_id: int):
return db.query(models.Lesson).filter(models.Lesson.id == lesson_id).first()
def get_module_lessons(db: Session, module_id: int):
return db.query(models.Lesson).filter(
models.Lesson.module_id == module_id
).order_by(models.Lesson.order_index).all()
def create_lesson(db: Session, lesson: schemas.LessonCreate):
db_lesson = models.Lesson(
module_id=lesson.module_id,
title=lesson.title,
description=lesson.description,
order_index=lesson.order_index
)
db.add(db_lesson)
db.commit()
db.refresh(db_lesson)
return db_lesson
def update_lesson(db: Session, lesson_id: int, lesson_update: schemas.LessonUpdate):
db_lesson = get_lesson_by_id(db, lesson_id)
if not db_lesson:
return None
update_data = lesson_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_lesson, field, value)
db.commit()
db.refresh(db_lesson)
return db_lesson
def delete_lesson(db: Session, lesson_id: int):
db_lesson = get_lesson_by_id(db, lesson_id)
if not db_lesson:
return False
db.delete(db_lesson)
db.commit()
return True
# Step operations
def get_step_by_id(db: Session, step_id: int):
return db.query(models.LessonStep).filter(models.LessonStep.id == step_id).first()
def get_lesson_steps(db: Session, lesson_id: int):
return db.query(models.LessonStep).filter(
models.LessonStep.lesson_id == lesson_id
).order_by(models.LessonStep.order_index).all()
def create_step(db: Session, step: schemas.StepCreate):
content_html = render_markdown_to_html(step.content) if step.content else None
db_step = models.LessonStep(
lesson_id=step.lesson_id,
step_type=step.step_type,
title=step.title,
content=step.content,
content_html=content_html,
file_url=step.file_url,
order_index=step.order_index
)
db.add(db_step)
db.commit()
db.refresh(db_step)
return db_step
def update_step(db: Session, step_id: int, step_update: schemas.StepUpdate):
db_step = get_step_by_id(db, step_id)
if not db_step:
return None
update_data = step_update.model_dump(exclude_unset=True)
if 'content' in update_data:
update_data['content_html'] = render_markdown_to_html(update_data['content'])
for field, value in update_data.items():
setattr(db_step, field, value)
db.commit()
db.refresh(db_step)
return db_step
def delete_step(db: Session, step_id: int):
db_step = get_step_by_id(db, step_id)
if not db_step:
return False
db.delete(db_step)
db.commit()
return True
# Stats operations
def get_stats(db: Session):
total_users = db.query(func.count(models.User.id)).scalar()
total_courses = db.query(func.count(models.Course.id)).scalar()
# For now, return the latest course as most popular
# In a real app, you would track course enrollments
most_popular = db.query(models.Course).order_by(desc(models.Course.created_at)).first()
most_popular_title = most_popular.title if most_popular else None
return {
"total_users": total_users or 0,
"total_courses": total_courses or 0,
"most_popular_course": most_popular_title
}
# Favorite courses operations
def get_favorite_courses(db: Session, user_id: int):
return db.query(models.FavoriteCourse).filter(models.FavoriteCourse.user_id == user_id).all()
def get_favorite_course(db: Session, user_id: int, course_id: int):
return db.query(models.FavoriteCourse).filter(
models.FavoriteCourse.user_id == user_id,
models.FavoriteCourse.course_id == course_id
).first()
def add_favorite_course(db: Session, user_id: int, course_id: int):
# Check if already favorited
existing = get_favorite_course(db, user_id, course_id)
if existing:
return existing
db_favorite = models.FavoriteCourse(user_id=user_id, course_id=course_id)
db.add(db_favorite)
db.commit()
db.refresh(db_favorite)
return db_favorite
def remove_favorite_course(db: Session, user_id: int, course_id: int):
favorite = get_favorite_course(db, user_id, course_id)
if not favorite:
return False
db.delete(favorite)
db.commit()
return True
# Progress operations
def get_progress(db: Session, user_id: int, course_id: int):
return db.query(models.Progress).filter(
models.Progress.user_id == user_id,
models.Progress.course_id == course_id
).first()
def get_user_progress(db: Session, user_id: int):
return db.query(models.Progress).filter(models.Progress.user_id == user_id).all()
def create_or_update_progress(db: Session, user_id: int, course_id: int, completed_lessons: int, total_lessons: int):
progress = get_progress(db, user_id, course_id)
if progress:
progress.completed_lessons = completed_lessons
progress.total_lessons = total_lessons
else:
progress = models.Progress(
user_id=user_id,
course_id=course_id,
completed_lessons=completed_lessons,
total_lessons=total_lessons
)
db.add(progress)
db.commit()
db.refresh(progress)
return progress
def delete_progress(db: Session, user_id: int, course_id: int):
progress = get_progress(db, user_id, course_id)
if not progress:
return False
db.delete(progress)
db.commit()
return True
# Course draft operations
def get_course_drafts(db: Session, user_id: int):
return db.query(models.CourseDraft).filter(
models.CourseDraft.user_id == user_id
).order_by(models.CourseDraft.updated_at.desc()).all()
def get_course_draft(db: Session, draft_id: int):
return db.query(models.CourseDraft).filter(models.CourseDraft.id == draft_id).first()
def save_course_draft(db: Session, draft: schemas.CourseDraftCreate, user_id: int):
# Check if draft exists
existing = db.query(models.CourseDraft).filter(
models.CourseDraft.user_id == user_id,
models.CourseDraft.title == draft.title
).first()
if existing:
# Update existing draft
existing.description = draft.description
existing.level = draft.level
existing.content = draft.content
existing.structure = draft.structure or existing.structure
existing.updated_at = func.now()
db.commit()
db.refresh(existing)
return existing
else:
# Create new draft
db_draft = models.CourseDraft(
user_id=user_id,
title=draft.title,
description=draft.description,
level=draft.level,
content=draft.content,
structure=draft.structure
)
db.add(db_draft)
db.commit()
db.refresh(db_draft)
return db_draft
def delete_course_draft(db: Session, draft_id: int, user_id: int):
draft = db.query(models.CourseDraft).filter(
models.CourseDraft.id == draft_id,
models.CourseDraft.user_id == user_id
).first()
if not draft:
return False
db.delete(draft)
db.commit()
return True
# Draft-specific queries using Course.status='draft'
def get_user_drafts(db: Session, user_id: int):
"""Get all draft courses for a user (status='draft')"""
return db.query(models.Course).filter(
models.Course.author_id == user_id,
models.Course.status == 'draft'
).order_by(models.Course.updated_at.desc()).all()
def get_user_draft(db: Session, user_id: int, draft_id: int, include_structure: bool = False):
"""Get a specific draft for a user"""
query = db.query(models.Course).filter(
models.Course.id == draft_id,
models.Course.author_id == user_id,
models.Course.status == 'draft'
)
if include_structure:
# Предзагрузка связей для полной структуры
query = query.options(
joinedload(models.Course.modules).joinedload(models.CourseModule.lessons).joinedload(models.Lesson.steps)
)
return query.first()
def publish_course(db: Session, course_id: int, user_id: int):
"""Publish a draft by changing status to 'published'"""
draft = get_user_draft(db, user_id, course_id)
if not draft:
return None
draft.status = 'published'
db.commit()
db.refresh(draft)
return draft
def delete_user_draft(db: Session, course_id: int, user_id: int):
"""Delete a user's draft course"""
draft = get_user_draft(db, user_id, course_id)
if not draft:
return False
db.delete(draft)
db.commit()
return True
# Test operations
def get_test_questions(db: Session, step_id: int):
"""Get all questions for a test step"""
return db.query(models.TestQuestion).filter(
models.TestQuestion.step_id == step_id
).order_by(models.TestQuestion.order_index).all()
def get_test_question(db: Session, question_id: int):
"""Get a specific test question"""
return db.query(models.TestQuestion).options(
joinedload(models.TestQuestion.answers),
joinedload(models.TestQuestion.match_items)
).filter(
models.TestQuestion.id == question_id
).first()
def create_test_question(db: Session, step_id: int, question: schemas.TestQuestionCreate):
"""Create a new test question with answers/match items"""
db_question = models.TestQuestion(
step_id=step_id,
question_text=question.question_text,
question_type=question.question_type,
points=question.points,
order_index=question.order_index
)
db.add(db_question)
db.flush() # Flush to get the question ID
# Create answers for choice questions
if question.question_type in ['single_choice', 'multiple_choice']:
for answer in question.answers:
db_answer = models.TestAnswer(
question_id=db_question.id,
answer_text=answer.answer_text,
is_correct=answer.is_correct,
order_index=answer.order_index
)
db.add(db_answer)
# Create match items for match questions
if question.question_type == 'match':
for item in question.match_items:
db_item = models.TestMatchItem(
question_id=db_question.id,
left_text=item.left_text,
right_text=item.right_text,
order_index=item.order_index
)
db.add(db_item)
# Store correct answers for text_answer questions in content field
if question.question_type == 'text_answer' and question.correct_answers:
# Store as JSON in question_text temporarily (we'll need a separate field)
# For now, we'll store it in the database and handle it properly
pass
db.commit()
db.refresh(db_question)
return db_question
def update_test_question(db: Session, question_id: int, question_update: schemas.TestQuestionUpdate):
"""Update a test question"""
db_question = get_test_question(db, question_id)
if not db_question:
return None
update_data = question_update.model_dump(exclude_unset=True)
# Update question fields first
if 'question_text' in update_data:
db_question.question_text = update_data['question_text']
if 'question_type' in update_data:
db_question.question_type = update_data['question_type']
if 'points' in update_data:
db_question.points = update_data['points']
if 'order_index' in update_data:
db_question.order_index = update_data['order_index']
# Flush to save question type changes before updating answers
db.flush()
# Update answers if provided
if 'answers' in update_data and db_question.question_type in ['single_choice', 'multiple_choice']:
# Delete existing answers
db.query(models.TestAnswer).filter(
models.TestAnswer.question_id == question_id
).delete()
# Create new answers
for answer_data in update_data['answers']:
db_answer = models.TestAnswer(
question_id=question_id,
answer_text=answer_data.get('answer_text', '') if isinstance(answer_data, dict) else answer_data.answer_text,
is_correct=answer_data.get('is_correct', False) if isinstance(answer_data, dict) else answer_data.is_correct,
order_index=answer_data.get('order_index', 0) if isinstance(answer_data, dict) else answer_data.order_index
)
db.add(db_answer)
# Update match items if provided
if 'match_items' in update_data and db_question.question_type == 'match':
# Delete existing match items
db.query(models.TestMatchItem).filter(
models.TestMatchItem.question_id == question_id
).delete()
# Create new match items
for item_data in update_data['match_items']:
db_item = models.TestMatchItem(
question_id=question_id,
left_text=item_data.get('left_text', '') if isinstance(item_data, dict) else item_data.left_text,
right_text=item_data.get('right_text', '') if isinstance(item_data, dict) else item_data.right_text,
order_index=item_data.get('order_index', 0) if isinstance(item_data, dict) else item_data.order_index
)
db.add(db_item)
db.commit()
# Re-query to get fresh data with relationships
db_question = get_test_question(db, question_id)
return db_question
def delete_test_question(db: Session, question_id: int):
"""Delete a test question"""
db_question = get_test_question(db, question_id)
if not db_question:
return False
db.delete(db_question)
db.commit()
return True
def update_test_settings(db: Session, step_id: int, settings: schemas.TestSettings):
"""Update test settings for a step"""
db_step = get_step_by_id(db, step_id)
if not db_step:
return None
db_step.test_settings = settings.model_dump()
db.commit()
db.refresh(db_step)
return db_step
def create_test_attempt(db: Session, user_id: int, step_id: int, answers: dict):
"""Create a test attempt and calculate score"""
# Get test settings
db_step = get_step_by_id(db, step_id)
if not db_step or not db_step.test_settings:
return None
settings = db_step.test_settings
# Get all questions
questions = get_test_questions(db, step_id)
if not questions:
return None
# Calculate score
total_points = 0
earned_points = 0
for question in questions:
total_points += question.points
question_answer = answers.get(str(question.id))
if not question_answer:
continue
if question.question_type == 'single_choice':
# Single choice: check if selected answer is correct
correct_answer = db.query(models.TestAnswer).filter(
models.TestAnswer.question_id == question.id,
models.TestAnswer.is_correct == True
).first()
if correct_answer and question_answer == correct_answer.id:
earned_points += question.points
elif question.question_type == 'multiple_choice':
# Multiple choice: check if all correct answers are selected
correct_answers = db.query(models.TestAnswer).filter(
models.TestAnswer.question_id == question.id,
models.TestAnswer.is_correct == True
).all()
correct_ids = set([a.id for a in correct_answers])
selected_ids = set(question_answer) if isinstance(question_answer, list) else set()
if correct_ids == selected_ids:
earned_points += question.points
elif question.question_type == 'text_answer':
# Text answer: simple keyword matching (case-insensitive)
# For better implementation, use fuzzy matching or AI
# Store correct answers in question content for now
pass
elif question.question_type == 'match':
# Match: check if all pairs are correct
match_items = db.query(models.TestMatchItem).filter(
models.TestMatchItem.question_id == question.id
).all()
all_correct = True
for item in match_items:
user_match = question_answer.get(str(item.id))
if user_match != item.right_text:
all_correct = False
break
if all_correct:
earned_points += question.points
# Calculate percentage and determine if passed
score_percentage = (earned_points / total_points * 100) if total_points > 0 else 0
passed = score_percentage >= settings.get('passing_score', 70)
# Create attempt record
db_attempt = models.TestAttempt(
user_id=user_id,
step_id=step_id,
score=earned_points,
max_score=total_points,
passed=passed,
answers=answers,
completed_at=datetime.utcnow()
)
db.add(db_attempt)
db.commit()
db.refresh(db_attempt)
return db_attempt
def get_test_attempts(db: Session, user_id: int, step_id: int):
"""Get all test attempts for a user and step"""
return db.query(models.TestAttempt).filter(
models.TestAttempt.user_id == user_id,
models.TestAttempt.step_id == step_id
).order_by(models.TestAttempt.started_at.desc()).all()
def get_test_attempt(db: Session, attempt_id: int):
"""Get a specific test attempt"""
return db.query(models.TestAttempt).filter(
models.TestAttempt.id == attempt_id
).first()