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.
This commit is contained in:
Daniel 2026-09-07 17:10:08 +02:00
parent 6c85f8b4a9
commit 0fa8d0a689
24 changed files with 1222 additions and 20 deletions

View file

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

View file

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

View file

@ -11,7 +11,7 @@ from app.logging_config import setup_logging
setup_logging(settings.LOG_LEVEL) setup_logging(settings.LOG_LEVEL)
from app.database import engine, Base, SessionLocal 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 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.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler 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(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(articles.router, prefix="/api/articles", tags=["articles"]) 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(documents.router, prefix="/api/documents", tags=["documents"])
app.include_router(quizzes.router, prefix="/api/quizzes", tags=["quizzes"]) app.include_router(quizzes.router, prefix="/api/quizzes", tags=["quizzes"])
app.include_router(attempts.router, prefix="/api/attempts", tags=["attempts"]) app.include_router(attempts.router, prefix="/api/attempts", tags=["attempts"])

View file

@ -10,6 +10,7 @@ from app.models.favorite import Favorite
from app.models.user_note import UserNote from app.models.user_note import UserNote
from app.models.lab_reference import LabReference from app.models.lab_reference import LabReference
from app.models.article import Article, QuestionArticleLink from app.models.article import Article, QuestionArticleLink
from app.models.comment import Comment
from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating, FlashcardQuestionLink, FlashcardArticleLink from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating, FlashcardQuestionLink, FlashcardArticleLink
__all__ = [ __all__ = [
@ -27,6 +28,7 @@ __all__ = [
"LabReference", "LabReference",
"Article", "Article",
"QuestionArticleLink", "QuestionArticleLink",
"Comment",
"FlashcardDeck", "FlashcardDeck",
"Flashcard", "Flashcard",
"FlashcardDeckRating", "FlashcardDeckRating",

View file

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

View file

@ -28,6 +28,7 @@ class Quiz(Base):
max_attempts = Column(Integer, nullable=True) # null = unlimited max_attempts = Column(Integer, nullable=True) # null = unlimited
questions_per_attempt = Column(Integer, nullable=True) # null = all; set = random subset from pool 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 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") section = relationship("Section", back_populates="quizzes")
user = relationship("User", back_populates="quizzes") user = relationship("User", back_populates="quizzes")

View file

@ -63,6 +63,40 @@ class ArticleLinkIn(BaseModel):
section_id: str | None = None 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]): def _validate_sections(sections: list[ArticleSection]):
ids, slugs = set(), set() ids, slugs = set(), set()
for section in sections: for section in sections:
@ -341,3 +375,87 @@ def unlink_question(
question_id=question_id, article_id=article_id, section_id=section_id, question_id=question_id, article_id=article_id, section_id=section_id,
).delete(synchronize_session=False) ).delete(synchronize_session=False)
db.commit() 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

View file

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

View file

@ -15,7 +15,7 @@ from app.models.question_category import QuestionCategory
from app.services.quiz_builder import category_breadcrumbs from app.services.quiz_builder import category_breadcrumbs
from app.schemas.quiz import QuizCreate, QuizUpdate, QuizResponse, QuizDetail, QuizLearningDetail, QuizReview from app.schemas.quiz import QuizCreate, QuizUpdate, QuizResponse, QuizDetail, QuizLearningDetail, QuizReview
from app.services import quiz_service 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.auth import get_current_user, require_moderator
from app.utils.quiz_questions import get_quiz_questions, question_in_quiz, remove_question_from_quiz 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: if not quiz:
raise HTTPException(404, "Quiz not found") raise HTTPException(404, "Quiz not found")
return set_quiz_shared(db, quiz, current_user, shared) 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()

View file

@ -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 [],
}

View file

@ -63,6 +63,7 @@ class QuizResponse(BaseModel):
allow_review: int | None = 1 allow_review: int | None = 1
max_attempts: int | None = None max_attempts: int | None = None
attempt_mode: str | None = None attempt_mode: str | None = None
share_token: str | None = None
class Config: class Config:
from_attributes = True from_attributes = True

