From 0fa8d0a6899dcc657de40b5b4b454a3dce1f2bc2 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 7 Sep 2026 17:10:08 +0200 Subject: [PATCH] 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. --- .../alembic/versions/f2a1c9d4e801_comments.py | 31 ++++ .../versions/g4b7e2f5a903_share_tokens.py | 21 +++ backend/app/main.py | 4 +- backend/app/models/__init__.py | 2 + backend/app/models/comment.py | 22 +++ backend/app/models/quiz.py | 1 + backend/app/routers/articles.py | 118 +++++++++++++ backend/app/routers/comments.py | 158 +++++++++++++++++ backend/app/routers/quizzes.py | 37 +++- backend/app/routers/share.py | 42 +++++ backend/app/schemas/quiz.py | 1 + backend/app/tasks/quiz_tasks.py | 148 ++++++++++++++++ backend/tests/test_comments_ai.py | 159 ++++++++++++++++++ backend/tests/test_share_public.py | 63 +++++++ frontend/src/App.jsx | 2 + frontend/src/components/CommentSection.jsx | 95 +++++++++++ .../src/components/CommentSection.test.jsx | 52 ++++++ frontend/src/pages/AdminPage.jsx | 2 +- frontend/src/pages/ArticlesPage.css | 9 + frontend/src/pages/ArticlesPage.jsx | 83 ++++++++- frontend/src/pages/PublicQuizPage.jsx | 60 +++++++ frontend/src/pages/PublicQuizPage.test.jsx | 43 +++++ frontend/src/pages/QuizPage.jsx | 70 ++++++-- frontend/src/pages/QuizPage.test.jsx | 19 +++ 24 files changed, 1222 insertions(+), 20 deletions(-) create mode 100644 backend/alembic/versions/f2a1c9d4e801_comments.py create mode 100644 backend/alembic/versions/g4b7e2f5a903_share_tokens.py create mode 100644 backend/app/models/comment.py create mode 100644 backend/app/routers/comments.py create mode 100644 backend/app/routers/share.py create mode 100644 backend/tests/test_comments_ai.py create mode 100644 backend/tests/test_share_public.py create mode 100644 frontend/src/components/CommentSection.jsx create mode 100644 frontend/src/components/CommentSection.test.jsx create mode 100644 frontend/src/pages/PublicQuizPage.jsx create mode 100644 frontend/src/pages/PublicQuizPage.test.jsx diff --git a/backend/alembic/versions/f2a1c9d4e801_comments.py b/backend/alembic/versions/f2a1c9d4e801_comments.py new file mode 100644 index 0000000..99e095d --- /dev/null +++ b/backend/alembic/versions/f2a1c9d4e801_comments.py @@ -0,0 +1,31 @@ +"""Moderated discussion comments. + +Revision ID: f2a1c9d4e801 +Revises: e8d4f1a27c93 +""" +from alembic import op + +revision = "f2a1c9d4e801" +down_revision = "e8d4f1a27c93" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute(""" + CREATE TABLE IF NOT EXISTS comments ( + id SERIAL PRIMARY KEY, + article_id INTEGER REFERENCES articles(id) ON DELETE CASCADE, + question_id INTEGER REFERENCES questions(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + content TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )""") + op.execute("CREATE INDEX IF NOT EXISTS ix_comments_id ON comments (id)") + op.execute("CREATE INDEX IF NOT EXISTS ix_comments_article_id ON comments (article_id)") + op.execute("CREATE INDEX IF NOT EXISTS ix_comments_question_id ON comments (question_id)") + + +def downgrade(): + op.execute("DROP TABLE IF EXISTS comments") diff --git a/backend/alembic/versions/g4b7e2f5a903_share_tokens.py b/backend/alembic/versions/g4b7e2f5a903_share_tokens.py new file mode 100644 index 0000000..d71d0d5 --- /dev/null +++ b/backend/alembic/versions/g4b7e2f5a903_share_tokens.py @@ -0,0 +1,21 @@ +"""Public quiz share tokens. + +Revision ID: g4b7e2f5a903 +Revises: f2a1c9d4e801 +""" +from alembic import op + +revision = "g4b7e2f5a903" +down_revision = "f2a1c9d4e801" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS share_token VARCHAR(64)") + op.execute("CREATE UNIQUE INDEX IF NOT EXISTS uq_quizzes_share_token ON quizzes (share_token)") + + +def downgrade(): + op.execute("DROP INDEX IF EXISTS uq_quizzes_share_token") + op.execute("ALTER TABLE quizzes DROP COLUMN IF EXISTS share_token") diff --git a/backend/app/main.py b/backend/app/main.py index 98b521a..6d00537 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,7 +11,7 @@ from app.logging_config import setup_logging setup_logging(settings.LOG_LEVEL) from app.database import engine, Base, SessionLocal from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote -from app.routers import study_tools, uploads, articles +from app.routers import study_tools, uploads, articles, comments, share from app.utils.auth import get_password_hash from app.utils.scheduler import start_scheduler, stop_scheduler @@ -617,6 +617,8 @@ app.include_router(uploads.router) app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(articles.router, prefix="/api/articles", tags=["articles"]) +app.include_router(comments.router, prefix="/api/comments", tags=["comments"]) +app.include_router(share.router, prefix="/api/share", tags=["share"]) app.include_router(documents.router, prefix="/api/documents", tags=["documents"]) app.include_router(quizzes.router, prefix="/api/quizzes", tags=["quizzes"]) app.include_router(attempts.router, prefix="/api/attempts", tags=["attempts"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d318ef3..affc34b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -10,6 +10,7 @@ from app.models.favorite import Favorite from app.models.user_note import UserNote from app.models.lab_reference import LabReference from app.models.article import Article, QuestionArticleLink +from app.models.comment import Comment from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating, FlashcardQuestionLink, FlashcardArticleLink __all__ = [ @@ -27,6 +28,7 @@ __all__ = [ "LabReference", "Article", "QuestionArticleLink", + "Comment", "FlashcardDeck", "Flashcard", "FlashcardDeckRating", diff --git a/backend/app/models/comment.py b/backend/app/models/comment.py new file mode 100644 index 0000000..fc13b22 --- /dev/null +++ b/backend/app/models/comment.py @@ -0,0 +1,22 @@ +from datetime import datetime + +from sqlalchemy import Column, Integer, Text, String, DateTime, ForeignKey +from sqlalchemy.orm import relationship + +from app.database import Base + + +class Comment(Base): + """Moderated discussion comment on an article or a question.""" + + __tablename__ = "comments" + + id = Column(Integer, primary_key=True, index=True) + article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=True) + question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + content = Column(Text, nullable=False) + status = Column(String, default="pending") # pending | approved | rejected + created_at = Column(DateTime, default=datetime.utcnow) + + user = relationship("User") diff --git a/backend/app/models/quiz.py b/backend/app/models/quiz.py index ab4c2f2..6b9f2d8 100644 --- a/backend/app/models/quiz.py +++ b/backend/app/models/quiz.py @@ -28,6 +28,7 @@ class Quiz(Base): max_attempts = Column(Integer, nullable=True) # null = unlimited questions_per_attempt = Column(Integer, nullable=True) # null = all; set = random subset from pool allow_review = Column(Integer, default=1) # 1 = students can review answers after submit, 0 = no review + share_token = Column(String(64), unique=True, nullable=True) # public /share/{token} link when set section = relationship("Section", back_populates="quizzes") user = relationship("User", back_populates="quizzes") diff --git a/backend/app/routers/articles.py b/backend/app/routers/articles.py index 6ede145..12bd9c1 100644 --- a/backend/app/routers/articles.py +++ b/backend/app/routers/articles.py @@ -63,6 +63,40 @@ class ArticleLinkIn(BaseModel): section_id: str | None = None +class ArticleAIDraft(BaseModel): + topic: str + instructions: str | None = None + + @field_validator("topic") + @classmethod + def topic_shape(cls, value): + value = value.strip() + if not value or len(value) > 300: + raise ValueError("Topic is required (max 300 characters)") + return value + + +class ArticleAIRefine(BaseModel): + instructions: str = "" + + @field_validator("instructions") + @classmethod + def instructions_shape(cls, value): + if len(value) > 2000: + raise ValueError("Instructions are too long (max 2000 characters)") + return value + + +def _queue_article_job(db, user, job_id, job_title): + import redis as redis_lib + from app.config import settings + r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) + r.set(f"extraction:status:{job_id}", "pending", ex=3600) + r.lpush(f"extraction:user_jobs:{user.id}", job_id) + r.expire(f"extraction:user_jobs:{user.id}", 86400) + r.set(f"extraction:job_title:{job_id}", job_title, ex=3600) + + def _validate_sections(sections: list[ArticleSection]): ids, slugs = set(), set() for section in sections: @@ -341,3 +375,87 @@ def unlink_question( question_id=question_id, article_id=article_id, section_id=section_id, ).delete(synchronize_session=False) db.commit() + + +@router.post("/ai-draft") +def start_article_draft( + data: ArticleAIDraft, + db: Session = Depends(get_db), + current_user: User = Depends(require_moderator), +): + """Queue an AI draft; the saved article is a draft until an educator publishes it.""" + import uuid + from app.tasks.quiz_tasks import generate_article_draft + job_id = str(uuid.uuid4()) + _queue_article_job(db, current_user, job_id, f"Article draft: {data.topic[:60]}") + try: + generate_article_draft.delay( + job_id=job_id, user_id=current_user.id, + topic=data.topic, instructions=data.instructions or "", model_id=None, + ) + except Exception: + raise HTTPException(503, "Task queue unavailable") + return {"job_id": job_id, "status": "pending"} + + +@router.post("/{article_id}/ai-refine") +def start_article_refine( + article_id: int, + data: ArticleAIRefine, + db: Session = Depends(get_db), + current_user: User = Depends(require_moderator), +): + article = db.get(Article, article_id) + if not article: + raise HTTPException(404, "Article not found") + import uuid + from app.tasks.quiz_tasks import generate_article_draft + job_id = str(uuid.uuid4()) + _queue_article_job(db, current_user, job_id, f"Refine: {article.title[:60]}") + try: + generate_article_draft.delay( + job_id=job_id, user_id=current_user.id, topic=article.title, + instructions=data.instructions, article_id=article.id, model_id=None, + ) + except Exception: + raise HTTPException(503, "Task queue unavailable") + return {"job_id": job_id, "status": "pending"} + + +@router.post("/{article_id}/ai-cards") +def start_article_cards( + article_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_moderator), +): + """Generate cards into a private deck; sharing remains an explicit educator action.""" + article = db.get(Article, article_id) + if not article: + raise HTTPException(404, "Article not found") + import uuid + from app.tasks.quiz_tasks import generate_article_cards + job_id = str(uuid.uuid4()) + _queue_article_job(db, current_user, job_id, f"Cards: {article.title[:60]}") + try: + generate_article_cards.delay(job_id=job_id, user_id=current_user.id, article_id=article.id, model_id=None) + except Exception: + raise HTTPException(503, "Task queue unavailable") + return {"job_id": job_id, "status": "pending"} + + +@router.get("/job/{job_id}") +def get_article_job(job_id: str, current_user: User = Depends(get_current_user)): + """Poll an article AI job for the current user.""" + import json as _json + import redis as redis_lib + from app.config import settings + r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) + user_jobs = r.lrange(f"extraction:user_jobs:{current_user.id}", 0, 49) + if job_id not in user_jobs and not current_user.is_moderator: + raise HTTPException(404, "Job not found") + status = r.get(f"extraction:status:{job_id}") or "unknown" + steps = [_json.loads(s) for s in r.lrange(f"extraction:steps:{job_id}", 0, -1)] + result = {"job_id": job_id, "status": status, "steps": steps} + if status == "failed": + result["error"] = r.get(f"extraction:error:{job_id}") or "Unknown error" + return result diff --git a/backend/app/routers/comments.py b/backend/app/routers/comments.py new file mode 100644 index 0000000..49a5776 --- /dev/null +++ b/backend/app/routers/comments.py @@ -0,0 +1,158 @@ +"""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() diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py index c59b693..430c25e 100644 --- a/backend/app/routers/quizzes.py +++ b/backend/app/routers/quizzes.py @@ -15,7 +15,7 @@ from app.models.question_category import QuestionCategory from app.services.quiz_builder import category_breadcrumbs from app.schemas.quiz import QuizCreate, QuizUpdate, QuizResponse, QuizDetail, QuizLearningDetail, QuizReview from app.services import quiz_service -from app.utils.quiz_access import can_access_quiz, general_quiz_visibility, require_quiz_access, set_quiz_shared +from app.utils.quiz_access import can_access_quiz, general_quiz_visibility, require_quiz_access, set_quiz_shared, quiz_shareable_predicate from app.utils.auth import get_current_user, require_moderator from app.utils.quiz_questions import get_quiz_questions, question_in_quiz, remove_question_from_quiz @@ -607,3 +607,38 @@ def share_quiz(quiz_id: int, shared: bool = Query(...), db: Session = Depends(ge if not quiz: raise HTTPException(404, "Quiz not found") return set_quiz_shared(db, quiz, current_user, shared) + + +@router.post("/{quiz_id}/share-link") +def create_share_link(quiz_id: int, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user)): + """Mark a general quiz shareable and return its public token.""" + quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first() + if not quiz: + raise HTTPException(404, "Quiz not found") + if quiz.user_id != current_user.id and not current_user.is_moderator: + raise HTTPException(403, "Only the owner or a moderator can share this quiz") + if quiz.course_id is not None: + raise HTTPException(400, "Course quizzes cannot be shared publicly") + if not db.query(Quiz.id).filter(Quiz.id == quiz.id, quiz_shareable_predicate()).first(): + raise HTTPException(400, "This test contains private or course-only questions") + if not quiz.share_token: + import uuid + quiz.share_token = uuid.uuid4().hex + quiz.is_shared = 1 + db.commit() + return {"token": quiz.share_token, "enabled": True} + + +@router.delete("/{quiz_id}/share-link", status_code=204) +def revoke_share_link(quiz_id: int, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user)): + quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first() + if not quiz: + raise HTTPException(404, "Quiz not found") + if quiz.user_id != current_user.id and not current_user.is_moderator: + raise HTTPException(403, "Only the owner or a moderator can revoke sharing") + quiz.share_token = None + quiz.is_shared = 0 + quiz.is_published = 0 + db.commit() diff --git a/backend/app/routers/share.py b/backend/app/routers/share.py new file mode 100644 index 0000000..33b93bc --- /dev/null +++ b/backend/app/routers/share.py @@ -0,0 +1,42 @@ +"""Public share links — viewing quiz info does not require a login.""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.quiz import Quiz +from app.models.user import User +from app.services.quiz_builder import category_breadcrumbs +from app.models.question_category import QuestionCategory +from app.utils.quiz_access import quiz_shareable_predicate + +router = APIRouter() + + +@router.get("/{token}") +def get_shared_quiz( + token: str, + db: Session = Depends(get_db), +): + """Public quiz landing data; never exposes questions or answers.""" + quiz = db.query(Quiz).filter( + Quiz.share_token == token, + Quiz.deleted_at.is_(None), + Quiz.course_id.is_(None), + Quiz.is_shared == 1, + ).first() + if not quiz or not db.query(Quiz.id).filter( + Quiz.id == quiz.id, quiz_shareable_predicate()).first(): + raise HTTPException(404, "Shared quiz not found") + owner = db.get(User, quiz.user_id) if quiz.user_id else None + categories = db.query(QuestionCategory).all() + return { + "quiz_id": quiz.id, + "title": quiz.title, + "mode": quiz.mode, + "questions_count": quiz.questions_count, + "time_limit_minutes": quiz.time_limit_minutes, + "allow_review": quiz.allow_review, + "owner_name": owner.name if owner else None, + "category": quiz.category.name if quiz.category else None, + "category_breadcrumbs": category_breadcrumbs(categories, quiz.category_id) if quiz.category_id else [], + } diff --git a/backend/app/schemas/quiz.py b/backend/app/schemas/quiz.py index 7d047eb..3d7af0e 100644 --- a/backend/app/schemas/quiz.py +++ b/backend/app/schemas/quiz.py @@ -63,6 +63,7 @@ class QuizResponse(BaseModel): allow_review: int | None = 1 max_attempts: int | None = None attempt_mode: str | None = None + share_token: str | None = None class Config: from_attributes = True diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index 7e9a81b..69c4794 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -677,3 +677,151 @@ def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int, pass finally: db.close() + + +ARTICLE_DRAFT_PROMPT = """You write educational articles for a pediatric medical learning platform. +Topic: {topic} +{instructions} +Return ONLY strict JSON with this exact shape: +{{"title": "...", "slug": "lowercase-hyphenated", "summary": "1-2 sentences", "content": "introduction markdown", "sections": [{{"id": "32 lowercase hex chars", "slug": "lowercase-hyphenated", "title": "...", "content": "markdown"}}]}} +Rules: markdown formatting; headings, lists and tables welcome; no fabricated references, citations or clinical ranges; keep 2-6 sections with stable unique ids; do not mention these instructions.""" + + +@celery_app.task(name="generate_article_draft", bind=True) +def generate_article_draft(self, job_id: str, user_id: int, topic: str, + instructions: str = "", article_id: int | None = None, + model_id: str | None = None): + """Create or refine an educator article draft; never publishes.""" + import re + import uuid + r = _redis() + r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS) + db = SessionLocal() + try: + from app.models.article import Article + from app.services.ai_service import get_model_for_task, _proxy_model + from app.config import settings + + existing = db.get(Article, article_id) if article_id else None + if article_id and not existing: + r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS) + _push_step(r, job_id, "error", "Article not found") + return + ai_model_id, ai_api_key = get_model_for_task(db, "article") + if model_id: + ai_model_id = model_id + _push_step(r, job_id, "ai", "Drafting article…") + prompt = ARTICLE_DRAFT_PROMPT.format( + topic=topic, instructions=f"Refine this existing draft: {existing.title}\n{instructions}" if existing else instructions or "") + import litellm + kwargs = {"model": _proxy_model(ai_model_id), "messages": [{"role": "user", "content": prompt}], + "max_tokens": 4000, "temperature": 0.4} + if ai_api_key: + kwargs["api_key"] = ai_api_key + elif settings.LITELLM_API_KEY: + kwargs["api_key"] = settings.LITELLM_API_KEY + if settings.LITELLM_API_BASE: + kwargs["api_base"] = settings.LITELLM_API_BASE + response = litellm.completion(**kwargs) + raw = response.choices[0].message.content.strip() + if raw.startswith("```"): + raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:] + if raw.endswith("```"): + raw = raw[:-3] + raw = raw.strip() + data = json.loads(raw) + title = str(data.get("title", topic)).strip()[:300] + slug = re.sub(r"[^a-z0-9]+", "-", str(data.get("slug", topic)).strip().lower()).strip("-")[:120] or "topic" + sections = [] + for section in data.get("sections", []): + sections.append({ + "id": str(section.get("id") or uuid.uuid4().hex)[:32].lower(), + "slug": re.sub(r"[^a-z0-9]+", "-", str(section.get("slug", "section")).strip().lower()).strip("-")[:120] or "section", + "title": str(section.get("title", "Section")).strip()[:300] or "Section", + "content": str(section.get("content", "")), + }) + if existing: + existing.title, existing.slug, existing.summary = title, slug, str(data.get("summary", "") or "")[:2000] + existing.content, existing.sections = str(data.get("content", "") or ""), sections + else: + base_slug = slug + n = 2 + while db.query(Article.id).filter(Article.slug == slug).first(): + slug = f"{base_slug}-{n}" + n += 1 + db.add(Article(slug=slug, title=title, summary=str(data.get("summary", "") or "")[:2000], + content=str(data.get("content", "") or ""), sections=sections, + user_id=user_id, status="draft")) + db.commit() + r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS) + _push_step(r, job_id, "done", f"Draft saved: {title}") + except Exception as exc: + logger.warning("Article draft job %s failed: %s", job_id, exc) + r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS) + r.set(f"extraction:error:{job_id}", str(exc)[:300], ex=EXPIRE_SECONDS) + _push_step(r, job_id, "error", "Drafting failed; the model may need an 'article' configuration.") + finally: + db.close() + + +@celery_app.task(name="generate_article_cards", bind=True) +def generate_article_cards(self, job_id: str, user_id: int, article_id: int, + model_id: str | None = None): + """Generate cards from an article into an unshared educator deck; links stay private until shared.""" + r = _redis() + r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS) + db = SessionLocal() + try: + from app.models.article import Article + from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardArticleLink + from app.services import extraction_modes + from app.services.ai_service import get_model_for_task + + article = db.get(Article, article_id) + if not article: + r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS) + _push_step(r, job_id, "error", "Article not found") + return + ai_model_id, ai_api_key = get_model_for_task(db, "flashcard") + if model_id: + ai_model_id = model_id + _push_step(r, job_id, "ai", f"Generating cards from {article.title}…") + content = "\n\n".join(filter(None, [ + article.title, article.summary, article.content, + *[f"## {s['title']}\n{s['content']}" for s in (article.sections or [])], + ])) + cards = extraction_modes.generate_flashcards(content, "article", None, ai_model_id, ai_api_key) + if not cards: + r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS) + _push_step(r, job_id, "error", "No cards could be generated") + return + deck = db.query(FlashcardDeck).filter( + FlashcardDeck.title == f"Cards: {article.title}", + FlashcardDeck.user_id == user_id, + FlashcardDeck.deleted_at.is_(None), + ).first() + if not deck: + deck = FlashcardDeck(title=f"Cards: {article.title}", user_id=user_id, card_count=0, is_shared=0) + db.add(deck) + db.flush() + new_cards = [] + for card in cards: + item = Flashcard(deck_id=deck.id, front=card["front"], back=card["back"], + page_reference=card.get("page_reference")) + db.add(item) + new_cards.append(item) + db.flush() + for item in new_cards: + db.add(FlashcardArticleLink(flashcard_id=item.id, article_id=article.id)) + deck.card_count = db.query(Flashcard).filter(Flashcard.deck_id == deck.id).count() + db.commit() + r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS) + _push_step(r, job_id, "done", f"{len(cards)} cards saved to private deck {deck.title}") + except Exception as exc: + logger.warning("Article cards job %s failed: %s", job_id, exc) + db.rollback() + r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS) + r.set(f"extraction:error:{job_id}", str(exc)[:300], ex=EXPIRE_SECONDS) + _push_step(r, job_id, "error", "Card generation failed.") + finally: + db.close() diff --git a/backend/tests/test_comments_ai.py b/backend/tests/test_comments_ai.py new file mode 100644 index 0000000..df575d6 --- /dev/null +++ b/backend/tests/test_comments_ai.py @@ -0,0 +1,159 @@ +"""Comments moderation and AI authoring endpoints on disposable SQLite; no network/AI.""" +import json +import sys +import unittest +from unittest.mock import Mock, patch + +import test_quiz_builder as fixtures +from app.models.article import Article +from app.models.comment import Comment +from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardArticleLink +from app.models.question import Question +from app.routers import articles, comments +from app.tasks.quiz_tasks import generate_article_draft, generate_article_cards +from sqlalchemy.orm import sessionmaker + +DRAFT_RESPONSE = { + "title": "AI draft topic", "slug": "ai-draft-topic", "summary": "Draft summary", + "content": "Draft intro", "sections": [ + {"id": "a" * 32, "slug": "first", "title": "First", "content": "Body"}, + {"id": "b" * 32, "slug": "second", "title": "Second", "content": "More"}, + ], +} + + +class CommentsAiTests(unittest.TestCase): + def setUp(self): + self.bank = fixtures.BuilderTests() + self.bank.setUp() + self.client = self.bank.client + self.client.app.include_router(articles.router, prefix='/articles') + self.client.app.include_router(comments.router, prefix='/comments') + self.redis = Mock() + self.redis.from_url.return_value = self.redis + self.redis.get.return_value = None + self.redis.lrange.return_value = [] + self.redis.lpush.return_value = 1 + self.redis.expire.return_value = True + self.redis.incr.side_effect = [1, 21] + self.delay = patch.dict(sys.modules, {'redis': self.redis}) + self.delay.start() + self.queue = patch('app.tasks.quiz_tasks.generate_article_draft.delay').start() + self.card_queue = patch('app.tasks.quiz_tasks.generate_article_cards.delay').start() + + def tearDown(self): + patch.stopall() + self.bank.tearDown() + + def publish(self, article_id): + return self.client.post(f'/articles/{article_id}/publish', json={'published': True}) + + def test_comment_visibility_moderation_and_bounds(self): + self.bank.user = self.bank.mod + article = self.client.post('/articles/', json={ + "title": "Discussed", "slug": "discussed", "content": "Intro", "sections": []}).json() + self.publish(article['id']) + self.bank.user = self.bank.owner + created = self.client.post('/comments/', json={'article_id': article['id'], 'content': 'Owner pending note'}) + self.assertEqual(created.status_code, 200, created.text) + self.assertEqual(created.json()['status'], 'pending') + self.assertEqual(created.json()['own'], True) + for payload in ({'article_id': article['id'], 'question_id': 1, 'content': 'x'}, + {'article_id': article['id'], 'content': ' '}, + {'article_id': article['id'], 'content': 'x' * 2001}, + {'article_id': 999, 'content': 'x'}, + {'question_id': 999, 'content': 'x'}): + self.assertIn(self.client.post('/comments/', json=payload).status_code, (400, 404, 422), payload) + self.assertEqual(self.client.post('/comments/', json={'article_id': article['id'], 'content': 'Rate limited'}).status_code, 429) + listing = self.client.get('/comments/', params={'article_id': article['id']}).json() + self.assertEqual(listing['total'], 1) # Own pending visible. + self.bank.user = self.bank.peer + listing = self.client.get('/comments/', params={'article_id': article['id']}).json() + self.assertEqual(listing['total'], 0) # Others' pending hidden. + self.assertEqual(self.client.get('/comments/moderation').status_code, 403) + self.assertEqual(self.client.patch(f"/comments/{created.json()['id']}", json={'status': 'approved'}).status_code, 403) + self.bank.user = self.bank.mod + pending = self.client.get('/comments/moderation').json() + self.assertEqual(pending['total'], 1) + approved = self.client.patch(f"/comments/{created.json()['id']}", json={'status': 'approved'}) + self.assertEqual(approved.status_code, 200) + self.assertEqual(approved.json()['author_name'], 'Owner') + self.bank.user = self.bank.peer + listing = self.client.get('/comments/', params={'article_id': article['id']}).json() + self.assertEqual(listing['total'], 1) + self.assertEqual(self.client.patch(f"/comments/{created.json()['id']}", json={'status': 'rejected'}).status_code, 403) + # Question comments follow bank visibility. + self.bank.user = self.bank.peer + self.assertEqual(self.client.post('/comments/', json={'question_id': 3, 'content': 'Private leak'}).status_code, 404) + self.redis.incr.side_effect = [1] + self.bank.user = self.bank.owner + self.assertEqual(self.client.post('/comments/', json={'question_id': 3, 'content': 'Own question'}).status_code, 200) + self.bank.user = self.bank.mod + self.assertEqual(self.client.delete(f"/comments/{created.json()['id']}").status_code, 204) + + def test_ai_endpoints_queue_and_poll(self): + self.bank.user = self.bank.mod + response = self.client.post('/articles/ai-draft', json={'topic': 'Neonatal jaundice', 'instructions': 'Two sections'}) + self.assertEqual(response.status_code, 200, response.text) + job_id = response.json()['job_id'] + self.queue.assert_called_once() + self.redis.get.return_value = 'completed' + poll = self.client.get(f'/articles/job/{job_id}').json() + self.assertEqual(poll['status'], 'completed') + self.bank.user = self.bank.owner + self.assertEqual(self.client.get(f'/articles/job/{job_id}').status_code, 404) + self.bank.user = self.bank.mod + article = self.client.post('/articles/', json={ + "title": "Refine me", "slug": "refine-me", "content": "Intro", "sections": []}).json() + self.assertEqual(self.client.post(f"/articles/{article['id']}/ai-refine", json={'instructions': 'Shorten'}).status_code, 200) + self.assertEqual(self.client.post(f"/articles/{article['id']}/ai-cards").status_code, 200) + self.card_queue.assert_called_once() + self.bank.user = self.bank.owner + self.assertEqual(self.client.post('/articles/ai-draft', json={'topic': 'Learner'}).status_code, 403) + self.assertEqual(self.client.post(f"/articles/{article['id']}/ai-refine", json={'instructions': 'x'}).status_code, 403) + + def test_draft_task_creates_and_refines_without_publishing(self): + self.redis.set.return_value = True + ai = patch('litellm.completion').start() + ai.return_value = Mock(choices=[Mock(message=Mock(content=json.dumps(DRAFT_RESPONSE)))]) + with patch('app.tasks.quiz_tasks.SessionLocal', sessionmaker(bind=self.bank.engine)), \ + patch('app.services.ai_service.get_model_for_task', return_value=('synthetic', None)), \ + patch('app.services.ai_service._proxy_model', return_value='synthetic'): + generate_article_draft('job-1', 3, 'Neonatal jaundice', '') + article = self.bank.db.query(Article).filter_by(slug='ai-draft-topic').one() + self.assertEqual(article.status, 'draft') + self.assertEqual([s['id'] for s in article.sections], ['a' * 32, 'b' * 32]) + self.redis.set.assert_any_call('extraction:status:job-1', 'completed', ex=3600) + generate_article_draft('job-2', 3, 'Neonatal jaundice', 'Refine', article.id) + refreshed = self.bank.db.query(Article).filter_by(id=article.id).one() + self.assertEqual(refreshed.status, 'draft') + self.assertEqual(refreshed.slug, 'ai-draft-topic') + patch.stopall() + self.redis.reset_mock() + + def test_cards_task_builds_private_linked_deck(self): + self.redis.set.return_value = True + self.bank.db.add(Article(slug='cards-source', title='Cards source', content='Body', + sections=[{'id': 'c' * 32, 'slug': 's', 'title': 'S', 'content': 'More'}], + user_id=3, status='published')) + self.bank.db.commit() + article = self.bank.db.query(Article).filter_by(slug='cards-source').one() + with patch('app.tasks.quiz_tasks.SessionLocal', sessionmaker(bind=self.bank.engine)), \ + patch('app.services.extraction_modes.generate_flashcards', + return_value=[{'front': 'F1', 'back': 'B1'}, {'front': 'F2', 'back': 'B2', 'page_reference': 3}]), \ + patch('app.services.ai_service.get_model_for_task', return_value=('synthetic', None)): + generate_article_cards('job-3', 3, article.id) + deck = self.bank.db.query(FlashcardDeck).filter_by(title='Cards: Cards source').one() + self.assertEqual(deck.is_shared, 0) + cards = self.bank.db.query(Flashcard).filter_by(deck_id=deck.id).all() + self.assertEqual(len(cards), 2) + self.assertEqual(deck.card_count, 2) + linked = self.bank.db.query(FlashcardArticleLink).filter_by(article_id=article.id).count() + self.assertEqual(linked, 2) + self.redis.set.assert_any_call('extraction:status:job-3', 'completed', ex=3600) + patch.stopall() + self.redis.reset_mock() + + +if __name__ == '__main__': + unittest.main() diff --git a/backend/tests/test_share_public.py b/backend/tests/test_share_public.py new file mode 100644 index 0000000..c259bc1 --- /dev/null +++ b/backend/tests/test_share_public.py @@ -0,0 +1,63 @@ +"""Public share-link endpoints: token lifecycle and public landing data.""" +import unittest + +import test_quiz_builder as fixtures +from app.models.quiz import Quiz +from app.models.quiz_question_link import QuizQuestionLink +from app.routers import quizzes, share + + +class ShareLinkTests(unittest.TestCase): + def setUp(self): + self.bank = fixtures.BuilderTests() + self.bank.setUp() + self.client = self.bank.client + self.client.app.include_router(quizzes.router, prefix='/quizzes') + self.client.app.include_router(share.router, prefix='/share') + + def tearDown(self): + self.bank.tearDown() + + def test_owner_only_token_lifecycle_and_public_landing(self): + self.bank.user = self.bank.owner + self.assertEqual(self.client.post('/quizzes/1/share-link').status_code, 403) + self.bank.user = self.bank.mod + created = self.client.post('/quizzes/1/share-link') + self.assertEqual(created.status_code, 200, created.text) + token = created.json()['token'] + self.assertEqual(self.client.post('/quizzes/1/share-link').json()['token'], token) # Stable token. + public = self.client.get(f'/share/{token}') + self.assertEqual(public.status_code, 200, public.text) + data = public.json() + self.assertEqual(data['quiz_id'], 1) + self.assertEqual(data['title'], 'Origin') + self.assertNotIn('questions', data) + self.assertNotIn('correct_answer', str(data)) + self.assertEqual(self.client.get('/share/unknown-token').status_code, 404) + self.bank.user = self.bank.peer + detail = self.client.get('/quizzes/1') + self.assertEqual(detail.json()['share_token'], token) + self.assertEqual(self.client.delete('/quizzes/1/share-link').status_code, 403) + self.bank.user = self.bank.mod + self.assertEqual(self.client.delete('/quizzes/1/share-link').status_code, 204) + self.assertEqual(self.client.get(f'/share/{token}').status_code, 404) + self.assertEqual(self.bank.db.get(Quiz, 1).is_shared, 0) + self.assertEqual(self.client.get('/quizzes/1').json()['share_token'], None) + + def test_course_and_private_question_quizzes_cannot_share(self): + self.bank.user = self.bank.mod + self.assertEqual(self.client.post('/quizzes/2/share-link').status_code, 400) # Course quiz. + mixed = Quiz(title='Private mix', user_id=3, is_published=1, questions_count=1) + self.bank.db.add(mixed) + self.bank.db.flush() + self.bank.db.add(QuizQuestionLink(quiz_id=mixed.id, question_id=4, position=0)) + self.bank.db.commit() + self.assertEqual(self.client.post(f'/quizzes/{mixed.id}/share-link').status_code, 400) + mixed.is_shared = 1 # Stale share flag alone must not make it public. + self.bank.db.commit() + self.assertEqual(self.client.post(f'/quizzes/{mixed.id}/share-link').status_code, 400) + self.assertEqual(self.bank.db.get(Quiz, mixed.id).share_token, None) + + +if __name__ == '__main__': + unittest.main() diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4de519e..a0c1bdb 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -28,6 +28,7 @@ const LandingPage = lazy(() => import('./pages/LandingPage')) const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage')) const ArticlesPage = lazy(() => import('./pages/ArticlesPage')) const ArticlePage = lazy(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage }))) +const PublicQuizPage = lazy(() => import('./pages/PublicQuizPage')) const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage')) const CoursesPage = lazy(() => import('./pages/CoursesPage')) const CourseDetailPage = lazy(() => import('./pages/CourseDetailPage')) @@ -77,6 +78,7 @@ function AppRoutes() { } /> } /> } /> + } /> {/* Authenticated app — wrapped in AppLayout */} }> diff --git a/frontend/src/components/CommentSection.jsx b/frontend/src/components/CommentSection.jsx new file mode 100644 index 0000000..d4f4127 --- /dev/null +++ b/frontend/src/components/CommentSection.jsx @@ -0,0 +1,95 @@ +import { useCallback, useEffect, useState } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import api from '../api/client' + +const LIMIT = 20 + +export default function CommentSection({ articleId, questionId }) { + const [comments, setComments] = useState([]) + const [total, setTotal] = useState(0) + const [offset, setOffset] = useState(0) + const [draft, setDraft] = useState('') + const [error, setError] = useState('') + const [submitting, setSubmitting] = useState(false) + const [loaded, setLoaded] = useState(false) + + const params = articleId ? { article_id: articleId } : { question_id: questionId } + + const load = useCallback(async (off = 0) => { + try { + const res = await api.get('/comments', { params: { ...params, limit: LIMIT, offset: off } }) + const list = res.data?.comments || [] + setComments(prev => off === 0 ? list : [...prev, ...list]) + setTotal(res.data?.total || 0) + setOffset(off) + } catch { setComments([]); setTotal(0) } + finally { setLoaded(true) } + }, [articleId, questionId]) + + useEffect(() => { setComments([]); setLoaded(false); load(0) }, [load]) + + const submit = async () => { + setError('') + setSubmitting(true) + try { + const res = await api.post('/comments', { ...params, content: draft }) + setComments(prev => [res.data, ...prev.filter(c => c.id !== res.data.id)]) + setTotal(t => t + 1) + setDraft('') + } catch (err) { + setError(typeof err.response?.data?.detail === 'string' ? err.response.data.detail : 'Could not post comment') + } finally { setSubmitting(false) } + } + + const moderate = async (id, status) => { + setError('') + try { + const res = await api.patch(`/comments/${id}`, { status }) + setComments(prev => prev.map(c => c.id === id ? res.data : c)) + } catch (err) { setError('Could not moderate comment') } + } + + if (!loaded) return null + + return ( +
+

Comments {total > 0 && ({total})}

+
+