增加新模块
This commit is contained in:
parent
31e7a598dc
commit
a39fd6186c
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
263
backend/app/api/assignments.py
Normal file
263
backend/app/api/assignments.py
Normal 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)
|
||||
@ -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
177
backend/app/api/votes.py
Normal 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": "投票已删除"}
|
||||
@ -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")
|
||||
|
||||
@ -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")
|
||||
|
||||
62
backend/app/schemas/assignment.py
Normal file
62
backend/app/schemas/assignment.py
Normal 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
|
||||
@ -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
|
||||
|
||||
51
backend/app/schemas/vote.py
Normal file
51
backend/app/schemas/vote.py
Normal 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
|
||||
179
backend/app/services/assignment_service.py
Normal file
179
backend/app/services/assignment_service.py
Normal 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())
|
||||
@ -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
|
||||
|
||||
141
backend/app/services/vote_service.py
Normal file
141
backend/app/services/vote_service.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user