From a39fd6186c97cf2f6767ac09bc4c336aa6a46fc7 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Sun, 12 Apr 2026 11:56:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=96=B0=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 4 +- backend/app/api/assignments.py | 263 +++++++++++++++++++++ backend/app/api/timeline.py | 194 +++++++++++---- backend/app/api/votes.py | 177 ++++++++++++++ backend/app/db/models.py | 192 ++++++++++++++- backend/app/main.py | 4 +- backend/app/schemas/assignment.py | 62 +++++ backend/app/schemas/timeline.py | 18 ++ backend/app/schemas/vote.py | 51 ++++ backend/app/services/assignment_service.py | 179 ++++++++++++++ backend/app/services/timeline_service.py | 106 ++++++++- backend/app/services/vote_service.py | 141 +++++++++++ 12 files changed, 1336 insertions(+), 55 deletions(-) create mode 100644 backend/app/api/assignments.py create mode 100644 backend/app/api/votes.py create mode 100644 backend/app/schemas/assignment.py create mode 100644 backend/app/schemas/vote.py create mode 100644 backend/app/services/assignment_service.py create mode 100644 backend/app/services/vote_service.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1be01db..bf5e79e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -13,7 +13,9 @@ "Bash(test:*)", "Bash(/Users/aaron/source_code/hku-icb-class/backend/.venv/bin/python:*)", "Bash(.venv/bin/python:*)", - "Bash(backend/.venv/bin/pip show:*)" + "Bash(backend/.venv/bin/pip show:*)", + "Bash(uv run python:*)", + "Bash(grep:*)" ] } } diff --git a/backend/app/api/assignments.py b/backend/app/api/assignments.py new file mode 100644 index 0000000..ddb95c0 --- /dev/null +++ b/backend/app/api/assignments.py @@ -0,0 +1,263 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_role +from app.db.database import get_db +from app.db.models import User +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"]) + + +def _build_assignment_out(a: any, user_id: int) -> 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, + 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 + items = [_build_assignment_out(a, user.id) 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) + return _build_assignment_out(assignment, user.id) + + +@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) + + # 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) + + +@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) diff --git a/backend/app/api/timeline.py b/backend/app/api/timeline.py index 47dfdcb..1bbda36 100644 --- a/backend/app/api/timeline.py +++ b/backend/app/api/timeline.py @@ -1,10 +1,13 @@ -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import require_role from app.db.database import get_db from app.db.models import User -from app.schemas.timeline import TimelineCreate, TimelineUpdate, TimelineOut +from app.schemas.timeline import ( + TimelineCreate, TimelineUpdate, TimelineOut, + TimelineCommentCreate, TimelineCommentOut, +) from app.schemas.common import PageResponse from app.services.timeline_service import ( create_timeline, @@ -13,12 +16,52 @@ from app.services.timeline_service import ( get_timeline_by_id, list_timelines, add_images_to_timeline, + toggle_like, + create_comment, + delete_comment, + get_comment_by_id, + list_comments, ) from app.services.cos_service import upload_image router = APIRouter(prefix="/api/timeline", tags=["timeline"]) +def _build_timeline_out(p, user_id: int, include_comments: bool = False) -> TimelineOut: + like_count = len(p.likes) if p.likes else 0 + has_liked = any(l.user_id == user_id for l in (p.likes or [])) + comment_count = len(p.comments) if p.comments else 0 + comments_out = None + if include_comments and p.comments: + comments_out = [ + TimelineCommentOut( + id=c.id, + post_id=c.post_id, + author_id=c.author_id, + author_name=c.author.name if c.author else "Unknown", + content=c.content, + created_at=c.created_at, + updated_at=c.updated_at, + ) + for c in p.comments + ] + return TimelineOut( + id=p.id, + class_id=p.class_id, + author_id=p.author_id, + author_name=p.author.name if p.author else "Unknown", + title=p.title, + content=p.content, + image_urls=p.get_image_urls_list(), + like_count=like_count, + has_liked=has_liked, + comment_count=comment_count, + comments=comments_out, + created_at=p.created_at, + updated_at=p.updated_at, + ) + + @router.get("/", response_model=PageResponse[TimelineOut]) async def get_timelines( page: int = 1, @@ -34,21 +77,7 @@ async def get_timelines( posts, total = await list_timelines(db, effective_class_id, page, page_size) total_pages = (total + page_size - 1) // page_size - items = [] - for p in posts: - items.append( - TimelineOut( - id=p.id, - class_id=p.class_id, - author_id=p.author_id, - author_name=p.author.name if p.author else "Unknown", - title=p.title, - content=p.content, - image_urls=p.get_image_urls_list(), - created_at=p.created_at, - updated_at=p.updated_at, - ) - ) + items = [_build_timeline_out(p, user.id) for p in posts] return PageResponse( items=items, total=total, page=page, page_size=page_size, total_pages=total_pages @@ -59,7 +88,7 @@ async def get_timelines( async def create_new_timeline( data: TimelineCreate, class_id: int | None = None, - user: User = Depends(require_role("super_admin", "class_admin")), + 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 @@ -75,6 +104,9 @@ async def create_new_timeline( title=post.title, content=post.content, image_urls=[], + like_count=0, + has_liked=False, + comment_count=0, created_at=post.created_at, updated_at=post.updated_at, ) @@ -84,31 +116,27 @@ async def create_new_timeline( async def upload_timeline_images( post_id: int, files: list[UploadFile] = File(...), - user: User = Depends(require_role("super_admin", "class_admin")), + user: User = Depends(require_role("super_admin", "class_admin", "student")), db: AsyncSession = Depends(get_db), ): post = await get_timeline_by_id(db, post_id) if post is None: raise HTTPException(status_code=404, detail="Timeline post not found") - # Verify post belongs to user's class (super_admin can access any) + # Student can only upload to own post; admin can upload to any in their class + if user.role == "student" and post.author_id != user.id: + raise HTTPException(status_code=403, detail="Access denied") if user.role != "super_admin" and post.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) > 10 * 1024 * 1024: # 10MB limit - raise HTTPException( - status_code=400, detail=f"File {f.filename} too large (max 10MB)" - ) + if len(contents) > 10 * 1024 * 1024: + raise HTTPException(status_code=400, detail=f"File {f.filename} too large (max 10MB)") if f.content_type not in {"image/jpeg", "image/png", "image/gif", "image/webp"}: - raise HTTPException( - status_code=400, detail=f"File {f.filename} has invalid type" - ) - url = upload_image( - f"timeline/{post_id}", f.filename or "image.jpg", contents, f.content_type - ) + raise HTTPException(status_code=400, detail=f"File {f.filename} has invalid type") + url = upload_image(f"timeline/{post_id}", f.filename or "image.jpg", contents, f.content_type) urls.append(url) await add_images_to_timeline(db, post, urls) @@ -119,33 +147,85 @@ async def upload_timeline_images( async def update_existing_timeline( post_id: int, data: TimelineUpdate, - user: User = Depends(require_role("super_admin", "class_admin")), + user: User = Depends(require_role("super_admin", "class_admin", "student")), db: AsyncSession = Depends(get_db), ): post = await get_timeline_by_id(db, post_id) if post is None: raise HTTPException(status_code=404, detail="Timeline post not found") + if user.role == "student" and post.author_id != user.id: + raise HTTPException(status_code=403, detail="Access denied") if user.role != "super_admin" and post.class_id != user.class_id: raise HTTPException(status_code=403, detail="Access denied") updated = await update_timeline(db, post, data) - return TimelineOut( - id=updated.id, - class_id=updated.class_id, - author_id=updated.author_id, - author_name=user.name, - title=updated.title, - content=updated.content, - image_urls=updated.get_image_urls_list(), - created_at=updated.created_at, - updated_at=updated.updated_at, - ) + return _build_timeline_out(updated, user.id) @router.delete("/{post_id}") async def delete_existing_timeline( post_id: int, - user: User = Depends(require_role("super_admin", "class_admin")), + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + post = await get_timeline_by_id(db, post_id) + if post is None: + raise HTTPException(status_code=404, detail="Timeline post not found") + if user.role == "student" and post.author_id != user.id: + raise HTTPException(status_code=403, detail="Access denied") + if user.role != "super_admin" and post.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + await delete_timeline(db, post) + return {"message": "Timeline post deleted"} + + +# --- Like & Comment endpoints --- + +@router.post("/{post_id}/like") +async def like_timeline_post( + post_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + post = await get_timeline_by_id(db, post_id) + if post is None: + raise HTTPException(status_code=404, detail="Timeline post not found") + if user.role != "super_admin" and post.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + return await toggle_like(db, post_id, user.id) + + +@router.get("/{post_id}/comments", response_model=PageResponse[TimelineCommentOut]) +async def get_post_comments( + post_id: int, + page: int = 1, + page_size: int = 50, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + comments, total = await list_comments(db, post_id, page, page_size) + total_pages = (total + page_size - 1) // page_size + items = [ + TimelineCommentOut( + id=c.id, + post_id=c.post_id, + author_id=c.author_id, + author_name=c.author.name if c.author else "Unknown", + content=c.content, + created_at=c.created_at, + updated_at=c.updated_at, + ) + for c in comments + ] + return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages) + + +@router.post("/{post_id}/comments", response_model=TimelineCommentOut) +async def add_post_comment( + post_id: int, + data: TimelineCommentCreate, + user: User = Depends(require_role("super_admin", "class_admin", "student")), db: AsyncSession = Depends(get_db), ): post = await get_timeline_by_id(db, post_id) @@ -154,5 +234,29 @@ async def delete_existing_timeline( if user.role != "super_admin" and post.class_id != user.class_id: raise HTTPException(status_code=403, detail="Access denied") - await delete_timeline(db, post) - return {"message": "Timeline post deleted"} + comment = await create_comment(db, post_id, user.id, data) + return TimelineCommentOut( + id=comment.id, + post_id=comment.post_id, + author_id=comment.author_id, + author_name=comment.author.name if comment.author else "Unknown", + content=comment.content, + created_at=comment.created_at, + updated_at=comment.updated_at, + ) + + +@router.delete("/comments/{comment_id}") +async def delete_timeline_comment( + comment_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + comment = await get_comment_by_id(db, comment_id) + if comment is None: + raise HTTPException(status_code=404, detail="Comment not found") + if user.role == "student" and comment.author_id != user.id: + raise HTTPException(status_code=403, detail="Access denied") + + await delete_comment(db, comment) + return {"message": "Comment deleted"} diff --git a/backend/app/api/votes.py b/backend/app/api/votes.py new file mode 100644 index 0000000..59137ab --- /dev/null +++ b/backend/app/api/votes.py @@ -0,0 +1,177 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_role +from app.db.database import get_db +from app.db.models import User +from app.schemas.vote import VoteCreate, VoteUpdate, VoteSubmit, VoteOptionOut, VoteOut +from app.schemas.common import PageResponse +from app.services.vote_service import ( + create_vote, + get_vote_by_id, + list_votes, + submit_vote, + close_vote, + delete_vote, +) + +router = APIRouter(prefix="/api/votes", tags=["votes"]) + + +def _build_vote_out(vote: any, user_id: int) -> VoteOut: + # Compute per-option stats + options_out = [] + for opt in (vote.options or []): + responses = opt.responses or [] + vote_count = len(responses) + voter_names = None + if not vote.is_anonymous: + voter_names = [r.voter.name for r in responses if r.voter] + options_out.append(VoteOptionOut( + id=opt.id, + content=opt.content, + sort_order=opt.sort_order, + vote_count=vote_count, + voter_names=voter_names, + )) + + # Total unique voters + all_voter_ids = set() + my_option_ids = [] + for opt in (vote.options or []): + for r in (opt.responses or []): + all_voter_ids.add(r.voter_id) + if r.voter_id == user_id: + my_option_ids.append(opt.id) + + return VoteOut( + id=vote.id, + class_id=vote.class_id, + creator_id=vote.creator_id, + creator_name=vote.creator.name if vote.creator else "Unknown", + title=vote.title, + description=vote.description, + vote_type=vote.vote_type, + is_anonymous=vote.is_anonymous, + max_choices=vote.max_choices, + deadline=vote.deadline, + status=vote.status, + total_voters=len(all_voter_ids), + has_voted=user_id in all_voter_ids, + my_option_ids=my_option_ids if my_option_ids else None, + options=options_out, + created_at=vote.created_at, + updated_at=vote.updated_at, + ) + + +@router.get("/", response_model=PageResponse[VoteOut]) +async def get_votes( + 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) + + votes, total = await list_votes(db, effective_class_id, page, page_size) + total_pages = (total + page_size - 1) // page_size + items = [_build_vote_out(v, user.id) for v in votes] + return PageResponse(items=items, total=total, page=page, page_size=page_size, total_pages=total_pages) + + +@router.post("/", response_model=VoteOut) +async def create_new_vote( + data: VoteCreate, + class_id: int | None = None, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + if len(data.options) < 2: + raise HTTPException(status_code=400, detail="至少需要 2 个选项") + if data.vote_type == "multiple" and data.max_choices < 2: + raise HTTPException(status_code=400, detail="多选投票最多可选数不能小于 2") + + 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") + + vote = await create_vote(db, effective_class_id, user.id, data) + # Reload with relationships + vote = await get_vote_by_id(db, vote.id) + return _build_vote_out(vote, user.id) + + +@router.get("/{vote_id}", response_model=VoteOut) +async def get_vote_detail( + vote_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + vote = await get_vote_by_id(db, vote_id) + if vote is None: + raise HTTPException(status_code=404, detail="Vote not found") + if user.role != "super_admin" and vote.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + return _build_vote_out(vote, user.id) + + +@router.post("/{vote_id}/submit") +async def submit_vote_response( + vote_id: int, + data: VoteSubmit, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + vote = await get_vote_by_id(db, vote_id) + if vote is None: + raise HTTPException(status_code=404, detail="Vote not found") + if user.role != "super_admin" and vote.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + try: + await submit_vote(db, vote_id, user.id, data.option_ids) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + return {"message": "投票成功"} + + +@router.put("/{vote_id}/close") +async def close_vote_endpoint( + vote_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + vote = await get_vote_by_id(db, vote_id) + if vote is None: + raise HTTPException(status_code=404, detail="Vote not found") + # Only creator or admin can close + if user.role == "student" and vote.creator_id != user.id: + raise HTTPException(status_code=403, detail="只有创建者或管理员可以关闭投票") + if user.role != "super_admin" and vote.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + await close_vote(db, vote) + return {"message": "投票已关闭"} + + +@router.delete("/{vote_id}") +async def delete_vote_endpoint( + vote_id: int, + user: User = Depends(require_role("super_admin", "class_admin", "student")), + db: AsyncSession = Depends(get_db), +): + vote = await get_vote_by_id(db, vote_id) + if vote is None: + raise HTTPException(status_code=404, detail="Vote not found") + if user.role == "student" and vote.creator_id != user.id: + raise HTTPException(status_code=403, detail="只有创建者或管理员可以删除投票") + if user.role != "super_admin" and vote.class_id != user.class_id: + raise HTTPException(status_code=403, detail="Access denied") + + await delete_vote(db, vote) + return {"message": "投票已删除"} diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 47fc1b2..932b734 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -1,7 +1,7 @@ import json from datetime import datetime -from sqlalchemy import String, Text, Integer, DateTime, Boolean, ForeignKey, func +from sqlalchemy import String, Text, Integer, DateTime, Boolean, ForeignKey, func, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base @@ -36,6 +36,12 @@ class Class_(Base): roster: Mapped[list["StudentRoster"]] = relationship( "StudentRoster", back_populates="class_", cascade="all, delete-orphan" ) + assignments: Mapped[list["Assignment"]] = relationship( + "Assignment", back_populates="class_", cascade="all, delete-orphan" + ) + votes: Mapped[list["Vote"]] = relationship( + "Vote", back_populates="class_", cascade="all, delete-orphan" + ) class User(Base): @@ -75,6 +81,15 @@ class User(Base): timeline_posts: Mapped[list["Timeline"]] = relationship( "Timeline", back_populates="author" ) + created_assignments: Mapped[list["Assignment"]] = relationship( + "Assignment", back_populates="creator" + ) + assignment_submissions: Mapped[list["AssignmentSubmission"]] = relationship( + "AssignmentSubmission", back_populates="student" + ) + created_votes: Mapped[list["Vote"]] = relationship( + "Vote", back_populates="creator" + ) def get_skills_list(self) -> list[str]: if not self.skills_tags: @@ -108,6 +123,12 @@ class Timeline(Base): class_: Mapped["Class_"] = relationship("Class_", back_populates="timelines") author: Mapped["User"] = relationship("User", back_populates="timeline_posts") + likes: Mapped[list["TimelineLike"]] = relationship( + "TimelineLike", back_populates="post", cascade="all, delete-orphan" + ) + comments: Mapped[list["TimelineComment"]] = relationship( + "TimelineComment", back_populates="post", cascade="all, delete-orphan" + ) def get_image_urls_list(self) -> list[str]: if not self.image_urls: @@ -224,3 +245,172 @@ class StudentRoster(Base): class_: Mapped["Class_"] = relationship("Class_", back_populates="roster") user: Mapped["User | None"] = relationship("User") + + +class TimelineLike(Base): + __tablename__ = "timeline_likes" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + post_id: Mapped[int] = mapped_column( + Integer, ForeignKey("timelines.id"), nullable=False, index=True + ) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + post: Mapped["Timeline"] = relationship("Timeline", back_populates="likes") + user: Mapped["User"] = relationship("User") + + __table_args__ = ( + UniqueConstraint("post_id", "user_id", name="uq_timeline_like"), + ) + + +class TimelineComment(Base): + __tablename__ = "timeline_comments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + post_id: Mapped[int] = mapped_column( + Integer, ForeignKey("timelines.id"), nullable=False, index=True + ) + author_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + content: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + post: Mapped["Timeline"] = relationship("Timeline", back_populates="comments") + author: Mapped["User"] = relationship("User") + + +class Vote(Base): + __tablename__ = "votes" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + class_id: Mapped[int] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=False, index=True + ) + creator_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + vote_type: Mapped[str] = mapped_column(String(20), default="single", nullable=False) # single | multiple + is_anonymous: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + max_choices: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + deadline: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + status: Mapped[str] = mapped_column(String(20), default="open", nullable=False) # open | closed + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + class_: Mapped["Class_"] = relationship("Class_", back_populates="votes") + creator: Mapped["User"] = relationship("User", back_populates="created_votes") + options: Mapped[list["VoteOption"]] = relationship( + "VoteOption", back_populates="vote", cascade="all, delete-orphan" + ) + + +class VoteOption(Base): + __tablename__ = "vote_options" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + vote_id: Mapped[int] = mapped_column( + Integer, ForeignKey("votes.id"), nullable=False, index=True + ) + content: Mapped[str] = mapped_column(String(500), nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + vote: Mapped["Vote"] = relationship("Vote", back_populates="options") + responses: Mapped[list["VoteResponse"]] = relationship( + "VoteResponse", back_populates="option", cascade="all, delete-orphan" + ) + + +class VoteResponse(Base): + __tablename__ = "vote_responses" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + vote_id: Mapped[int] = mapped_column( + Integer, ForeignKey("votes.id"), nullable=False, index=True + ) + option_id: Mapped[int] = mapped_column( + Integer, ForeignKey("vote_options.id"), nullable=False, index=True + ) + voter_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + option: Mapped["VoteOption"] = relationship("VoteOption", back_populates="responses") + voter: Mapped["User"] = relationship("User") + + +class Assignment(Base): + __tablename__ = "assignments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + class_id: Mapped[int] = mapped_column( + Integer, ForeignKey("classes.id"), nullable=False, index=True + ) + creator_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + title: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + deadline: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + attachment_urls: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array + status: Mapped[str] = mapped_column(String(20), default="open", nullable=False) # open | closed + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + class_: Mapped["Class_"] = relationship("Class_", back_populates="assignments") + creator: Mapped["User"] = relationship("User", back_populates="created_assignments") + submissions: Mapped[list["AssignmentSubmission"]] = relationship( + "AssignmentSubmission", back_populates="assignment", cascade="all, delete-orphan" + ) + + def get_attachment_urls_list(self) -> list[str]: + if not self.attachment_urls: + return [] + try: + return json.loads(self.attachment_urls) + except (json.JSONDecodeError, TypeError): + return [] + + def set_attachment_urls_list(self, urls: list[str]): + self.attachment_urls = json.dumps(urls) if urls else None + + +class AssignmentSubmission(Base): + __tablename__ = "assignment_submissions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + assignment_id: Mapped[int] = mapped_column( + Integer, ForeignKey("assignments.id"), nullable=False, index=True + ) + student_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False + ) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + file_url: Mapped[str | None] = mapped_column(Text, nullable=True) + file_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + file_type: Mapped[str | None] = mapped_column(String(50), nullable=True) + file_size: Mapped[int | None] = mapped_column(Integer, nullable=True) + grade: Mapped[str | None] = mapped_column(String(50), nullable=True) + feedback: Mapped[str | None] = mapped_column(Text, nullable=True) + graded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="submissions") + student: Mapped["User"] = relationship("User", back_populates="assignment_submissions") diff --git a/backend/app/main.py b/backend/app/main.py index 4a10041..dc431d2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.config import settings from app.db.database import create_tables -from app.api import auth, users, classes, directory, timeline, schedule, upload, announcements, resources, notifications +from app.api import auth, users, classes, directory, timeline, schedule, upload, announcements, resources, notifications, votes, assignments logging.basicConfig( level=logging.DEBUG if settings.debug else logging.INFO, @@ -90,6 +90,8 @@ app.include_router(upload.router) app.include_router(announcements.router) app.include_router(resources.router) app.include_router(notifications.router) +app.include_router(votes.router) +app.include_router(assignments.router) @app.get("/api/health") diff --git a/backend/app/schemas/assignment.py b/backend/app/schemas/assignment.py new file mode 100644 index 0000000..095b5c0 --- /dev/null +++ b/backend/app/schemas/assignment.py @@ -0,0 +1,62 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class AssignmentCreate(BaseModel): + title: str + description: str | None = None + deadline: datetime | None = None + + +class AssignmentUpdate(BaseModel): + title: str | None = None + description: str | None = None + deadline: datetime | None = None + status: str | None = None # open | closed + + +class SubmissionCreate(BaseModel): + notes: str | None = None + + +class SubmissionGrade(BaseModel): + grade: str + feedback: str | None = None + + +class SubmissionOut(BaseModel): + id: int + assignment_id: int + student_id: int + student_name: str + notes: str | None + file_url: str | None + file_name: str | None + file_type: str | None + file_size: int | None + grade: str | None + feedback: str | None + graded_at: datetime | None + created_at: datetime + updated_at: datetime + + +class AssignmentOut(BaseModel): + id: int + class_id: int + creator_id: int + creator_name: str + title: str + description: str | None + deadline: datetime | None + attachment_urls: list[str] | None + status: str + submission_count: int + my_submitted: bool + created_at: datetime + updated_at: datetime + + +class AssignmentDetailOut(AssignmentOut): + submissions: list[SubmissionOut] | None = None diff --git a/backend/app/schemas/timeline.py b/backend/app/schemas/timeline.py index 0538b8f..d8278e1 100644 --- a/backend/app/schemas/timeline.py +++ b/backend/app/schemas/timeline.py @@ -13,6 +13,20 @@ class TimelineUpdate(BaseModel): content: str | None = None +class TimelineCommentCreate(BaseModel): + content: str + + +class TimelineCommentOut(BaseModel): + id: int + post_id: int + author_id: int + author_name: str + content: str + created_at: datetime + updated_at: datetime + + class TimelineOut(BaseModel): id: int class_id: int @@ -21,5 +35,9 @@ class TimelineOut(BaseModel): title: str content: str | None image_urls: list[str] | None + like_count: int + has_liked: bool + comment_count: int + comments: list[TimelineCommentOut] | None = None created_at: datetime updated_at: datetime diff --git a/backend/app/schemas/vote.py b/backend/app/schemas/vote.py new file mode 100644 index 0000000..059a692 --- /dev/null +++ b/backend/app/schemas/vote.py @@ -0,0 +1,51 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class VoteCreate(BaseModel): + title: str + description: str | None = None + vote_type: str = "single" # single | multiple + is_anonymous: bool = False + max_choices: int = 1 + deadline: datetime | None = None + options: list[str] # option text list, min 2 + + +class VoteUpdate(BaseModel): + title: str | None = None + description: str | None = None + status: str | None = None # to close a vote + + +class VoteSubmit(BaseModel): + option_ids: list[int] # selected option IDs + + +class VoteOptionOut(BaseModel): + id: int + content: str + sort_order: int + vote_count: int + voter_names: list[str] | None = None # None if anonymous + + +class VoteOut(BaseModel): + id: int + class_id: int + creator_id: int + creator_name: str + title: str + description: str | None + vote_type: str + is_anonymous: bool + max_choices: int + deadline: datetime | None + status: str + total_voters: int + has_voted: bool + my_option_ids: list[int] | None = None + options: list[VoteOptionOut] + created_at: datetime + updated_at: datetime diff --git a/backend/app/services/assignment_service.py b/backend/app/services/assignment_service.py new file mode 100644 index 0000000..a0807ae --- /dev/null +++ b/backend/app/services/assignment_service.py @@ -0,0 +1,179 @@ +from datetime import datetime + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.models import Assignment, AssignmentSubmission, User +from app.schemas.assignment import AssignmentCreate, AssignmentUpdate, SubmissionCreate, SubmissionGrade +from app.services.notification_service import create_notifications_for_class + + +async def create_assignment( + db: AsyncSession, class_id: int, creator_id: int, data: AssignmentCreate +) -> Assignment: + assignment = Assignment( + class_id=class_id, + creator_id=creator_id, + title=data.title, + description=data.description, + deadline=data.deadline, + ) + db.add(assignment) + await db.commit() + await db.refresh(assignment) + + await create_notifications_for_class( + db, class_id, "assignment", f"新作业: {data.title}", + content=data.description, + related_id=assignment.id, + email_subject=f"HKU ICB - 新作业: {data.title}", + email_body=f"
{data.description or data.title}
", + email_action_path="/assignments", + ) + + return assignment + + +async def update_assignment( + db: AsyncSession, assignment: Assignment, data: AssignmentUpdate +) -> Assignment: + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(assignment, field, value) + await db.commit() + await db.refresh(assignment) + return assignment + + +async def delete_assignment(db: AsyncSession, assignment: Assignment): + await db.delete(assignment) + await db.commit() + + +async def get_assignment_by_id(db: AsyncSession, assignment_id: int) -> Assignment | None: + result = await db.execute( + select(Assignment) + .options( + selectinload(Assignment.creator), + selectinload(Assignment.submissions).selectinload(AssignmentSubmission.student), + ) + .where(Assignment.id == assignment_id) + ) + return result.scalar_one_or_none() + + +async def list_assignments( + db: AsyncSession, class_id: int, page: int = 1, page_size: int = 20 +) -> tuple[list[Assignment], int]: + total_result = await db.execute( + select(func.count(Assignment.id)).where(Assignment.class_id == class_id) + ) + total = total_result.scalar() or 0 + + result = await db.execute( + select(Assignment) + .options( + selectinload(Assignment.creator), + selectinload(Assignment.submissions), + ) + .where(Assignment.class_id == class_id) + .order_by(Assignment.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + return list(result.scalars().all()), total + + +async def add_attachments(db: AsyncSession, assignment: Assignment, urls: list[str]): + existing = assignment.get_attachment_urls_list() + existing.extend(urls) + assignment.set_attachment_urls_list(existing) + await db.commit() + await db.refresh(assignment) + + +async def create_submission( + db: AsyncSession, + assignment_id: int, + student_id: int, + notes: str | None, + file_url: str | None = None, + file_name: str | None = None, + file_type: str | None = None, + file_size: int | None = None, +) -> AssignmentSubmission: + # Check assignment exists and is open + a_result = await db.execute(select(Assignment).where(Assignment.id == assignment_id)) + assignment = a_result.scalar_one_or_none() + if assignment is None: + raise ValueError("作业不存在") + if assignment.status != "open": + raise ValueError("作业已关闭提交") + if assignment.deadline and datetime.now() > assignment.deadline: + raise ValueError("作业已过截止日期") + + # Check no existing submission + existing = await db.execute( + select(AssignmentSubmission).where( + AssignmentSubmission.assignment_id == assignment_id, + AssignmentSubmission.student_id == student_id, + ) + ) + if existing.scalar_one_or_none(): + raise ValueError("你已经提交过作业了") + + submission = AssignmentSubmission( + assignment_id=assignment_id, + student_id=student_id, + notes=notes, + file_url=file_url, + file_name=file_name, + file_type=file_type, + file_size=file_size, + ) + db.add(submission) + await db.commit() + await db.refresh(submission) + + # Reload with student relationship + result = await db.execute( + select(AssignmentSubmission) + .options(selectinload(AssignmentSubmission.student)) + .where(AssignmentSubmission.id == submission.id) + ) + return result.scalar_one() + + +async def get_submission_by_student( + db: AsyncSession, assignment_id: int, student_id: int +) -> AssignmentSubmission | None: + result = await db.execute( + select(AssignmentSubmission) + .options(selectinload(AssignmentSubmission.student)) + .where( + AssignmentSubmission.assignment_id == assignment_id, + AssignmentSubmission.student_id == student_id, + ) + ) + return result.scalar_one_or_none() + + +async def grade_submission( + db: AsyncSession, submission: AssignmentSubmission, data: SubmissionGrade +) -> AssignmentSubmission: + submission.grade = data.grade + submission.feedback = data.feedback + submission.graded_at = datetime.now() + await db.commit() + await db.refresh(submission) + return submission + + +async def list_submissions(db: AsyncSession, assignment_id: int) -> list[AssignmentSubmission]: + result = await db.execute( + select(AssignmentSubmission) + .options(selectinload(AssignmentSubmission.student)) + .where(AssignmentSubmission.assignment_id == assignment_id) + .order_by(AssignmentSubmission.created_at.desc()) + ) + return list(result.scalars().all()) diff --git a/backend/app/services/timeline_service.py b/backend/app/services/timeline_service.py index c092e3b..dc29b3a 100644 --- a/backend/app/services/timeline_service.py +++ b/backend/app/services/timeline_service.py @@ -5,8 +5,8 @@ from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.db.models import Timeline, User -from app.schemas.timeline import TimelineCreate, TimelineUpdate +from app.db.models import Timeline, TimelineLike, TimelineComment, User +from app.schemas.timeline import TimelineCreate, TimelineUpdate, TimelineCommentCreate from app.services.notification_service import create_notifications_for_class @@ -23,13 +23,12 @@ async def create_timeline( await db.commit() await db.refresh(post) - # Send notifications + email to class members content_preview = (data.content[:100] + "...") if data.content and len(data.content) > 100 else (data.content or "") await create_notifications_for_class( - db, class_id, "timeline", f"新大事件: {data.title}", + db, class_id, "timeline", f"新动态: {data.title}", content=content_preview, related_id=post.id, - email_subject=f"HKU ICB - 新大事件: {data.title}", + email_subject=f"HKU ICB - 新动态: {data.title}", email_body=f"{content_preview}
" if content_preview else None, email_action_path="/timeline", ) @@ -53,7 +52,15 @@ async def delete_timeline(db: AsyncSession, post: Timeline): async def get_timeline_by_id(db: AsyncSession, post_id: int) -> Timeline | None: - result = await db.execute(select(Timeline).where(Timeline.id == post_id)) + result = await db.execute( + select(Timeline) + .options( + selectinload(Timeline.author), + selectinload(Timeline.likes), + selectinload(Timeline.comments).selectinload(TimelineComment.author), + ) + .where(Timeline.id == post_id) + ) return result.scalar_one_or_none() @@ -67,7 +74,11 @@ async def list_timelines( result = await db.execute( select(Timeline) - .options(selectinload(Timeline.author)) + .options( + selectinload(Timeline.author), + selectinload(Timeline.likes), + selectinload(Timeline.comments), + ) .where(Timeline.class_id == class_id) .order_by(Timeline.created_at.desc()) .offset((page - 1) * page_size) @@ -83,3 +94,84 @@ async def add_images_to_timeline(db: AsyncSession, post: Timeline, urls: list[st post.set_image_urls_list(existing) await db.commit() await db.refresh(post) + + +async def toggle_like(db: AsyncSession, post_id: int, user_id: int) -> dict: + result = await db.execute( + select(TimelineLike).where( + TimelineLike.post_id == post_id, + TimelineLike.user_id == user_id, + ) + ) + existing = result.scalar_one_or_none() + + if existing: + await db.delete(existing) + await db.commit() + # Count remaining likes + count_result = await db.execute( + select(func.count(TimelineLike.id)).where(TimelineLike.post_id == post_id) + ) + return {"liked": False, "like_count": count_result.scalar() or 0} + else: + like = TimelineLike(post_id=post_id, user_id=user_id) + db.add(like) + await db.commit() + count_result = await db.execute( + select(func.count(TimelineLike.id)).where(TimelineLike.post_id == post_id) + ) + return {"liked": True, "like_count": count_result.scalar() or 0} + + +async def create_comment( + db: AsyncSession, post_id: int, author_id: int, data: TimelineCommentCreate +) -> TimelineComment: + comment = TimelineComment( + post_id=post_id, + author_id=author_id, + content=data.content, + ) + db.add(comment) + await db.commit() + await db.refresh(comment) + + # Load author relationship for response + result = await db.execute( + select(TimelineComment) + .options(selectinload(TimelineComment.author)) + .where(TimelineComment.id == comment.id) + ) + return result.scalar_one() + + +async def delete_comment(db: AsyncSession, comment: TimelineComment): + await db.delete(comment) + await db.commit() + + +async def get_comment_by_id(db: AsyncSession, comment_id: int) -> TimelineComment | None: + result = await db.execute( + select(TimelineComment) + .options(selectinload(TimelineComment.author)) + .where(TimelineComment.id == comment_id) + ) + return result.scalar_one_or_none() + + +async def list_comments( + db: AsyncSession, post_id: int, page: int = 1, page_size: int = 50 +) -> tuple[list[TimelineComment], int]: + total_result = await db.execute( + select(func.count(TimelineComment.id)).where(TimelineComment.post_id == post_id) + ) + total = total_result.scalar() or 0 + + result = await db.execute( + select(TimelineComment) + .options(selectinload(TimelineComment.author)) + .where(TimelineComment.post_id == post_id) + .order_by(TimelineComment.created_at.asc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + return list(result.scalars().all()), total diff --git a/backend/app/services/vote_service.py b/backend/app/services/vote_service.py new file mode 100644 index 0000000..8173ebe --- /dev/null +++ b/backend/app/services/vote_service.py @@ -0,0 +1,141 @@ +from datetime import datetime + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.models import Vote, VoteOption, VoteResponse, User +from app.schemas.vote import VoteCreate +from app.services.notification_service import create_notifications_for_class + + +async def create_vote( + db: AsyncSession, class_id: int, creator_id: int, data: VoteCreate +) -> Vote: + vote = Vote( + class_id=class_id, + creator_id=creator_id, + title=data.title, + description=data.description, + vote_type=data.vote_type, + is_anonymous=data.is_anonymous, + max_choices=data.max_choices, + deadline=data.deadline, + ) + db.add(vote) + await db.flush() + + for i, opt_text in enumerate(data.options): + option = VoteOption(vote_id=vote.id, content=opt_text, sort_order=i) + db.add(option) + + await db.commit() + await db.refresh(vote) + + # Load options for the returned object + result = await db.execute( + select(Vote) + .options(selectinload(Vote.options)) + .where(Vote.id == vote.id) + ) + vote = result.scalar_one() + + await create_notifications_for_class( + db, class_id, "vote", f"新投票: {data.title}", + content=data.description, + related_id=vote.id, + email_subject=f"HKU ICB - 新投票: {data.title}", + email_body=f"{data.description or data.title}
", + email_action_path="/votes", + ) + + return vote + + +async def get_vote_by_id(db: AsyncSession, vote_id: int) -> Vote | None: + result = await db.execute( + select(Vote) + .options( + selectinload(Vote.creator), + selectinload(Vote.options).selectinload(VoteOption.responses).selectinload(VoteResponse.voter), + ) + .where(Vote.id == vote_id) + ) + return result.scalar_one_or_none() + + +async def list_votes( + db: AsyncSession, class_id: int, page: int = 1, page_size: int = 20 +) -> tuple[list[Vote], int]: + total_result = await db.execute( + select(func.count(Vote.id)).where(Vote.class_id == class_id) + ) + total = total_result.scalar() or 0 + + result = await db.execute( + select(Vote) + .options( + selectinload(Vote.creator), + selectinload(Vote.options).selectinload(VoteOption.responses), + ) + .where(Vote.class_id == class_id) + .order_by(Vote.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + return list(result.scalars().all()), total + + +async def submit_vote( + db: AsyncSession, vote_id: int, voter_id: int, option_ids: list[int] +) -> None: + vote_result = await db.execute(select(Vote).where(Vote.id == vote_id)) + vote = vote_result.scalar_one_or_none() + if vote is None: + raise ValueError("投票不存在") + if vote.status != "open": + raise ValueError("投票已关闭") + if vote.deadline and datetime.now() > vote.deadline: + raise ValueError("投票已过截止日期") + + # Check if already voted + existing = await db.execute( + select(VoteResponse).where( + VoteResponse.vote_id == vote_id, + VoteResponse.voter_id == voter_id, + ) + ) + if existing.scalar_one_or_none(): + raise ValueError("你已经投过票了") + + # Validate option count + if vote.vote_type == "single" and len(option_ids) != 1: + raise ValueError("单选投票只能选择一个选项") + if vote.vote_type == "multiple" and len(option_ids) > vote.max_choices: + raise ValueError(f"最多选择 {vote.max_choices} 个选项") + + # Validate option_ids belong to this vote + for oid in option_ids: + opt_result = await db.execute( + select(VoteOption).where(VoteOption.id == oid, VoteOption.vote_id == vote_id) + ) + if opt_result.scalar_one_or_none() is None: + raise ValueError(f"选项 {oid} 不属于此投票") + + for oid in option_ids: + response = VoteResponse(vote_id=vote_id, option_id=oid, voter_id=voter_id) + db.add(response) + + await db.commit() + + +async def close_vote(db: AsyncSession, vote: Vote) -> Vote: + vote.status = "closed" + await db.commit() + await db.refresh(vote) + return vote + + +async def delete_vote(db: AsyncSession, vote: Vote): + await db.delete(vote) + await db.commit()