View file

@ -677,3 +677,151 @@ def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int,
pass pass
finally: finally:
db.close() 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()

View file

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

View file

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

View file

@ -28,6 +28,7 @@ const LandingPage = lazy(() => import('./pages/LandingPage'))
const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage')) const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
const ArticlesPage = lazy(() => import('./pages/ArticlesPage')) const ArticlesPage = lazy(() => import('./pages/ArticlesPage'))
const ArticlePage = lazy(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage }))) const ArticlePage = lazy(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage })))
const PublicQuizPage = lazy(() => import('./pages/PublicQuizPage'))
const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage')) const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage'))
const CoursesPage = lazy(() => import('./pages/CoursesPage')) const CoursesPage = lazy(() => import('./pages/CoursesPage'))
const CourseDetailPage = lazy(() => import('./pages/CourseDetailPage')) const CourseDetailPage = lazy(() => import('./pages/CourseDetailPage'))
@ -77,6 +78,7 @@ function AppRoutes() {
<Route path="/forgot-password" element={<ForgotPasswordPage />} /> <Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} /> <Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/sso-callback" element={<SsoCallbackPage />} /> <Route path="/sso-callback" element={<SsoCallbackPage />} />
<Route path="/share/:token" element={<PublicQuizPage />} />
{/* Authenticated app — wrapped in AppLayout */} {/* Authenticated app — wrapped in AppLayout */}
<Route element={<RequireAuth />}> <Route element={<RequireAuth />}>

View file

@ -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 (
<section className="comment-section" aria-label="Comments" data-testid="comment-section">
<h3>Comments {total > 0 && <span className="article-card-meta">({total})</span>}</h3>
<div className="comment-form">
<textarea className="input" rows={2} maxLength={2000} value={draft} onChange={e => setDraft(e.target.value)}
placeholder="Ask a question or add a note — visible to everyone after educator approval." aria-label="Comment text" />
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button className="btn btn-primary btn-sm" disabled={submitting || !draft.trim()} onClick={submit}>
{submitting ? 'Posting…' : 'Post comment'}
</button>
{error && <span className="form-error" role="alert">{error}</span>}
</div>
</div>
{comments.length === 0 ? (
<p className="article-card-meta">No comments yet.</p>
) : (
<ul className="comment-list">
{comments.map(comment => (
<li key={comment.id} className="comment">
<div className="comment-meta">
<strong>{comment.author_name}</strong>
<span className="article-card-meta">{new Date(comment.created_at).toLocaleDateString()}
{comment.status === 'pending' && <em className="article-status-draft"> awaiting approval</em>}</span>
</div>
<div className="comment-content"><ReactMarkdown remarkPlugins={[remarkGfm]}>{comment.content}</ReactMarkdown></div>
{comment.can_moderate && (
<div style={{ display: 'flex', gap: 6 }}>
{comment.status !== 'approved' && <button className="btn btn-sm btn-primary" onClick={() => moderate(comment.id, 'approved')}>Approve</button>}
{comment.status !== 'rejected' && <button className="btn btn-sm btn-secondary" onClick={() => moderate(comment.id, 'rejected')}>Reject</button>}
</div>
)}
</li>
))}
</ul>
)}
{comments.length < total && (
<button className="btn btn-secondary btn-sm" onClick={() => load(offset + LIMIT)}>Load more</button>
)}
</section>
)
}

View file

