Старт

This commit is contained in:
2026-03-13 14:39:43 +08:00
commit a2cc480644
88 changed files with 18526 additions and 0 deletions

4
backend/app/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
from .database import Base, engine, get_db
from . import models, schemas, crud, auth
__all__ = ["Base", "engine", "get_db", "models", "schemas", "crud", "auth"]

112
backend/app/auth.py Normal file
View File

@@ -0,0 +1,112 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi import Depends, HTTPException, status
from sqlalchemy.orm import Session
import os
from .schemas import TokenData, UserResponse
from .database import get_db
from . import crud
# Security settings
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
# Warning for default secret key
import logging
logger = logging.getLogger(__name__)
if SECRET_KEY == "your-secret-key-change-in-production":
logger.warning(
"WARNING: Using default SECRET_KEY! "
"Please provide a secure secret key through environment variables."
)
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
security = HTTPBearer(auto_error=False)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict):
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def create_tokens(email: str):
access_token = create_access_token(data={"sub": email})
refresh_token = create_refresh_token(data={"sub": email})
return access_token, refresh_token
def verify_token(token: str) -> Optional[TokenData]:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
email: str = payload.get("sub")
token_type: str = payload.get("type")
if email is None or token_type != "access":
return None
return TokenData(email=email)
except JWTError:
return None
def get_current_token(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
) -> Optional[TokenData]:
if not credentials:
return None
return verify_token(credentials.credentials)
def get_current_user(
token: Optional[TokenData] = Depends(get_current_token),
db: Session = Depends(get_db)
):
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials"
)
user = crud.get_user_by_email(db, email=token.email)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Convert SQLAlchemy model to Pydantic model
return UserResponse.model_validate(user)
def get_optional_current_user(
token: Optional[TokenData] = Depends(get_current_token),
db: Session = Depends(get_db)
):
"""Returns current user if authenticated, otherwise returns None"""
if not token:
return None
user = crud.get_user_by_email(db, email=token.email)
if not user:
return None
return UserResponse.model_validate(user)

771
backend/app/crud.py Normal file
View File

@@ -0,0 +1,771 @@
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()

18
backend/app/database.py Normal file
View File

@@ -0,0 +1,18 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import os
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://auth_user:auth_password@localhost:5432/auth_learning")
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

51
backend/app/main.py Normal file
View File

@@ -0,0 +1,51 @@
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from .database import engine, Base
from .routes import auth_router, courses_router, stats_router, favorites_router, progress_router, course_builder_router, admin_router, learn_router
from .middleware.cors import setup_cors
import logging
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Create FastAPI app
app = FastAPI(
title="Auth Learning API",
description="API for authentication and course management system",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc"
)
# Setup CORS
setup_cors(app)
# Serve static files (uploads)
from fastapi.staticfiles import StaticFiles
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
# Create database tables
@app.on_event("startup")
async def startup():
logger.info("Creating database tables...")
Base.metadata.create_all(bind=engine)
logger.info("Database tables created")
# Include routers
app.include_router(auth_router, prefix="/api")
app.include_router(courses_router, prefix="/api")
app.include_router(stats_router, prefix="/api")
app.include_router(favorites_router, prefix="/api")
app.include_router(progress_router, prefix="/api")
app.include_router(course_builder_router, prefix="/api")
app.include_router(admin_router, prefix="/api")
app.include_router(learn_router, prefix="/api")
@app.get("/")
async def root():
return RedirectResponse(url="/docs")
@app.get("/health")
async def health_check():
return {"status": "healthy", "service": "auth-learning-api"}

View File

@@ -0,0 +1,24 @@
from fastapi.middleware.cors import CORSMiddleware
import os
def setup_cors(app):
origins = [
"http://localhost",
"http://localhost:80",
"http://localhost:3000",
"http://frontend:80",
]
# Add environment variable if set
allowed_origins = os.getenv("ALLOWED_ORIGINS", "")
if allowed_origins:
origins.extend([origin.strip() for origin in allowed_origins.split(",")])
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"],
)

187
backend/app/models.py Normal file
View File

