pdf-quiz-generator/backend/app/routers/comments.py
Daniel 0fa8d0a689 feat: shareable quiz links, moderated comments and AI authoring
Quiz share links replace the PIN copy with a public /share/{token} landing page; owners can enable/revoke without showing the full link. Moderated article/question comments with approval flow, bounds and rate limits. Educator AI article drafts/refine and private card generation with Celery job polling. Migrations f2a1c9d4e801 and g4b7e2f5a903. 50 backend and 85 frontend tests pass.
2026-09-07 17:10:08 +02:00

158 lines
5.4 KiB
Python

"""Moderated comments on articles and questions."""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, field_validator
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.article import Article
from app.models.comment import Comment
from app.models.question import Question
from app.models.user import User
from app.services.quiz_builder import bank_question_predicate
from app.utils.auth import check_rate_limit, get_current_user, require_moderator
router = APIRouter()
class CommentCreate(BaseModel):
article_id: int | None = None
question_id: int | None = None
content: str
@field_validator("content")
@classmethod
def content_shape(cls, value):
value = value.strip()
if not value:
raise ValueError("Comment cannot be empty")
if len(value) > 2000:
raise ValueError("Comment is too long (max 2000 characters)")
return value
class CommentModerate(BaseModel):
status: str
@field_validator("status")
@classmethod
def status_shape(cls, value):
if value not in ("pending", "approved", "rejected"):
raise ValueError("Invalid moderation status")
return value
def _target(db: Session, article_id: int | None, question_id: int | None, user: User):
if (article_id is None) == (question_id is None):
raise HTTPException(400, "Provide exactly one of article_id or question_id")
if article_id is not None:
article = db.get(Article, article_id)
if not article:
raise HTTPException(404, "Article not found")
if article.status != "published" and not user.is_moderator:
raise HTTPException(404, "Article not found")
return "article_id", article
question = db.get(Question, question_id)
if not question or not db.query(Question.id).filter(
Question.id == question_id, bank_question_predicate(user)).first():
raise HTTPException(404, "Question not found")
return "question_id", question
@router.post("/")
def create_comment(
data: CommentCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
target_field, _ = _target(db, data.article_id, data.question_id, current_user)
check_rate_limit(
key=f"comments:daily:{current_user.id}",
max_calls=20,
window_seconds=86400,
detail="You've reached today's comment limit. Try again tomorrow.",
user=current_user,
)
comment = Comment(article_id=data.article_id, question_id=data.question_id,
user_id=current_user.id, content=data.content, status="pending")
db.add(comment)
db.commit()
db.refresh(comment)
return _json(db, comment, current_user)
def _json(db: Session, comment: Comment, user: User):
author = db.get(User, comment.user_id) if comment.user_id else None
return {
"id": comment.id,
"article_id": comment.article_id,
"question_id": comment.question_id,
"user_id": comment.user_id,
"author_name": author.name if author else "Unknown",
"content": comment.content,
"status": comment.status,
"created_at": comment.created_at,
"own": comment.user_id == user.id,
"can_moderate": user.is_moderator,
}
@router.get("/")
def list_comments(
article_id: int | None = Query(None),
question_id: int | None = Query(None),
limit: int = Query(20, le=100),
offset: int = Query(0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
target_field, target = _target(db, article_id, question_id, current_user)
target_id = getattr(target, "id")
query = db.query(Comment).filter(getattr(Comment, target_field) == target_id).filter(
(Comment.status == "approved") | (Comment.user_id == current_user.id),
)
total = query.count()
comments = query.order_by(Comment.created_at.desc()).offset(offset).limit(limit).all()
return {"total": total, "comments": [_json(db, c, current_user) for c in comments]}
@router.get("/moderation")
def list_pending_comments(
limit: int = Query(50, le=200),
offset: int = Query(0),
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
query = db.query(Comment).filter(Comment.status == "pending")
total = query.count()
comments = query.order_by(Comment.created_at.asc()).offset(offset).limit(limit).all()
return {"total": total, "comments": [_json(db, c, current_user) for c in comments]}
@router.patch("/{comment_id}")
def moderate_comment(
comment_id: int,
data: CommentModerate,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
comment = db.get(Comment, comment_id)
if not comment:
raise HTTPException(404, "Comment not found")
comment.status = data.status
db.commit()
return _json(db, comment, current_user)
@router.delete("/{comment_id}", status_code=204)
def delete_comment(
comment_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
comment = db.get(Comment, comment_id)
if not comment:
raise HTTPException(404, "Comment not found")
if comment.user_id != current_user.id and not current_user.is_moderator:
raise HTTPException(403, "Not your comment")
db.delete(comment)
db.commit()