Старт

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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