@@ -0,0 +1,187 @@
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey, Numeric, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from .database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
is_active = Column(Boolean, default=True)
is_developer = Column(Boolean, default=False)
favorite_courses = relationship("FavoriteCourse", back_populates="user", cascade="all, delete-orphan")
progress = relationship("Progress", back_populates="user", cascade="all, delete-orphan")
class Course(Base):
__tablename__ = "courses"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=False)
level = Column(String(50), nullable=False)
duration = Column(Integer, default=0, nullable=False)
content = Column(Text)
author_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
status = Column(String(20), default='draft', nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
author = relationship("User")
modules = relationship("CourseModule", back_populates="course", cascade="all, delete-orphan")
favorites = relationship("FavoriteCourse", back_populates="course", cascade="all, delete-orphan")
progress_records = relationship("Progress", back_populates="course", cascade="all, delete-orphan")
class CourseModule(Base):
__tablename__ = "course_modules"
id = Column(Integer, primary_key=True, index=True)
course_id = Column(Integer, ForeignKey("courses.id", ondelete="CASCADE"), nullable=False)
title = Column(String(255), nullable=False)
description = Column(Text)
duration = Column(String(100)) # e.g., "2 weeks", "10 hours"
order_index = Column(Integer, default=0, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
course = relationship("Course", back_populates="modules")
lessons = relationship("Lesson", back_populates="module", cascade="all, delete-orphan")
class Lesson(Base):
__tablename__ = "lessons"
id = Column(Integer, primary_key=True, index=True)
module_id = Column(Integer, ForeignKey("course_modules.id", ondelete="CASCADE"), nullable=False)
title = Column(String(255), nullable=False)
description = Column(Text)
order_index = Column(Integer, default=0, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
module = relationship("CourseModule", back_populates="lessons")
steps = relationship("LessonStep", back_populates="lesson", cascade="all, delete-orphan")
class LessonStep(Base):
__tablename__ = "lesson_steps"
id = Column(Integer, primary_key=True, index=True)
lesson_id = Column(Integer, ForeignKey("lessons.id", ondelete="CASCADE"), nullable=False)
step_type = Column(String(50), nullable=False)
title = Column(String(255))
content = Column(Text)
content_html = Column(Text)
file_url = Column(String(500))
test_settings = Column(JSON) # {passing_score, max_attempts, time_limit, show_answers}
order_index = Column(Integer, default=0, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
lesson = relationship("Lesson", back_populates="steps")
test_questions = relationship("TestQuestion", back_populates="step", cascade="all, delete-orphan")
test_attempts = relationship("TestAttempt", back_populates="step", cascade="all, delete-orphan")
class FavoriteCourse(Base):
__tablename__ = "favorite_courses"
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
course_id = Column(Integer, ForeignKey("courses.id", ondelete="CASCADE"), primary_key=True)
added_at = Column(DateTime(timezone=True), server_default=func.now())
user = relationship("User", back_populates="favorite_courses")
course = relationship("Course", back_populates="favorites")
class Progress(Base):
__tablename__ = "progress"
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
course_id = Column(Integer, ForeignKey("courses.id", ondelete="CASCADE"), primary_key=True)
completed_lessons = Column(Integer, default=0, nullable=False)
total_lessons = Column(Integer, default=0, nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
user = relationship("User", back_populates="progress")
course = relationship("Course")
class CourseDraft(Base):
__tablename__ = "course_drafts"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
title = Column(String(255), nullable=False)
description = Column(Text)
level = Column(String(50), nullable=False)
content = Column(Text, nullable=False)
structure = Column(Text) # JSON строка для структуры модулей
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
user = relationship("User")
class TestQuestion(Base):
__tablename__ = "test_questions"
id = Column(Integer, primary_key=True, index=True)
step_id = Column(Integer, ForeignKey("lesson_steps.id", ondelete="CASCADE"), nullable=False)
question_text = Column(Text, nullable=False)
question_type = Column(String(50), nullable=False) # single_choice, multiple_choice, text_answer, match
points = Column(Integer, default=1)
order_index = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
step = relationship("LessonStep", back_populates="test_questions")
answers = relationship("TestAnswer", back_populates="question", cascade="all, delete-orphan")
match_items = relationship("TestMatchItem", back_populates="question", cascade="all, delete-orphan")
class TestAnswer(Base):
__tablename__ = "test_answers"
id = Column(Integer, primary_key=True, index=True)
question_id = Column(Integer, ForeignKey("test_questions.id", ondelete="CASCADE"), nullable=False)
answer_text = Column(Text, nullable=False)
is_correct = Column(Boolean, default=False)
order_index = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
question = relationship("TestQuestion", back_populates="answers")
class TestMatchItem(Base):
__tablename__ = "test_match_items"
id = Column(Integer, primary_key=True, index=True)
question_id = Column(Integer, ForeignKey("test_questions.id", ondelete="CASCADE"), nullable=False)
left_text = Column(Text, nullable=False)
right_text = Column(Text, nullable=False)
order_index = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
question = relationship("TestQuestion", back_populates="match_items")
class TestAttempt(Base):
__tablename__ = "test_attempts"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
step_id = Column(Integer, ForeignKey("lesson_steps.id", ondelete="CASCADE"), nullable=False)
score = Column(Numeric(5, 2))
max_score = Column(Numeric(5, 2))
passed = Column(Boolean, default=False)
answers = Column(JSON) # {question_id: answer_id или [answer_ids] или text или {left_id: right_id}}
started_at = Column(DateTime(timezone=True), server_default=func.now())
completed_at = Column(DateTime(timezone=True))
user = relationship("User")
step = relationship("LessonStep", back_populates="test_attempts")

View File

@@ -0,0 +1,10 @@
from .auth import router as auth_router
from .courses import router as courses_router
from .stats import router as stats_router
from .favorites import router as favorites_router
from .progress import router as progress_router
from .course_builder import router as course_builder_router
from .admin import router as admin_router
from .learn import router as learn_router
__all__ = ["auth_router", "courses_router", "stats_router", "favorites_router", "progress_router", "course_builder_router", "admin_router", "learn_router"]

104
backend/app/routes/admin.py Normal file
View File

@@ -0,0 +1,104 @@
from fastapi import APIRouter, Body, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import Dict
from .. import crud, schemas
from ..database import get_db
from ..models import User
import os
router = APIRouter(prefix="/admin", tags=["admin"])
# Проверка админ-секрета через переменную окружения
def verify_admin_secret(admin_secret: str) -> bool:
"""
Проверка admin-секрета
Разрешенные источники:
1. DEVELOPER_SECRET из переменной окружения
2. Можно добавить дополнительные проверки (IP, токен и т.д.)
"""
valid_secret = os.getenv("DEVELOPER_SECRET")
if not valid_secret:
raise HTTPException(status_code=500, detail="DEVELOPER_SECRET not configured")
return admin_secret == valid_secret
@router.post("/users/{user_id}/make-developer")
def make_user_developer(
user_id: int,
admin_secret: str = Body(..., embed=True),
db: Session = Depends(get_db)
):
"""
Назначить статус разработчика для пользователя
Требует admin_secret в запросе
"""
if not verify_admin_secret(admin_secret):
raise HTTPException(status_code=403, detail="Invalid admin secret")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.is_developer = True
db.commit()
db.refresh(user)
return {
"message": f"Developer status enabled for user {user.email}",
"user_id": user.id,
"email": user.email,
"is_developer": True
}
@router.post("/users/{user_id}/remove-developer")
def remove_user_developer(
user_id: int,
admin_secret: str = Body(..., embed=True),
db: Session = Depends(get_db)
):
"""
Убрать статус разработчика у пользователя
Требует admin_secret в запросе
"""
if not verify_admin_secret(admin_secret):
raise HTTPException(status_code=403, detail="Invalid admin secret")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.is_developer = False
db.commit()
db.refresh(user)
return {
"message": f"Developer status removed for user {user.email}",
"user_id": user.id,
"email": user.email,
"is_developer": False
}
@router.get("/users")
def list_users(
admin_secret: str,
db: Session = Depends(get_db)
):
if not verify_admin_secret(admin_secret):
raise HTTPException(status_code=403, detail="Invalid admin secret")
users = db.query(User).all()
return {
"users": [
{
"id": user.id,
"email": user.email,
"is_developer": user.is_developer,
"is_active": user.is_active,
"created_at": user.created_at.isoformat() if user.created_at else None
}
for user in users
]
}

105
backend/app/routes/auth.py Normal file
View File

@@ -0,0 +1,105 @@
from fastapi import APIRouter, Depends, HTTPException, status, Body
from sqlalchemy.orm import Session
from typing import Dict
from .. import crud, schemas, auth
from ..database import get_db
from ..models import User
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=schemas.SuccessResponse)
def register(user: schemas.UserCreate, db: Session = Depends(get_db)):
# Check if user already exists
db_user = crud.get_user_by_email(db, email=user.email)
if db_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Create user
crud.create_user(db=db, user=user)
return schemas.SuccessResponse(
message="User registered successfully",
data={"email": user.email}
)
@router.post("/login", response_model=schemas.SuccessResponse)
def login(credentials: schemas.UserLogin, db: Session = Depends(get_db)):
# Authenticate user
user = crud.authenticate_user(db, credentials.email, credentials.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password"
)
# Create tokens
access_token, refresh_token = auth.create_tokens(user.email)
return schemas.SuccessResponse(
message="Login successful",
data={
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
"user": {
"email": user.email,
"id": user.id,
"created_at": user.created_at.isoformat() if user.created_at else None
}
}
)
@router.post("/logout", response_model=schemas.SuccessResponse)
def logout():
# In a real app, you might want to blacklist the token
return schemas.SuccessResponse(message="Logout successful")
@router.get("/me", response_model=schemas.SuccessResponse)
def get_current_user(
current_user: User = Depends(auth.get_current_user)
):
return schemas.SuccessResponse(
data={
"email": current_user.email,
"id": current_user.id,
"created_at": current_user.created_at.isoformat() if current_user.created_at else None,
"is_active": current_user.is_active,
"is_developer": current_user.is_developer
}
)
@router.put("/me/developer-status", response_model=schemas.SuccessResponse)
def update_developer_status(
is_developer: bool = Body(..., embed=True),
current_user: User = Depends(auth.get_current_user),
db: Session = Depends(get_db)
):
"""
Update developer status for current user
"""
current_user.is_developer = is_developer
db.commit()
return schemas.SuccessResponse(
message=f"Developer status updated to: {is_developer}",
data={"is_developer": is_developer}
)
@router.post("/refresh", response_model=schemas.Token)
def refresh_token(
current_user: User = Depends(auth.get_current_user)
):
"""
Refresh access token using refresh token
"""
# Create new access token for the current user
access_token, refresh_token = auth.create_tokens(current_user.email)
return schemas.Token(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer"
)

View File

@@ -0,0 +1,646 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from sqlalchemy.orm import Session
import shutil
import os
import time
from pathlib import Path
from .. import crud, schemas, auth
from ..database import get_db
from ..models import User, CourseDraft
router = APIRouter(prefix="/course-builder", tags=["course-builder"])
# Configure upload directory - same as in main.py StaticFiles
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
@router.get("/steps/{step_id}", response_model=schemas.SuccessResponse)
def get_step_content(
step_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Get step content by ID
"""
step = crud.get_step_by_id(db, step_id=step_id)
if not step:
raise HTTPException(status_code=404, detail="Step not found")
# Check if user has access to the course
# TODO: Implement proper access check
# For now, allow access if user is authenticated
step_response = schemas.StepResponse.from_orm(step)
return schemas.SuccessResponse(data={"step": step_response})
@router.put("/steps/{step_id}/content", response_model=schemas.SuccessResponse)
def update_step_content(
step_id: int,
content_update: schemas.StepUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Update step content (markdown)
"""
step = crud.get_step_by_id(db, step_id=step_id)
if not step:
raise HTTPException(status_code=404, detail="Step not found")
# Check if user is the author of the course
# TODO: Implement proper authorization
# For now, allow if user is authenticated
updated_step = crud.update_step(db, step_id=step_id, step_update=content_update)
if not updated_step:
raise HTTPException(status_code=500, detail="Failed to update step")
step_response = schemas.StepResponse.from_orm(updated_step)
return schemas.SuccessResponse(
data={"step": step_response},
message="Step content updated successfully"
)
@router.post("/upload", response_model=schemas.SuccessResponse)
async def upload_file(
file: UploadFile = File(...),
current_user: User = Depends(auth.get_current_user)
):
"""
Upload file (image, document, etc.) for use in course content
"""
# Validate file type
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.pdf', '.doc', '.docx', '.txt', '.md'}
file_extension = Path(file.filename).suffix.lower()
if file_extension not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"File type not allowed. Allowed types: {', '.join(allowed_extensions)}"
)
# Generate unique filename
unique_filename = f"{current_user.id}_{int(time.time())}_{file.filename}"
file_path = UPLOAD_DIR / unique_filename
# Save file
try:
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to save file: {str(e)}")
# Return file URL
file_url = f"/uploads/{unique_filename}"
return schemas.SuccessResponse(
data={
"filename": file.filename,
"url": file_url,
"size": file_path.stat().st_size
},
message="File uploaded successfully"
)
@router.get("/render/markdown", response_model=schemas.SuccessResponse)
def render_markdown(
markdown: str = Form(...),
current_user: User = Depends(auth.get_current_user)
):
"""
Render markdown to HTML (server-side rendering)
This can be used for preview functionality
"""
# Simple markdown rendering (basic conversion)
# In production, use a proper markdown library like markdown2 or mistune
html_content = markdown.replace("\n", "<br>")
return schemas.SuccessResponse(
data={"html": html_content},
message="Markdown rendered (basic conversion)"
)
@router.get("/courses/{course_id}/structure", response_model=schemas.SuccessResponse)
def get_course_structure(
course_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Get complete course structure for editing
"""
course = crud.get_course_by_id(db, course_id=course_id, include_structure=True)
if not course:
raise HTTPException(status_code=404, detail="Course not found")
# Check if user is the author
# Note: Add is_admin field to User model for admin access
if course.author_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to edit this course")
# Convert to response schema
course_structure = schemas.CourseStructureResponse.from_orm(course)
return schemas.SuccessResponse(data={"course": course_structure})
@router.post("/steps/{step_id}/autosave", response_model=schemas.SuccessResponse)
def autosave_step_content(
step_id: int,
content: str = Form(...),
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Autosave step content (frequent saves during editing)
"""
step = crud.get_step_by_id(db, step_id=step_id)
if not step:
raise HTTPException(status_code=404, detail="Step not found")
# Update content only
step_update = schemas.StepUpdate(content=content)
updated_step = crud.update_step(db, step_id=step_id, step_update=step_update)
if not updated_step:
raise HTTPException(status_code=500, detail="Failed to autosave")
return schemas.SuccessResponse(
data={"step_id": step_id, "saved_at": updated_step.updated_at},
message="Content autosaved"
)
@router.post("/create", response_model=schemas.SuccessResponse)
async def create_course(
course_data: schemas.CourseCreateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Create a new course with content and structure
"""
try:
db_course = crud.create_course_with_content(db, course_data, current_user.id)
course_response = schemas.CourseResponse.from_orm(db_course)
return schemas.SuccessResponse(
data={"course": course_response},
message="Course created successfully"
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to create course: {str(e)}"
)
# Draft endpoints - now using Course.status='draft'
@router.get("/drafts", response_model=schemas.SuccessResponse)
def get_drafts(
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Get all draft courses for current user (status='draft')
"""
drafts = crud.get_user_drafts(db, current_user.id)
draft_responses = [schemas.CourseResponse.from_orm(d) for d in drafts]
return schemas.SuccessResponse(
data={"drafts": draft_responses, "total": len(draft_responses)},
message="Drafts retrieved successfully"
)
@router.get("/drafts/{draft_id}", response_model=schemas.SuccessResponse)
def get_draft(
draft_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Get a specific draft course with full structure
"""
draft = crud.get_user_draft(db, current_user.id, draft_id, include_structure=True)
if not draft:
raise HTTPException(status_code=404, detail="Draft not found")
draft_data = {
"id": draft.id,
"title": draft.title,
"description": draft.description,
"level": draft.level,
"duration": draft.duration,
"content": draft.content,
"status": draft.status,
"author_id": draft.author_id,
"created_at": draft.created_at.isoformat() if draft.created_at else None,
"updated_at": draft.updated_at.isoformat() if draft.updated_at else None,
"modules": [
{
"id": module.id,
"title": module.title,
"description": module.description,
"duration": module.duration,
"order_index": module.order_index,
"lessons": [
{
"id": lesson.id,
"title": lesson.title,
"description": lesson.description,
"order_index": lesson.order_index,
"steps": [
{
"id": step.id,
"step_type": step.step_type,
"title": step.title,
"content": step.content,
"order_index": step.order_index
} for step in lesson.steps
] if lesson.steps else []
}
for lesson in module.lessons
] if module.lessons else []
}
for module in draft.modules
] if draft.modules else []
}
return schemas.SuccessResponse(data={"draft": draft_data})
@router.put("/drafts/{draft_id}", response_model=schemas.SuccessResponse)
def update_draft(
draft_id: int,
course_update: schemas.CourseUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Update a draft course - replaces old /drafts/save endpoint
"""
draft = crud.get_user_draft(db, current_user.id, draft_id)
if not draft:
raise HTTPException(status_code=404, detail="Draft not found")
updated_draft = crud.update_course(db, draft_id, course_update)
draft_response = schemas.CourseResponse.from_orm(updated_draft)
return schemas.SuccessResponse(
data={"draft": draft_response, "saved_at": updated_draft.updated_at},
message="Draft updated successfully"
)
@router.delete("/drafts/{draft_id}", response_model=schemas.SuccessResponse)
def delete_draft(
draft_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Delete a draft course
"""
deleted = crud.delete_user_draft(db, draft_id, current_user.id)
if not deleted:
raise HTTPException(status_code=404, detail="Draft not found")
return schemas.SuccessResponse(message="Draft deleted successfully")
@router.post("/drafts/{draft_id}/publish", response_model=schemas.SuccessResponse)
def publish_draft(
draft_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Publish a draft by changing status to 'published'
"""
published_course = crud.publish_course(db, draft_id, current_user.id)
if not published_course:
raise HTTPException(status_code=404, detail="Draft not found")
return schemas.SuccessResponse(
data={"course": schemas.CourseResponse.from_orm(published_course)},
message="Course published successfully"
)
# Module endpoints
@router.post("/modules", response_model=schemas.SuccessResponse)
def create_module(
module: schemas.ModuleCreate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Create a new module for a course
"""
# Check if user has access to the course (optional: add course_id validation)
created_module = crud.create_module(db, module)
module_response = schemas.ModuleResponse.from_orm(created_module)
return schemas.SuccessResponse(
data={"module": module_response},
message="Module created successfully"
)
@router.get("/modules/{module_id}", response_model=schemas.SuccessResponse)
def get_module(
module_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Get a module by ID
"""
module = crud.get_module_by_id(db, module_id)
if not module:
raise HTTPException(status_code=404, detail="Module not found")
# TODO: Check if user has access to this module
module_response = schemas.ModuleResponse.from_orm(module)
return schemas.SuccessResponse(data={"module": module_response})
# Lesson endpoints
@router.post("/lessons", response_model=schemas.SuccessResponse)
def create_lesson(
lesson: schemas.LessonCreate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Create a new lesson for a module
"""
# Validate that module exists
crud.get_module_by_id(db, lesson.module_id)
created_lesson = crud.create_lesson(db, lesson)
lesson_response = schemas.LessonResponse.from_orm(created_lesson)
return schemas.SuccessResponse(
data={"lesson": lesson_response},
message="Lesson created successfully"
)
@router.get("/lessons/{lesson_id}", response_model=schemas.SuccessResponse)
def get_lesson(
lesson_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Get a lesson by ID with steps
"""
lesson = crud.get_lesson_by_id(db, lesson_id)
if not lesson:
raise HTTPException(status_code=404, detail="Lesson not found")
lesson_data = {
"id": lesson.id,
"module_id": lesson.module_id,
"title": lesson.title,
"description": lesson.description,
"order_index": lesson.order_index,
"created_at": lesson.created_at,
"updated_at": lesson.updated_at,
"steps": [
{
"id": step.id,
"step_type": step.step_type,
"title": step.title,
"content": step.content,
"content_html": step.content_html,
"file_url": step.file_url,
"order_index": step.order_index
}
for step in sorted(lesson.steps, key=lambda s: s.order_index)
] if lesson.steps else []
}
return schemas.SuccessResponse(data={"lesson": lesson_data})
# Update endpoints
@router.put("/modules/{module_id}", response_model=schemas.SuccessResponse)
def update_module(
module_id: int,
module_update: schemas.ModuleUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Update a module
"""
updated_module = crud.update_module(db, module_id, module_update)
if not updated_module:
raise HTTPException(status_code=404, detail="Module not found")
module_response = schemas.ModuleResponse.from_orm(updated_module)
return schemas.SuccessResponse(
data={"module": module_response},
message="Module updated successfully"
)
@router.delete("/modules/{module_id}", response_model=schemas.SuccessResponse)
def delete_module(
module_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Delete a module
"""
deleted = crud.delete_module(db, module_id)
if not deleted:
raise HTTPException(status_code=404, detail="Module not found")
return schemas.SuccessResponse(message="Module deleted successfully")
@router.put("/lessons/{lesson_id}", response_model=schemas.SuccessResponse)
def update_lesson(
lesson_id: int,
lesson_update: schemas.LessonUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Update a lesson
"""
updated_lesson = crud.update_lesson(db, lesson_id, lesson_update)
if not updated_lesson:
raise HTTPException(status_code=404, detail="Lesson not found")
lesson_response = schemas.LessonResponse.from_orm(updated_lesson)
return schemas.SuccessResponse(
data={"lesson": lesson_response},
message="Lesson updated successfully"
)
@router.delete("/lessons/{lesson_id}", response_model=schemas.SuccessResponse)
def delete_lesson(
lesson_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Delete a lesson
"""
deleted = crud.delete_lesson(db, lesson_id)
if not deleted:
raise HTTPException(status_code=404, detail="Lesson not found")
return schemas.SuccessResponse(message="Lesson deleted successfully")
# Step endpoints
@router.post("/steps", response_model=schemas.SuccessResponse)
def create_step(
step: schemas.StepCreate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Create a new step for a lesson
"""
lesson = crud.get_lesson_by_id(db, step.lesson_id)
if not lesson:
raise HTTPException(status_code=404, detail="Lesson not found")
created_step = crud.create_step(db, step)
step_response = schemas.StepResponse.from_orm(created_step)
return schemas.SuccessResponse(
data={"step": step_response},
message="Step created successfully"
)
@router.put("/steps/{step_id}", response_model=schemas.SuccessResponse)
def update_step(
step_id: int,
step_update: schemas.StepUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Update a step (full update including type, title, content, order)
"""
updated_step = crud.update_step(db, step_id, step_update)
if not updated_step:
raise HTTPException(status_code=404, detail="Step not found")
step_response = schemas.StepResponse.from_orm(updated_step)
return schemas.SuccessResponse(
data={"step": step_response},
message="Step updated successfully"
)
@router.delete("/steps/{step_id}", response_model=schemas.SuccessResponse)
def delete_step(
step_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Delete a step
"""
deleted = crud.delete_step(db, step_id)
if not deleted:
raise HTTPException(status_code=404, detail="Step not found")
return schemas.SuccessResponse(message="Step deleted successfully")
# Test endpoints
@router.post("/steps/{step_id}/test/questions", response_model=schemas.SuccessResponse)
def create_test_question(
step_id: int,
question: schemas.TestQuestionCreate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Create a new test question
"""
step = crud.get_step_by_id(db, step_id)
if not step:
raise HTTPException(status_code=404, detail="Step not found")
if step.step_type != 'test':
raise HTTPException(status_code=400, detail="Step is not a test")
created_question = crud.create_test_question(db, step_id, question)
question_response = schemas.TestQuestionResponse.from_orm(created_question)
return schemas.SuccessResponse(
data={"question": question_response},
message="Question created successfully"
)
@router.get("/steps/{step_id}/test/questions", response_model=schemas.SuccessResponse)
def get_test_questions(
step_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Get all questions for a test
"""
step = crud.get_step_by_id(db, step_id)
if not step:
raise HTTPException(status_code=404, detail="Step not found")
questions = crud.get_test_questions(db, step_id)
questions_response = [schemas.TestQuestionResponse.from_orm(q) for q in questions]
return schemas.SuccessResponse(
data={"questions": questions_response, "settings": step.test_settings}
)
@router.put("/steps/{step_id}/test/questions/{question_id}", response_model=schemas.SuccessResponse)
def update_test_question(
step_id: int,
question_id: int,
question_update: schemas.TestQuestionUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Update a test question
"""
updated_question = crud.update_test_question(db, question_id, question_update)
if not updated_question:
raise HTTPException(status_code=404, detail="Question not found")
question_response = schemas.TestQuestionResponse.from_orm(updated_question)
return schemas.SuccessResponse(
data={"question": question_response},
message="Question updated successfully"
)
@router.delete("/steps/{step_id}/test/questions/{question_id}", response_model=schemas.SuccessResponse)
def delete_test_question(
step_id: int,
question_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Delete a test question
"""
deleted = crud.delete_test_question(db, question_id)
if not deleted:
raise HTTPException(status_code=404, detail="Question not found")
return schemas.SuccessResponse(message="Question deleted successfully")
@router.put("/steps/{step_id}/test/settings", response_model=schemas.SuccessResponse)
def update_test_settings(
step_id: int,
settings: schemas.TestSettings,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Update test settings (passing score, max attempts, time limit, show answers)
"""
updated_step = crud.update_test_settings(db, step_id, settings)
if not updated_step:
raise HTTPException(status_code=404, detail="Step not found")
return schemas.SuccessResponse(
data={"settings": updated_step.test_settings},
message="Test settings updated successfully"
)

View File

@@ -0,0 +1,141 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from .. import crud, schemas, auth
from ..database import get_db
from ..models import User
router = APIRouter(prefix="/courses", tags=["courses"])
@router.get("/", response_model=schemas.SuccessResponse)
def get_courses(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=100),
level: Optional[str] = Query(None, pattern="^(beginner|intermediate|advanced)$"),
db: Session = Depends(get_db)
):
# Get published courses for all users (including guests)
courses = crud.get_courses(db, skip=skip, limit=limit, level=level, published_only=True)
# Convert SQLAlchemy models to Pydantic models
course_responses = [schemas.CourseResponse.from_orm(course) for course in courses]
return schemas.SuccessResponse(
data={
"courses": course_responses,
"total": len(courses),
"skip": skip,
"limit": limit,
"level": level
}
)
@router.get("/{course_id}", response_model=schemas.SuccessResponse)
def get_course(
course_id: int,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(auth.get_optional_current_user)
):
course = crud.get_course_by_id(db, course_id=course_id)
if not course:
raise HTTPException(
status_code=404,
detail="Course not found"
)
# Check access based on status
course_status = getattr(course, 'status', 'draft')
author_id = getattr(course, 'author_id', None)
if course_status == 'published':
pass # Anyone can access published course
elif course_status == 'draft':
# Only author can access drafts
if current_user is None or author_id != current_user.id:
raise HTTPException(
status_code=403,
detail="Not authorized to access this draft"
)
else:
# Archived - author-only access
if current_user is None or author_id != current_user.id:
raise HTTPException(
status_code=403,
detail="Not authorized to access this course"
)
# Convert SQLAlchemy model to Pydantic model
course_response = schemas.CourseResponse.from_orm(course)
return schemas.SuccessResponse(data={"course": course_response})
@router.put("/{course_id}", response_model=schemas.SuccessResponse)
def update_course(
course_id: int,
course_update: schemas.CourseUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
course = crud.get_course_by_id(db, course_id)
if not course:
raise HTTPException(status_code=404, detail="Course not found")
if course.author_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to update this course")
updated_course = crud.update_course(db, course_id, course_update)
course_response = schemas.CourseResponse.from_orm(updated_course)
return schemas.SuccessResponse(data={"course": course_response})
@router.get("/{course_id}/structure", response_model=schemas.SuccessResponse)
def get_course_structure(
course_id: int,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(auth.get_optional_current_user)
):
course = crud.get_course_by_id(db, course_id=course_id, include_structure=True)
if not course:
raise HTTPException(status_code=404, detail="Course not found")
if course.status != 'published':
if current_user is None or course.author_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
course_data = {
"id": course.id,
"title": course.title,
"description": course.description,
"level": course.level,
"duration": course.duration,
"status": course.status,
"modules": [
{
"id": module.id,
"title": module.title,
"description": module.description,
"duration": module.duration,
"order_index": module.order_index,
"lessons": [
{
"id": lesson.id,
"title": lesson.title,
"description": lesson.description,
"order_index": lesson.order_index,
"steps": [
{
"id": step.id,
"step_type": step.step_type,
"title": step.title,
"order_index": step.order_index
} for step in sorted(lesson.steps, key=lambda s: s.order_index)
] if lesson.steps else []
}
for lesson in sorted(module.lessons, key=lambda l: l.order_index)
] if module.lessons else []
}
for module in sorted(course.modules, key=lambda m: m.order_index)
] if course.modules else []
}
return schemas.SuccessResponse(data={"course": course_data})

View File

@@ -0,0 +1,58 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from .. import crud, schemas, auth
from ..database import get_db
from ..auth import get_current_user
router = APIRouter(prefix="/favorites", tags=["favorites"])
@router.get("/", response_model=List[schemas.FavoriteResponse])
def get_my_favorites(
db: Session = Depends(get_db),
current_user: schemas.UserResponse = Depends(get_current_user)
):
favorites = crud.get_favorite_courses(db, user_id=current_user.id)
# Convert SQLAlchemy models to Pydantic models
return [schemas.FavoriteResponse.model_validate(favorite) for favorite in favorites]
@router.post("/", response_model=schemas.FavoriteResponse)
def add_to_favorites(
favorite: schemas.FavoriteCreate,
db: Session = Depends(get_db),
current_user: schemas.UserResponse = Depends(get_current_user)
):
# Check if course exists
course = crud.get_course_by_id(db, favorite.course_id)
if not course:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Course not found"
)
# Add to favorites
db_favorite = crud.add_favorite_course(db, user_id=current_user.id, course_id=favorite.course_id)
return schemas.FavoriteResponse.model_validate(db_favorite)
@router.delete("/{course_id}", response_model=schemas.SuccessResponse)
def remove_from_favorites(
course_id: int,
db: Session = Depends(get_db),
current_user: schemas.UserResponse = Depends(get_current_user)
):
# Check if course exists
course = crud.get_course_by_id(db, course_id)
if not course:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Course not found"
)
success = crud.remove_favorite_course(db, user_id=current_user.id, course_id=course_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Course not in favorites"
)
return {"success": True, "message": "Course removed from favorites"}

179
backend/app/routes/learn.py Normal file
View File

@@ -0,0 +1,179 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import Optional
from .. import crud, schemas, auth
from ..database import get_db
from ..models import User
router = APIRouter(prefix="/learn", tags=["learning"])
@router.get("/steps/{step_id}", response_model=schemas.SuccessResponse)
def get_step_content(
step_id: int,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(auth.get_optional_current_user)
):
step = crud.get_step_by_id(db, step_id=step_id)
if not step:
raise HTTPException(status_code=404, detail="Step not found")
lesson = crud.get_lesson_by_id(db, step.lesson_id)
if not lesson:
raise HTTPException(status_code=404, detail="Lesson not found")
module = crud.get_module_by_id(db, lesson.module_id)
if not module:
raise HTTPException(status_code=404, detail="Module not found")
from ..models import Course
course = db.query(Course).filter(Course.id == module.course_id).first()
if not course:
raise HTTPException(status_code=404, detail="Course not found")
if course.status != 'published':
if current_user is None or course.author_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
return schemas.SuccessResponse(data={
"step": {
"id": step.id,
"step_type": step.step_type,
"title": step.title,
"content": step.content,
"content_html": step.content_html,
"file_url": step.file_url,
"test_settings": step.test_settings,
"order_index": step.order_index
}
})
@router.get("/steps/{step_id}/test", response_model=schemas.SuccessResponse)
def get_test_for_viewing(
step_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Get test questions for viewing (without correct answers)
"""
step = crud.get_step_by_id(db, step_id)
if not step:
raise HTTPException(status_code=404, detail="Step not found")
if step.step_type != 'test':
raise HTTPException(status_code=400, detail="Step is not a test")
questions = crud.get_test_questions(db, step_id)
# Prepare questions without correct answers
questions_for_viewer = []
for q in questions:
question_data = {
"id": q.id,
"question_text": q.question_text,
"question_type": q.question_type,
"points": q.points,
"order_index": q.order_index
}
if q.question_type in ['single_choice', 'multiple_choice']:
# Return answers without is_correct flag
question_data["answers"] = [
{"id": a.id, "answer_text": a.answer_text, "order_index": a.order_index}
for a in q.answers
]
elif q.question_type == 'match':
# Return match items shuffled
question_data["match_items"] = [
{"id": m.id, "left_text": m.left_text, "right_text": m.right_text, "order_index": m.order_index}
for m in q.match_items
]
questions_for_viewer.append(question_data)
# Get user's previous attempts
attempts = crud.get_test_attempts(db, current_user.id, step_id)
attempts_data = [
{
"id": a.id,
"score": float(a.score),
"max_score": float(a.max_score),
"passed": a.passed,
"completed_at": a.completed_at.isoformat() if a.completed_at else None
}
for a in attempts
]
return schemas.SuccessResponse(data={
"questions": questions_for_viewer,
"settings": step.test_settings,
"attempts": attempts_data
})
@router.post("/steps/{step_id}/test/submit", response_model=schemas.SuccessResponse)
def submit_test(
step_id: int,
attempt: schemas.TestAttemptCreate,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Submit test answers and get results
"""
step = crud.get_step_by_id(db, step_id)
if not step:
raise HTTPException(status_code=404, detail="Step not found")
if step.step_type != 'test':
raise HTTPException(status_code=400, detail="Step is not a test")
# Check max attempts limit
settings = step.test_settings or {}
max_attempts = settings.get('max_attempts', 0)
if max_attempts > 0:
attempts = crud.get_test_attempts(db, current_user.id, step_id)
if len(attempts) >= max_attempts:
raise HTTPException(status_code=400, detail="Maximum attempts reached")
# Create attempt and calculate score
test_attempt = crud.create_test_attempt(db, current_user.id, step_id, attempt.answers)
if not test_attempt:
raise HTTPException(status_code=500, detail="Failed to create test attempt")
return schemas.SuccessResponse(data={
"attempt": {
"id": test_attempt.id,
"score": float(test_attempt.score),
"max_score": float(test_attempt.max_score),
"passed": test_attempt.passed,
"completed_at": test_attempt.completed_at.isoformat() if test_attempt.completed_at else None
}
})
@router.get("/steps/{step_id}/test/attempts", response_model=schemas.SuccessResponse)
def get_test_attempts(
step_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(auth.get_current_user)
):
"""
Get user's test attempts history
"""
step = crud.get_step_by_id(db, step_id)
if not step:
raise HTTPException(status_code=404, detail="Step not found")
attempts = crud.get_test_attempts(db, current_user.id, step_id)
attempts_data = [
{
"id": a.id,
"score": float(a.score),
"max_score": float(a.max_score),
"passed": a.passed,
"completed_at": a.completed_at.isoformat() if a.completed_at else None
}
for a in attempts
]
return schemas.SuccessResponse(data={"attempts": attempts_data})

View File

@@ -0,0 +1,106 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from .. import crud, schemas, auth
from ..database import get_db
from ..auth import get_current_user
router = APIRouter(prefix="/progress", tags=["progress"])
@router.get("/", response_model=List[schemas.ProgressResponse])
def get_my_progress(
db: Session = Depends(get_db),
current_user: schemas.UserResponse = Depends(get_current_user)
):
progress_list = crud.get_user_progress(db, user_id=current_user.id)
# Calculate percentage for each progress
result = []
for progress in progress_list:
percentage = (progress.completed_lessons / progress.total_lessons * 100) if progress.total_lessons > 0 else 0
result.append({
"user_id": progress.user_id,
"course_id": progress.course_id,
"completed_lessons": progress.completed_lessons,
"total_lessons": progress.total_lessons,
"percentage": percentage,
"updated_at": progress.updated_at
})
return result
@router.get("/{course_id}", response_model=schemas.ProgressResponse)
def get_course_progress(
course_id: int,
db: Session = Depends(get_db),
current_user: schemas.UserResponse = Depends(get_current_user)
):
progress = crud.get_progress(db, user_id=current_user.id, course_id=course_id)
if not progress:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Progress not found for this course"
)
percentage = (progress.completed_lessons / progress.total_lessons * 100) if progress.total_lessons > 0 else 0
return {
"user_id": progress.user_id,
"course_id": progress.course_id,
"completed_lessons": progress.completed_lessons,
"total_lessons": progress.total_lessons,
"percentage": percentage,
"updated_at": progress.updated_at
}
@router.post("/{course_id}", response_model=schemas.ProgressResponse)
def update_progress(
course_id: int,
progress_update: schemas.ProgressUpdate,
db: Session = Depends(get_db),
current_user: schemas.UserResponse = Depends(get_current_user)
):
# Check if course exists
course = crud.get_course_by_id(db, course_id)
if not course:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Course not found"
)
# Validate completed_lessons <= total_lessons
if progress_update.completed_lessons > progress_update.total_lessons:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Completed lessons cannot exceed total lessons"
)
progress = crud.create_or_update_progress(
db,
user_id=current_user.id,
course_id=course_id,
completed_lessons=progress_update.completed_lessons,
total_lessons=progress_update.total_lessons
)
percentage = (progress.completed_lessons / progress.total_lessons * 100) if progress.total_lessons > 0 else 0
return {
"user_id": progress.user_id,
"course_id": progress.course_id,
"completed_lessons": progress.completed_lessons,
"total_lessons": progress.total_lessons,
"percentage": percentage,
"updated_at": progress.updated_at
}
@router.delete("/{course_id}", response_model=schemas.SuccessResponse)
def delete_progress(
course_id: int,
db: Session = Depends(get_db),
current_user: schemas.UserResponse = Depends(get_current_user)
):
success = crud.delete_progress(db, user_id=current_user.id, course_id=course_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Progress not found"
)
return {"success": True, "message": "Progress deleted"}

View File

@@ -0,0 +1,12 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import crud, schemas
from ..database import get_db
router = APIRouter(prefix="/stats", tags=["stats"])
@router.get("/", response_model=schemas.SuccessResponse)
def get_stats(db: Session = Depends(get_db)):
stats = crud.get_stats(db)
return schemas.SuccessResponse(data=stats)

354
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,354 @@
from pydantic import BaseModel, EmailStr, Field
from typing import Optional, List, Dict, Any
from datetime import datetime
# User schemas
class UserBase(BaseModel):
email: EmailStr
class UserCreate(UserBase):
password: str = Field(..., min_length=8, max_length=100)
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserResponse(UserBase):
id: int
created_at: datetime
is_active: bool
is_developer: bool
class Config:
from_attributes = True
# Token schemas
class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
email: Optional[str] = None
# Course schemas
class CourseBase(BaseModel):
title: str = Field(..., max_length=255)
description: str
level: str = Field(..., pattern="^(beginner|intermediate|advanced)$")
duration: int = Field(0, ge=0)
status: str = Field('draft', pattern="^(draft|published|archived)$")
class CourseCreate(CourseBase):
pass
# Course builder schemas
class CourseCreateRequest(BaseModel):
title: str = Field(..., max_length=255)
description: str
level: str = Field(..., pattern="^(beginner|intermediate|advanced)$")
duration: int = Field(..., ge=0)
content: str = Field(..., min_length=1)
structure: Optional[dict] = None
class CourseUpdate(BaseModel):
title: Optional[str] = Field(None, max_length=255)
description: Optional[str] = None
level: Optional[str] = Field(None, pattern="^(beginner|intermediate|advanced)$")
duration: Optional[int] = Field(None, ge=0)
content: Optional[str] = None
status: Optional[str] = Field(None, pattern="^(draft|published|archived)$")
class CourseResponse(CourseBase):
id: int
author_id: int
content: Optional[str] = None
status: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Course response with full structure
class CourseStructureResponse(CourseBase):
id: int
author_id: int
content: Optional[str] = None
status: str
created_at: datetime
updated_at: datetime
modules: list['ModuleResponse'] = []
class Config:
from_attributes = True
# Module schemas
class ModuleBase(BaseModel):
title: str = Field(..., max_length=255)
description: Optional[str] = None
duration: Optional[str] = Field(None, max_length=100)
order_index: int = 0
class ModuleCreate(ModuleBase):
course_id: int
class ModuleUpdate(BaseModel):
title: Optional[str] = Field(None, max_length=255)
description: Optional[str] = None
duration: Optional[str] = Field(None, max_length=100)
order_index: Optional[int] = None
class ModuleResponse(ModuleBase):
id: int
course_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Lesson schemas
class LessonBase(BaseModel):
title: str = Field(..., max_length=255)
description: Optional[str] = None
order_index: int = 0
class LessonCreate(LessonBase):
module_id: int
class LessonUpdate(BaseModel):
title: Optional[str] = Field(None, max_length=255)
description: Optional[str] = None
order_index: Optional[int] = None
class LessonResponse(LessonBase):
id: int
module_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Lesson Step schemas
class StepBase(BaseModel):
step_type: str = Field(..., pattern="^(theory|practice|lab|test|presentation)$")
title: Optional[str] = Field(None, max_length=255)
content: Optional[str] = None
content_html: Optional[str] = None
file_url: Optional[str] = Field(None, max_length=500)
order_index: int = 0
class StepCreate(StepBase):
lesson_id: int
class StepUpdate(BaseModel):
step_type: Optional[str] = Field(None, pattern="^(theory|practice|lab|test|presentation)$")
title: Optional[str] = Field(None, max_length=255)
content: Optional[str] = None
content_html: Optional[str] = None
file_url: Optional[str] = Field(None, max_length=500)
order_index: Optional[int] = None
class StepResponse(StepBase):
id: int
lesson_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Test schemas
class TestSettings(BaseModel):
passing_score: int = Field(70, ge=0, le=100)
max_attempts: int = Field(0, ge=0)
time_limit: int = Field(0, ge=0)
show_answers: bool = False
class TestAnswerBase(BaseModel):
answer_text: str
is_correct: bool = False
order_index: int = 0
class TestAnswerCreate(TestAnswerBase):
pass
class TestAnswerResponse(TestAnswerBase):
id: int
question_id: int
created_at: datetime
class Config:
from_attributes = True
class TestMatchItemBase(BaseModel):
left_text: str
right_text: str
order_index: int = 0
class TestMatchItemCreate(TestMatchItemBase):
pass
class TestMatchItemResponse(TestMatchItemBase):
id: int
question_id: int
created_at: datetime
class Config:
from_attributes = True
class TestQuestionBase(BaseModel):
question_text: str
question_type: str = Field(..., pattern="^(single_choice|multiple_choice|text_answer|match)$")
points: int = Field(1, ge=1)
order_index: int = 0
class TestQuestionCreate(TestQuestionBase):
answers: List[TestAnswerCreate] = []
match_items: List[TestMatchItemCreate] = []
correct_answers: List[str] = [] # Для text_answer
class TestQuestionUpdate(BaseModel):
question_text: Optional[str] = None
question_type: Optional[str] = Field(None, pattern="^(single_choice|multiple_choice|text_answer|match)$")
points: Optional[int] = Field(None, ge=1)
order_index: Optional[int] = None
answers: Optional[List[TestAnswerCreate]] = None
match_items: Optional[List[TestMatchItemCreate]] = None
correct_answers: Optional[List[str]] = None
class TestQuestionResponse(TestQuestionBase):
id: int
step_id: int
created_at: datetime
updated_at: datetime
answers: List[TestAnswerResponse] = []
match_items: List[TestMatchItemResponse] = []
class Config:
from_attributes = True
class TestQuestionForViewer(TestQuestionResponse):
"""Вопрос без правильных ответов для отображения студенту"""
answers: List[dict] = [] # {id, answer_text, order_index} без is_correct
match_items: List[TestMatchItemResponse] = []
class TestAttemptCreate(BaseModel):
step_id: int
answers: Dict[str, Any] # {question_id: answer_id или [answer_ids] или text или {left_id: right_id}}
class TestAttemptResponse(BaseModel):
id: int
score: float
max_score: float
passed: bool
completed_at: datetime
class Config:
from_attributes = True
class TestAttemptWithDetailsResponse(TestAttemptResponse):
"""Попытка с деталями для просмотра результатов"""
answers: Dict[str, Any]
# Full course structure schemas
class CourseStructureResponse(CourseResponse):
modules: list["ModuleWithLessonsResponse"] = []
class ModuleWithLessonsResponse(ModuleResponse):
lessons: list["LessonWithStepsResponse"] = []
class LessonWithStepsResponse(LessonResponse):
steps: list[StepResponse] = []
# Update forward references
CourseStructureResponse.update_forward_refs()
ModuleWithLessonsResponse.update_forward_refs()
LessonWithStepsResponse.update_forward_refs()
# Favorite schemas
class FavoriteBase(BaseModel):
course_id: int
class FavoriteCreate(FavoriteBase):
pass
class FavoriteResponse(FavoriteBase):
user_id: int
added_at: datetime
class Config:
from_attributes = True
# Progress schemas
class ProgressBase(BaseModel):
course_id: int
completed_lessons: int = Field(..., ge=0)
total_lessons: int = Field(..., gt=0)
class ProgressCreate(ProgressBase):
pass
class ProgressUpdate(BaseModel):
completed_lessons: int = Field(..., ge=0)
total_lessons: int = Field(..., gt=0)
class ProgressResponse(ProgressBase):
user_id: int
percentage: float
updated_at: datetime
class Config:
from_attributes = True
# Stats schemas
class StatsResponse(BaseModel):
total_users: int
total_courses: int
most_popular_course: Optional[str] = None
# Response schemas
class SuccessResponse(BaseModel):
success: bool = True
data: Optional[dict] = None
message: Optional[str] = None
# ErrorResponse
class ErrorResponse(BaseModel):
success: bool = False
error: str
details: Optional[dict] = None
# Course builder schemas
class CourseCreateRequest(BaseModel):
title: str = Field(..., max_length=255)
description: str
level: str = Field(..., pattern="^(beginner|intermediate|advanced)$")
duration: int = Field(..., ge=0)
content: str = Field(..., min_length=1)
structure: Optional[dict] = None
# Course draft schemas
class CourseDraftBase(BaseModel):
title: str = Field(..., max_length=255)
description: str
level: str = Field(..., pattern="^(beginner|intermediate|advanced)$")
content: str
class CourseDraftCreate(CourseDraftBase):
structure: Optional[str] = None # JSON string
class CourseDraftUpdate(CourseDraftCreate):
pass
class CourseDraftResponse(CourseDraftBase):
id: int
user_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True