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

View file

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

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

View file

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

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

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
max_attempts: int | None = None
attempt_mode: str | None = None
share_token: str | None = None
class Config:
from_attributes = True

View file

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

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 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() {
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/sso-callback" element={<SsoCallbackPage />} />
<Route path="/share/:token" element={<PublicQuizPage />} />
{/* Authenticated app — wrapped in AppLayout */}
<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 { 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() {
const { user } = useAuth()

View file

@ -46,3 +46,12 @@
.article-drawer-toggle { display: inline-block; }
.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 { useAuth } from '../context/AuthContext'
import RichEditor from '../components/RichEditor'
import CommentSection from '../components/CommentSection'
import { markdownImageUrl } from '../utils/uploads'
import './ArticlesPage.css'
@ -35,9 +36,13 @@ export default function ArticlesPage() {
const [query, setQuery] = useState('')
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [showAi, setShowAi] = useState(false)
const [title, setTitle] = useState('')
const [slug, setSlug] = useState('')
const [error, setError] = useState('')
const [aiTopic, setAiTopic] = useState('')
const [aiInstructions, setAiInstructions] = useState('')
const [aiStatus, setAiStatus] = useState('')
const navigate = useNavigate()
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 (
<div className="articles-page">
<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>
</div>
{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>
{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 && (
<div className="card" style={{ marginBottom: 16 }}>
<h3>New article</h3>
@ -130,6 +169,10 @@ export function ArticlePage() {
const [linkQuestionId, setLinkQuestionId] = useState('')
const [linkSection, setLinkSection] = 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 load = useCallback(() => {
@ -190,6 +233,25 @@ export function ArticlePage() {
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 (error && !article) return <div className="card empty-state">{error}</div>
if (!article) return null
@ -209,6 +271,12 @@ export function ArticlePage() {
<p className="articles-subtitle">{questions.length} linked questions · {cards.length} linked cards</p>
</div>
<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 && (
<>
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(true)}>Edit</button>
@ -221,6 +289,18 @@ export function ArticlePage() {
</div>
</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 ? (
<div className="article-edit">
@ -328,6 +408,7 @@ export function ArticlePage() {
))}
</div>
)}
<CommentSection articleId={article.id} />
</main>
</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 QuestionReadingLinks from '../components/QuestionReadingLinks'
import CommentSection from '../components/CommentSection'
import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
@ -265,25 +266,61 @@ function TimerDisplay({ seconds, total }) {
)
}
function QuizCodeBadge({ code }) {
if (!code) return null
function ShareLinkBadge({ quiz, onShareChanged }) {
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 {
await navigator.clipboard.writeText(String(code))
} catch { }
await navigator.clipboard.writeText(`${window.location.origin}/share/${quiz.share_token}`)
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 (
<div className="quiz-code-badge" title="Share this PIN/code to identify the quiz">
<span>Quiz PIN</span>
<code>{code}</code>
<button type="button" className="btn btn-secondary btn-sm" onClick={copyCode}>Copy</button>
<div className="quiz-code-badge">
<span>Share</span>
{quiz.share_token ? (
<>
<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>
)
}
function CourseQuizStart({ quiz, onStart }) {
function CourseQuizStart({ quiz, onStart, onShareChanged }) {
const mode = quiz.mode === 'timed' || quiz.allow_review !== 1 ? 'exam' : 'study'
const [error, setError] = useState('')
const [starting, setStarting] = useState(false)
@ -301,7 +338,7 @@ function CourseQuizStart({ quiz, onStart }) {
<div style={{ maxWidth: 480, margin: '40px auto' }}>
<div className="card" style={{ textAlign: 'center' }}>
<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 }}>
{quiz.questions_per_attempt || quiz.questions_count} questions
{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 [customTimer, setCustomTimer] = useState(quiz.time_limit_minutes || '')
const [startError, setStartError] = useState('')
@ -352,7 +389,7 @@ function ModeSelectScreen({ quiz, voices, onStart }) {
<div className="card" style={{ textAlign: 'center' }}>
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}>📝</div>
<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 }}>
{quiz.questions_count} questions
{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>
) : 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>
)
@ -1055,7 +1092,7 @@ const timerStarted = timeLeft !== null
</span>
<span>Q {currentIdx + 1} / {totalCount}</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 style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
@ -1274,6 +1311,7 @@ const timerStarted = timeLeft !== null
</div>
)}
<QuestionReadingLinks questionId={current.id} />
<CommentSection questionId={current.id} />
{current.question_type === 'fill_blank' && (
<div className="explanation" style={{ marginTop: 12, borderLeftColor: '#22c55e' }}>
<strong>Correct Answer:</strong> {current.correct_answer}

View file

@ -223,4 +223,23 @@ describe('quiz player', () => {
expect(await screen.findByText('Submitted results')).toBeInTheDocument()
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()
})
})