Старт
This commit is contained in:
4
backend/app/__init__.py
Normal file
4
backend/app/__init__.py
Normal 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
112
backend/app/auth.py
Normal 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
771
backend/app/crud.py
Normal 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
18
backend/app/database.py
Normal 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
51
backend/app/main.py
Normal 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"}
|
||||
24
backend/app/middleware/cors.py
Normal file
24
backend/app/middleware/cors.py
Normal 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
187
backend/app/models.py
Normal 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")
|
||||
10
backend/app/routes/__init__.py
Normal file
10
backend/app/routes/__init__.py
Normal 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
104
backend/app/routes/admin.py
Normal 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
105
backend/app/routes/auth.py
Normal 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"
|
||||
)
|
||||
646
backend/app/routes/course_builder.py
Normal file
646
backend/app/routes/course_builder.py
Normal 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"
|
||||
)
|
||||
141
backend/app/routes/courses.py
Normal file
141
backend/app/routes/courses.py
Normal 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})
|
||||
58
backend/app/routes/favorites.py
Normal file
58
backend/app/routes/favorites.py
Normal 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
179
backend/app/routes/learn.py
Normal 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})
|
||||
106
backend/app/routes/progress.py
Normal file
106
backend/app/routes/progress.py
Normal 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"}
|
||||
12
backend/app/routes/stats.py
Normal file
12
backend/app/routes/stats.py
Normal 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
354
backend/app/schemas.py
Normal 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
|
||||
Reference in New Issue
Block a user