@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import CommentSection from './CommentSection'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() } }))
const comments = [
{ id: 1, author_name: 'Educator', content: 'Approved **note**', status: 'approved', created_at: '2026-09-07T10:00:00', own: false, can_moderate: false },
{ id: 2, author_name: 'Me', content: 'My pending note', status: 'pending', created_at: '2026-09-07T11:00:00', own: true, can_moderate: false },
]
beforeEach(() => {
vi.resetAllMocks()
api.get.mockResolvedValue({ data: { total: comments.length, comments } })
api.post.mockResolvedValue({ data: { id: 3, author_name: 'Me', content: 'New note', status: 'pending', created_at: '2026-09-07T12:00:00', own: true, can_moderate: false } })
api.patch.mockResolvedValue({ data: { ...comments[1], status: 'approved', can_moderate: true } })
})
describe('comment section', () => {
it('lists comments with markdown and own pending state', async () => {
render(<CommentSection articleId={1} />)
expect(await screen.findByText('Educator')).toBeInTheDocument()
expect(screen.getByText('note')).toBeInTheDocument() // **note** rendered as strong text
expect(screen.getByText(/awaiting approval/)).toBeInTheDocument()
})
it('posts a comment and prepends it', async () => {
render(<CommentSection questionId={5} />)
await screen.findByRole('heading', { name: /Comments/ })
await userEvent.type(screen.getByLabelText('Comment text'), 'New note')
await userEvent.click(screen.getByRole('button', { name: 'Post comment' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/comments', { question_id: 5, content: 'New note' }))
expect(await screen.findByText('New note')).toBeInTheDocument()
})
it('renders comment markdown without executing raw HTML', async () => {
api.get.mockResolvedValue({ data: { total: 1, comments: [{ id: 4, author_name: 'A', content: '<img src=x onerror=alert(1)>Text', status: 'approved', created_at: '2026-09-07T10:00:00', own: false, can_moderate: false }] } })
const { container } = render(<CommentSection articleId={1} />)
expect(await screen.findByText(/Text/)).toBeInTheDocument()
expect(container.querySelector('img')).toBeNull()
})
it('lets a moderator approve and reject', async () => {
api.get.mockResolvedValue({ data: { total: 1, comments: [{ ...comments[1], can_moderate: true }] } })
render(<CommentSection articleId={1} />)
expect(await screen.findByRole('button', { name: 'Approve' })).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Approve' }))
await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/comments/2', { status: 'approved' }))
})
})

View file

@ -5,7 +5,7 @@ import api from '../api/client'
import Dialog from '../components/Dialog' import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog' import { useDialog } from '../hooks/useDialog'
const TASKS = ['extraction', 'tts', 'stt', 'teach', 'keyword', 'flashcard'] const TASKS = ['extraction', 'tts', 'stt', 'teach', 'keyword', 'flashcard', 'article']
export default function AdminPage() { export default function AdminPage() {
const { user } = useAuth() const { user } = useAuth()

View file

@ -46,3 +46,12 @@
.article-drawer-toggle { display: inline-block; } .article-drawer-toggle { display: inline-block; }
.article-content { padding: 16px; } .article-content { padding: 16px; }
} }
.comment-section { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 14px; }
.comment-section h3 { margin: 0 0 10px; font-size: 1rem; }
.comment-form { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
.comment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.comment { border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
.comment-meta { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
.comment-content { font-size: 0.88rem; }
.comment-content p { margin: 0 0 4px; }
.comment-section .comment .btn { margin-top: 6px; }

View file

@ -5,6 +5,7 @@ import remarkGfm from 'remark-gfm'
import api from '../api/client' import api from '../api/client'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import RichEditor from '../components/RichEditor' import RichEditor from '../components/RichEditor'
import CommentSection from '../components/CommentSection'
import { markdownImageUrl } from '../utils/uploads' import { markdownImageUrl } from '../utils/uploads'
import './ArticlesPage.css' import './ArticlesPage.css'
@ -35,9 +36,13 @@ export default function ArticlesPage() {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [showAi, setShowAi] = useState(false)
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [slug, setSlug] = useState('') const [slug, setSlug] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [aiTopic, setAiTopic] = useState('')
const [aiInstructions, setAiInstructions] = useState('')
const [aiStatus, setAiStatus] = useState('')
const navigate = useNavigate() const navigate = useNavigate()
const load = useCallback(() => { const load = useCallback(() => {
@ -64,6 +69,22 @@ export default function ArticlesPage() {
} }
} }
const aiDraft = async () => {
setError('')
if (!aiTopic.trim()) { setError('Topic is required'); return }
try {
const res = await api.post('/articles/ai-draft', { topic: aiTopic, instructions: aiInstructions })
setAiStatus('Drafting…')
const poll = async () => {
const job = await api.get(`/articles/job/${res.data.job_id}`)
if (job.data.status === 'completed') { setAiStatus(''); setShowAi(false); load() }
else if (job.data.status === 'failed') { setAiStatus(`Failed: ${job.data.error || 'unknown error'}`) }
else { setAiStatus(`Working… ${job.data.steps.at(-1)?.message || ''}`); setTimeout(poll, 2000) }
}
setTimeout(poll, 1000)
} catch (err) { setError(typeof err.response?.data?.detail === 'string' ? err.response.data.detail : 'Could not start drafting') }
}
return ( return (
<div className="articles-page"> <div className="articles-page">
<div className="articles-header"> <div className="articles-header">
@ -72,9 +93,27 @@ export default function ArticlesPage() {
<p className="articles-subtitle">Educator-reviewed articles linked to questions and cards.</p> <p className="articles-subtitle">Educator-reviewed articles linked to questions and cards.</p>
</div> </div>
{user?.is_moderator && ( {user?.is_moderator && (
<button className="btn btn-primary" onClick={() => setShowCreate(v => !v)}>New article</button> <div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-secondary" onClick={() => { setShowAi(v => !v); setShowCreate(false) }}>AI draft</button>
<button className="btn btn-primary" onClick={() => setShowCreate(v => !v)}>New article</button>
</div>
)} )}
</div> </div>
{showAi && user?.is_moderator && (
<div className="card" style={{ marginBottom: 16 }}>
<h3>AI article draft</h3>
<p className="articles-subtitle" style={{ marginBottom: 8 }}>Drafts stay private until you publish them. The model does not invent references.</p>
<label className="form-label" htmlFor="ai-topic">Topic</label>
<input id="ai-topic" className="input" value={aiTopic} onChange={e => setAiTopic(e.target.value)} placeholder="e.g. Febrile seizures" />
<label className="form-label" htmlFor="ai-instructions">Instructions (optional)</label>
<textarea id="ai-instructions" className="input" rows={2} value={aiInstructions} onChange={e => setAiInstructions(e.target.value)} placeholder="Include an initial workup section" />
{aiStatus && <p className="articles-subtitle" role="status">{aiStatus}</p>}
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<button className="btn btn-primary btn-sm" onClick={aiDraft} disabled={!!aiStatus}>Draft article</button>
<button className="btn btn-secondary btn-sm" onClick={() => setShowAi(false)}>Cancel</button>
</div>
</div>
)}
{showCreate && ( {showCreate && (
<div className="card" style={{ marginBottom: 16 }}> <div className="card" style={{ marginBottom: 16 }}>
<h3>New article</h3> <h3>New article</h3>
@ -130,6 +169,10 @@ export function ArticlePage() {
const [linkQuestionId, setLinkQuestionId] = useState('') const [linkQuestionId, setLinkQuestionId] = useState('')
const [linkSection, setLinkSection] = useState('') const [linkSection, setLinkSection] = useState('')
const [linkError, setLinkError] = useState('') const [linkError, setLinkError] = useState('')
const [aiJob, setAiJob] = useState(null)
const [aiMessage, setAiMessage] = useState('')
const [showRefine, setShowRefine] = useState(false)
const [refineText, setRefineText] = useState('')
const navigate = useNavigate() const navigate = useNavigate()
const load = useCallback(() => { const load = useCallback(() => {
@ -190,6 +233,25 @@ export function ArticlePage() {
load() load()
} }
const pollAiJob = async (jobId) => {
try {
const job = await api.get(`/articles/job/${jobId}`)
if (job.data.status === 'completed') { setAiJob(null); setAiMessage(''); load() }
else if (job.data.status === 'failed') { setAiJob(null); setAiMessage(`AI job failed: ${job.data.error || 'unknown error'}`) }
else { setAiMessage(`Working… ${job.data.steps.at(-1)?.message || ''}`); setTimeout(() => pollAiJob(jobId), 2000) }
} catch { setAiJob(null); setAiMessage('Could not read job status') }
}
const runAi = async (kind, payload) => {
setError('')
setAiMessage('')
try {
const res = await api.post(kind === 'cards' ? `/articles/${id}/ai-cards` : `/articles/${id}/ai-refine`, payload)
setAiJob(res.data.job_id)
setTimeout(() => pollAiJob(res.data.job_id), 1000)
} catch (err) { setError(typeof err.response?.data?.detail === 'string' ? err.response.data.detail : 'Could not start AI job') }
}
if (loading) return <div className="loading"><div className="spinner" /></div> if (loading) return <div className="loading"><div className="spinner" /></div>
if (error && !article) return <div className="card empty-state">{error}</div> if (error && !article) return <div className="card empty-state">{error}</div>
if (!article) return null if (!article) return null
@ -209,6 +271,12 @@ export function ArticlePage() {
<p className="articles-subtitle">{questions.length} linked questions · {cards.length} linked cards</p> <p className="articles-subtitle">{questions.length} linked questions · {cards.length} linked cards</p>
</div> </div>
<div className="article-header-actions"> <div className="article-header-actions">
{user?.is_moderator && !editing && (
<>
<button className="btn btn-secondary btn-sm" onClick={() => runAi('cards')} disabled={!!aiJob}>Generate cards</button>
<button className="btn btn-secondary btn-sm" onClick={() => setShowRefine(v => !v)} disabled={!!aiJob}>AI refine</button>
</>
)}
{canEdit && !editing && ( {canEdit && !editing && (
<> <>
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(true)}>Edit</button> <button className="btn btn-secondary btn-sm" onClick={() => setEditing(true)}>Edit</button>
@ -221,6 +289,18 @@ export function ArticlePage() {
</div> </div>
</div> </div>
{error && <div className="form-error" role="alert">{error}</div>} {error && <div className="form-error" role="alert">{error}</div>}
{showRefine && user?.is_moderator && (
<div className="card" style={{ marginBottom: 12 }}>
<h4>AI refine</h4>
<p className="articles-subtitle">Rewrites this draft; the result stays a draft.</p>
<textarea className="input" rows={2} value={refineText} onChange={e => setRefineText(e.target.value)} placeholder="e.g. Shorten the workup section" aria-label="Refine instructions" />
{aiMessage && <p className="articles-subtitle" role="status">{aiMessage}</p>}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button className="btn btn-primary btn-sm" disabled={!!aiJob} onClick={() => runAi('refine', { instructions: refineText })}>Refine</button>
<button className="btn btn-secondary btn-sm" onClick={() => setShowRefine(false)}>Cancel</button>
</div>
</div>
)}
{editing && form ? ( {editing && form ? (
<div className="article-edit"> <div className="article-edit">
@ -328,6 +408,7 @@ export function ArticlePage() {
))} ))}
</div> </div>
)} )}
<CommentSection articleId={article.id} />
</main> </main>
</div> </div>
)} )}

View file

@ -0,0 +1,60 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
import './ArticlesPage.css'
export default function PublicQuizPage() {
const { token } = useParams()
const { user } = useAuth()
const navigate = useNavigate()
const [quiz, setQuiz] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
api.get(`/share/${token}`)
.then(res => setQuiz(res.data))
.catch(() => setQuiz(null))
.finally(() => setLoading(false))
}, [token])
if (loading) return <div className="loading"><div className="spinner" /></div>
if (!quiz) {
return (
<div className="articles-page">
<div className="card empty-state">
This shared quiz is not available. It may have been revoked or the link is incorrect.
</div>
</div>
)
}
return (
<div className="articles-page">
<div className="card" style={{ maxWidth: 620, margin: '32px auto', textAlign: 'center', padding: 32 }}>
<div style={{ fontSize: '2.2rem', marginBottom: 10 }}>🏥</div>
<h1 style={{ fontSize: '1.4rem', marginBottom: 6 }}>{quiz.title}</h1>
<p className="articles-subtitle">
{quiz.owner_name ? `Shared by ${quiz.owner_name} · ` : ''}
{quiz.questions_count} questions
{quiz.mode === 'timed' && quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min` : ' · study mode'}
{quiz.allow_review === 1 ? '' : ' · no answer review'}
</p>
{quiz.category_breadcrumbs?.length > 0 && (
<p className="articles-subtitle" style={{ marginBottom: 18 }}>
{quiz.category_breadcrumbs.map(c => c.name).join(' / ')}
</p>
)}
{user ? (
<button className="btn btn-primary" onClick={() => navigate(`/quizzes/${quiz.quiz_id}`)}>
Take this quiz
</button>
) : (
<Link className="btn btn-primary" to={`/login?return_to=/share/${token}`}>
Sign in to take this quiz
</Link>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import PublicQuizPage from './PublicQuizPage'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
vi.mock('../api/client', () => ({ default: { get: vi.fn() } }))
vi.mock('../context/AuthContext', () => ({ useAuth: vi.fn() }))
const quiz = {
quiz_id: 7, title: 'Shared pediatrics quiz', mode: 'timed', questions_count: 12,
time_limit_minutes: 20, allow_review: 1, owner_name: 'Educator', category_breadcrumbs: [{ name: 'Pediatrics' }],
}
beforeEach(() => {
vi.resetAllMocks()
api.get.mockResolvedValue({ data: quiz })
})
describe('public share page', () => {
it('asks a signed-out visitor to sign in without exposing questions', async () => {
useAuth.mockReturnValue({ user: null })
render(<MemoryRouter initialEntries={['/share/abc']}><Routes><Route path="/share/:token" element={<PublicQuizPage />} /></Routes></MemoryRouter>)
expect(await screen.findByRole('heading', { name: 'Shared pediatrics quiz' })).toBeInTheDocument()
const link = screen.getByRole('link', { name: 'Sign in to take this quiz' })
expect(link).toHaveAttribute('href', '/login?return_to=/share/abc')
expect(screen.queryByText(/correct_answer/)).not.toBeInTheDocument()
})
it('lets a signed-in learner start the quiz', async () => {
useAuth.mockReturnValue({ user: { id: 1, name: 'Learner' } })
render(<MemoryRouter initialEntries={['/share/abc']}><Routes><Route path="/share/:token" element={<PublicQuizPage />} /></Routes></MemoryRouter>)
expect(await screen.findByRole('button', { name: 'Take this quiz' })).toBeInTheDocument()
})
it('shows an honest missing state for unknown tokens', async () => {
useAuth.mockReturnValue({ user: null })
api.get.mockRejectedValue({ response: { status: 404 } })
render(<MemoryRouter initialEntries={['/share/gone']}><Routes><Route path="/share/:token" element={<PublicQuizPage />} /></Routes></MemoryRouter>)
expect(await screen.findByText(/not available/)).toBeInTheDocument()
})
})

View file

@ -1,5 +1,6 @@
import { uploadUrl } from '../utils/uploads' import { uploadUrl } from '../utils/uploads'
import QuestionReadingLinks from '../components/QuestionReadingLinks' import QuestionReadingLinks from '../components/QuestionReadingLinks'
import CommentSection from '../components/CommentSection'
import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react' import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom' import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
@ -265,25 +266,61 @@ function TimerDisplay({ seconds, total }) {
) )
} }
function QuizCodeBadge({ code }) { function ShareLinkBadge({ quiz, onShareChanged }) {
if (!code) return null const { user } = useAuth()
const [copied, setCopied] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const canManage = user && (user.is_moderator || quiz.user_id === user.id)
const copyCode = async () => { const copyLink = async () => {
setError('')
try { try {
await navigator.clipboard.writeText(String(code)) await navigator.clipboard.writeText(`${window.location.origin}/share/${quiz.share_token}`)
} catch { } setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch { setError('Could not copy the link') }
}
const enable = async () => {
setBusy(true); setError('')
try {
const res = await api.post(`/quizzes/${quiz.id}/share-link`)
onShareChanged?.(res.data.token)
} catch (err) { setError(typeof err.response?.data?.detail === 'string' ? err.response.data.detail : 'Could not enable sharing') }
finally { setBusy(false) }
}
const revoke = async () => {
setBusy(true); setError('')
try {
await api.delete(`/quizzes/${quiz.id}/share-link`)
onShareChanged?.(null)
} catch (err) { setError(typeof err.response?.data?.detail === 'string' ? err.response.data.detail : 'Could not revoke sharing') }
finally { setBusy(false) }
} }
return ( return (
<div className="quiz-code-badge" title="Share this PIN/code to identify the quiz"> <div className="quiz-code-badge">
<span>Quiz PIN</span> <span>Share</span>
<code>{code}</code> {quiz.share_token ? (
<button type="button" className="btn btn-secondary btn-sm" onClick={copyCode}>Copy</button> <>
<button type="button" className="btn btn-primary btn-sm" onClick={copyLink}>
{copied ? '✓ Link copied' : 'Copy share link'}
</button>
{canManage && <button type="button" className="btn btn-secondary btn-sm" disabled={busy} onClick={revoke}>Revoke</button>}
</>
) : canManage ? (
<button type="button" className="btn btn-secondary btn-sm" disabled={busy} onClick={enable}>
Make shareable
</button>
) : null}
{error && <span className="form-error" role="alert">{error}</span>}
</div> </div>
) )
} }
function CourseQuizStart({ quiz, onStart }) { function CourseQuizStart({ quiz, onStart, onShareChanged }) {
const mode = quiz.mode === 'timed' || quiz.allow_review !== 1 ? 'exam' : 'study' const mode = quiz.mode === 'timed' || quiz.allow_review !== 1 ? 'exam' : 'study'
const [error, setError] = useState('') const [error, setError] = useState('')
const [starting, setStarting] = useState(false) const [starting, setStarting] = useState(false)
@ -301,7 +338,7 @@ function CourseQuizStart({ quiz, onStart }) {
<div style={{ maxWidth: 480, margin: '40px auto' }}> <div style={{ maxWidth: 480, margin: '40px auto' }}>
<div className="card" style={{ textAlign: 'center' }}> <div className="card" style={{ textAlign: 'center' }}>
<h2 style={{ marginBottom: 8 }}>{quiz.title}</h2> <h2 style={{ marginBottom: 8 }}>{quiz.title}</h2>
<QuizCodeBadge code={quiz.quiz_code || quiz.id} /> <ShareLinkBadge quiz={quiz} onShareChanged={onShareChanged} />
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem', marginBottom: 8 }}> <p style={{ color: 'var(--text-muted)', fontSize: '0.9rem', marginBottom: 8 }}>
{quiz.questions_per_attempt || quiz.questions_count} questions {quiz.questions_per_attempt || quiz.questions_count} questions
{quiz.mode === 'timed' && quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min time limit` : ''} {quiz.mode === 'timed' && quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min time limit` : ''}
@ -324,7 +361,7 @@ function CourseQuizStart({ quiz, onStart }) {
) )
} }
function ModeSelectScreen({ quiz, voices, onStart }) { function ModeSelectScreen({ quiz, voices, onStart, onShareChanged }) {
const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '') const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '')
const [customTimer, setCustomTimer] = useState(quiz.time_limit_minutes || '') const [customTimer, setCustomTimer] = useState(quiz.time_limit_minutes || '')
const [startError, setStartError] = useState('') const [startError, setStartError] = useState('')
@ -352,7 +389,7 @@ function ModeSelectScreen({ quiz, voices, onStart }) {
<div className="card" style={{ textAlign: 'center' }}> <div className="card" style={{ textAlign: 'center' }}>
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}>📝</div> <div style={{ fontSize: '2.5rem', marginBottom: 12 }}>📝</div>
<h2 style={{ marginBottom: 6 }}>{quiz.title}</h2> <h2 style={{ marginBottom: 6 }}>{quiz.title}</h2>
<QuizCodeBadge code={quiz.quiz_code || quiz.id} /> <ShareLinkBadge quiz={quiz} onShareChanged={onShareChanged} />
<p style={{ color: '#64748b', fontSize: '0.9rem', marginBottom: 24 }}> <p style={{ color: '#64748b', fontSize: '0.9rem', marginBottom: 24 }}>
{quiz.questions_count} questions {quiz.questions_count} questions
{quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min limit` : ''} {quiz.time_limit_minutes ? ` · ${quiz.time_limit_minutes} min limit` : ''}
@ -895,9 +932,9 @@ const timerStarted = timeLeft !== null
<div style={{ color: 'var(--text-muted)', fontSize: '0.95rem' }}>Loading quiz</div> <div style={{ color: 'var(--text-muted)', fontSize: '0.95rem' }}>Loading quiz</div>
</div> </div>
) : returnTo ? ( ) : returnTo ? (
<CourseQuizStart quiz={quiz} onStart={startQuiz} /> <CourseQuizStart quiz={quiz} onStart={startQuiz} onShareChanged={token => setQuiz(q => ({ ...q, share_token: token }))} />
) : ( ) : (
<ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} /> <ModeSelectScreen quiz={quiz} voices={voices} onStart={startQuiz} onShareChanged={token => setQuiz(q => ({ ...q, share_token: token }))} />
)} )}
</div> </div>
) )
@ -1055,7 +1092,7 @@ const timerStarted = timeLeft !== null
</span> </span>
<span>Q {currentIdx + 1} / {totalCount}</span> <span>Q {currentIdx + 1} / {totalCount}</span>
<span style={{ color: 'var(--text-subtle)' }}>{answeredCount} answered</span> <span style={{ color: 'var(--text-subtle)' }}>{answeredCount} answered</span>
<QuizCodeBadge code={quizCode} /> <ShareLinkBadge quiz={quiz} onShareChanged={token => setQuiz(q => ({ ...q, share_token: token }))} />
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
@ -1274,6 +1311,7 @@ const timerStarted = timeLeft !== null
</div> </div>
)} )}
<QuestionReadingLinks questionId={current.id} /> <QuestionReadingLinks questionId={current.id} />
<CommentSection questionId={current.id} />
{current.question_type === 'fill_blank' && ( {current.question_type === 'fill_blank' && (
<div className="explanation" style={{ marginTop: 12, borderLeftColor: '#22c55e' }}> <div className="explanation" style={{ marginTop: 12, borderLeftColor: '#22c55e' }}>
<strong>Correct Answer:</strong> {current.correct_answer} <strong>Correct Answer:</strong> {current.correct_answer}

View file

@ -223,4 +223,23 @@ describe('quiz player', () => {
expect(await screen.findByText('Submitted results')).toBeInTheDocument() expect(await screen.findByText('Submitted results')).toBeInTheDocument()
expect(api.post).toHaveBeenLastCalledWith('/attempts/50/submit', { answers: [{ question_id: 1, user_answer: 'Second answer' }] }) expect(api.post).toHaveBeenLastCalledWith('/attempts/50/submit', { answers: [{ question_id: 1, user_answer: 'Second answer' }] })
}) })
it('makes a quiz shareable and copies the link without showing it', async () => {
const clipboard = { writeText: vi.fn().mockResolvedValue(undefined) }
Object.defineProperty(navigator, 'clipboard', { value: clipboard, configurable: true })
const originalPost = api.post.getMockImplementation()
api.post.mockImplementation((url, ...args) => {
if (url === '/quizzes/10/share-link') return Promise.resolve({ data: { token: 'tok-1' } })
return originalPost(url, ...args)
})
mount()
await screen.findByRole('button', { name: 'Make shareable' })
await userEvent.click(screen.getByRole('button', { name: 'Make shareable' }))
await screen.findByRole('button', { name: 'Copy share link' })
expect(api.post).toHaveBeenCalledWith('/quizzes/10/share-link')
await userEvent.click(screen.getByRole('button', { name: 'Copy share link' }))
expect(clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining('/share/tok-1'))
expect(screen.getByText('✓ Link copied')).toBeInTheDocument()
expect(screen.queryByText(/share\/tok-1/)).not.toBeInTheDocument()
})
}) })