from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.core.deps import require_role from app.db.database import get_db from app.db.models import User, Class_ from app.schemas.assignment import ( AssignmentCreate, AssignmentUpdate, AssignmentOut, AssignmentDetailOut, SubmissionGrade, SubmissionOut, ) from app.schemas.common import PageResponse from app.services.assignment_service import ( create_assignment, update_assignment, delete_assignment, get_assignment_by_id, list_assignments, add_attachments, create_submission, get_submission_by_student, grade_submission, list_submissions, ) from app.services.cos_service import upload_file router = APIRouter(prefix="/api/assignments", tags=["assignments"]) async def _get_roster_count(db: AsyncSession, class_id: int) -> int: from app.db.models import StudentRoster result = await db.execute( select(func.count(StudentRoster.id)).where(StudentRoster.class_id == class_id) ) return result.scalar() or 0 def _build_assignment_out(a: any, user_id: int, total_members: int = 0) -> AssignmentOut: submission_count = len(a.submissions) if a.submissions else 0 my_submitted = any(s.student_id == user_id for s in (a.submissions or [])) return AssignmentOut( id=a.id, class_id=a.class_id, creator_id=a.creator_id, creator_name=a.creator.name if a.creator else "Unknown", title=a.title, description=a.description, deadline=a.deadline, attachment_urls=a.get_attachment_urls_list(), status=a.status, submission_count=submission_count, total_members=total_members, my_submitted=my_submitted, created_at=a.created_at, updated_at=a.updated_at, ) def _build_submission_out(s: any) -> SubmissionOut: return SubmissionOut( id=s.id, assignment_id=s.assignment_id, student_id=s.student_id, student_name=s.student.name if s.student else "Unknown", notes=s.notes, file_url=s.file_url, file_name=s.file_name, file_type=s.file_type, file_size=s.file_size, grade=s.grade, feedback=s.feedback, graded_at=s.graded_at, created_at=s.created_at, updated_at=s.updated_at, ) @router.get("/", response_model=PageResponse[AssignmentOut]) async def get_assignments( page: int = 1, page_size: int = 20, class_id: int | None = None, user: User = Depends(require_role("super_admin", "class_admin", "student")), db: AsyncSession = Depends(get_db), ): effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id if effective_class_id is None: return PageResponse(items=[], total=0, page=page, page_size=page_size, total_pages=0) assignments, total = await list_assignments(db, effective_class_id, page, page_size) total_pages = (total + page_size - 1) // page_size roster_count = await _get_roster_count(db, effective_class_id) items = [_build_assignment_out(a, user.id, roster_count) for a in assignments] return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages) @router.post("/", response_model=AssignmentOut) async def create_new_assignment( data: AssignmentCreate, class_id: int | None = None, user: User = Depends(require_role("super_admin", "class_admin")), db: AsyncSession = Depends(get_db), ): effective_class_id = class_id if user.role == "super_admin" and class_id else user.class_id if effective_class_id is None: raise HTTPException(status_code=400, detail="You are not assigned to a class") assignment = await create_assignment(db, effective_class_id, user.id, data) roster_count = await _get_roster_count(db, effective_class_id) return _build_assignment_out(assignment, user.id, roster_count) @router.post("/{assignment_id}/attachments") async def upload_assignment_attachments( assignment_id: int, files: list[UploadFile] = File(...), user: User = Depends(require_role("super_admin", "class_admin")), db: AsyncSession = Depends(get_db), ): assignment = await get_assignment_by_id(db, assignment_id) if assignment is None: raise HTTPException(status_code=404, detail="Assignment not found") if user.role != "super_admin" and assignment.class_id != user.class_id: raise HTTPException(status_code=403, detail="Access denied") urls = [] for f in files: contents = await f.read() if len(contents) > 20 * 1024 * 1024: # 20MB limit raise HTTPException(status_code=400, detail=f"File {f.filename} too large (max 20MB)") url = upload_file( f"assignments/{assignment_id}", f.filename or "file", contents, f.content_type or "application/octet-stream", ) urls.append(url) await add_attachments(db, assignment, urls) return {"attachment_urls": urls} @router.get("/{assignment_id}", response_model=AssignmentDetailOut) async def get_assignment_detail( assignment_id: int, user: User = Depends(require_role("super_admin", "class_admin", "student")), db: AsyncSession = Depends(get_db), ): assignment = await get_assignment_by_id(db, assignment_id) if assignment is None: raise HTTPException(status_code=404, detail="Assignment not found") if user.role != "super_admin" and assignment.class_id != user.class_id: raise HTTPException(status_code=403, detail="Access denied") base = _build_assignment_out(assignment, user.id, await _get_roster_count(db, assignment.class_id)) # Student only sees their own submission if user.role == "student": my_submission = None for s in (assignment.submissions or []): if s.student_id == user.id: my_submission = _build_submission_out(s) break return AssignmentDetailOut(**base.model_dump(), submissions=[my_submission] if my_submission else []) # Admin sees all submissions submissions = [_build_submission_out(s) for s in (assignment.submissions or [])] return AssignmentDetailOut(**base.model_dump(), submissions=submissions) @router.put("/{assignment_id}", response_model=AssignmentOut) async def update_existing_assignment( assignment_id: int, data: AssignmentUpdate, user: User = Depends(require_role("super_admin", "class_admin")), db: AsyncSession = Depends(get_db), ): assignment = await get_assignment_by_id(db, assignment_id) if assignment is None: raise HTTPException(status_code=404, detail="Assignment not found") if user.role != "super_admin" and assignment.class_id != user.class_id: raise HTTPException(status_code=403, detail="Access denied") updated = await update_assignment(db, assignment, data) return _build_assignment_out(updated, user.id, await _get_roster_count(db, updated.class_id)) @router.delete("/{assignment_id}") async def delete_existing_assignment( assignment_id: int, user: User = Depends(require_role("super_admin", "class_admin")), db: AsyncSession = Depends(get_db), ): assignment = await get_assignment_by_id(db, assignment_id) if assignment is None: raise HTTPException(status_code=404, detail="Assignment not found") if user.role != "super_admin" and assignment.class_id != user.class_id: raise HTTPException(status_code=403, detail="Access denied") await delete_assignment(db, assignment) return {"message": "Assignment deleted"} @router.post("/{assignment_id}/submit", response_model=SubmissionOut) async def submit_assignment( assignment_id: int, notes: str = "", file: UploadFile = File(...), user: User = Depends(require_role("super_admin", "class_admin", "student")), db: AsyncSession = Depends(get_db), ): assignment = await get_assignment_by_id(db, assignment_id) if assignment is None: raise HTTPException(status_code=404, detail="Assignment not found") if user.role != "super_admin" and assignment.class_id != user.class_id: raise HTTPException(status_code=403, detail="Access denied") # Upload file file_url = None file_name = None file_type = None file_size = None if file.filename: contents = await file.read() if len(contents) > 50 * 1024 * 1024: # 50MB limit raise HTTPException(status_code=400, detail="File too large (max 50MB)") file_url = upload_file( f"submissions/{assignment_id}/{user.id}", file.filename, contents, file.content_type or "application/octet-stream", ) file_name = file.filename file_type = file.content_type file_size = len(contents) try: submission = await create_submission( db, assignment_id, user.id, notes=notes or None, file_url=file_url, file_name=file_name, file_type=file_type, file_size=file_size, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) return _build_submission_out(submission) @router.put("/submissions/{submission_id}/grade", response_model=SubmissionOut) async def grade_assignment_submission( submission_id: int, data: SubmissionGrade, user: User = Depends(require_role("super_admin", "class_admin")), db: AsyncSession = Depends(get_db), ): from sqlalchemy import select from app.db.models import AssignmentSubmission result = await db.execute( select(AssignmentSubmission) .where(AssignmentSubmission.id == submission_id) ) submission = result.scalar_one_or_none() if submission is None: raise HTTPException(status_code=404, detail="Submission not found") graded = await grade_submission(db, submission, data) # Reload with student relationship from sqlalchemy.orm import selectinload result = await db.execute( select(AssignmentSubmission) .options(selectinload(AssignmentSubmission.student)) .where(AssignmentSubmission.id == graded.id) ) graded = result.scalar_one() return _build_submission_out(graded)