增加新模块

This commit is contained in:
aaron 2026-04-12 11:56:39 +08:00
parent 31e7a598dc
commit a39fd6186c
12 changed files with 1336 additions and 55 deletions

View File

@ -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:*)"
]
}
}

View File

@ -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)

View File

@ -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"}

177
backend/app/api/votes.py Normal file
View File

@ -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": "投票已删除"}

View File

@ -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")

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"<p>{data.description or data.title}</p>",
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())

View File

@ -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"<p>{content_preview}</p>" 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

View File

@ -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"<p>{data.description or data.title}</p>",
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()