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:
parent
6c85f8b4a9
commit
0fa8d0a689
24 changed files with 1222 additions and 20 deletions
31
backend/alembic/versions/f2a1c9d4e801_comments.py
Normal file
31
backend/alembic/versions/f2a1c9d4e801_comments.py
Normal 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")
|
||||
21
backend/alembic/versions/g4b7e2f5a903_share_tokens.py
Normal file
21
backend/alembic/versions/g4b7e2f5a903_share_tokens.py
Normal 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")
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
22
backend/app/models/comment.py
Normal file
22
backend/app/models/comment.py
Normal 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")
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
158
backend/app/routers/comments.py
Normal file
158
backend/app/routers/comments.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
42
backend/app/routers/share.py
Normal file
42
backend/app/routers/share.py
Normal 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 [],
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
159
backend/tests/test_comments_ai.py
Normal file
159
backend/tests/test_comments_ai.py
Normal 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()
|
||||
63
backend/tests/test_share_public.py
Normal file
63
backend/tests/test_share_public.py
Normal 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()
|
||||
|
|
@ -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 />}>
|
||||
|
|
|
|||
95
frontend/src/components/CommentSection.jsx
Normal file
95
frontend/src/components/CommentSection.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
frontend/src/components/CommentSection.test.jsx
Normal file
52
frontend/src/components/CommentSection.test.jsx
Normal 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' }))
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
60
frontend/src/pages/PublicQuizPage.jsx
Normal file
60
frontend/src/pages/PublicQuizPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
frontend/src/pages/PublicQuizPage.test.jsx
Normal file
43
frontend/src/pages/PublicQuizPage.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue