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.
158 lines
5.4 KiB
Python
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()
|