277 lines
10 KiB
Python
277 lines
10 KiB
Python
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)
|