feat: linked topic articles and card associations

Draft/published article library with stable section IDs, breadcrumbs, link remediation, question and card associations, manual card creation and side-by-side/mobile-drawer reading. Migration e8d4f1a27c93. Verified 42 deployed-image backend tests, 72 frontend tests/build and PostgreSQL migration round-trip.
This commit is contained in:
Daniel 2026-09-07 15:16:48 +02:00
parent 1ce3eec7cb
commit 325f7f400f
21 changed files with 1387 additions and 6 deletions

View file

@ -0,0 +1,67 @@
"""Topic articles and card/question associations.
Revision ID: e8d4f1a27c93
Revises: d94a26b8f302
"""
from alembic import op
import sqlalchemy as sa
revision = "e8d4f1a27c93"
down_revision = "d94a26b8f302"
branch_labels = None
depends_on = None
def upgrade():
op.execute("""
CREATE TABLE IF NOT EXISTS articles (
id SERIAL PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
title VARCHAR(300) NOT NULL,
summary TEXT,
content TEXT,
sections JSON NOT NULL DEFAULT '[]',
category_id INTEGER REFERENCES question_categories(id) ON DELETE SET NULL,
section_id INTEGER REFERENCES sections(id) ON DELETE SET NULL,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)""")
op.execute("CREATE INDEX IF NOT EXISTS ix_articles_id ON articles (id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_articles_slug ON articles (slug)")
op.execute("""
CREATE TABLE IF NOT EXISTS question_article_links (
id SERIAL PRIMARY KEY,
question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
section_id VARCHAR(64),
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_question_article_section UNIQUE (question_id, article_id, section_id)
)""")
op.execute("CREATE INDEX IF NOT EXISTS ix_question_article_links_id ON question_article_links (id)")
op.execute("""
CREATE TABLE IF NOT EXISTS flashcard_question_links (
id SERIAL PRIMARY KEY,
flashcard_id INTEGER NOT NULL REFERENCES flashcards(id) ON DELETE CASCADE,
question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
CONSTRAINT uq_card_question UNIQUE (flashcard_id, question_id)
)""")
op.execute("CREATE INDEX IF NOT EXISTS ix_flashcard_question_links_id ON flashcard_question_links (id)")
op.execute("""
CREATE TABLE IF NOT EXISTS flashcard_article_links (
id SERIAL PRIMARY KEY,
flashcard_id INTEGER NOT NULL REFERENCES flashcards(id) ON DELETE CASCADE,
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
article_section_id VARCHAR(64),
CONSTRAINT uq_card_article_section UNIQUE (flashcard_id, article_id, article_section_id)
)""")
op.execute("CREATE INDEX IF NOT EXISTS ix_flashcard_article_links_id ON flashcard_article_links (id)")
def downgrade():
op.execute("DROP TABLE IF EXISTS flashcard_article_links")
op.execute("DROP TABLE IF EXISTS flashcard_question_links")
op.execute("DROP TABLE IF EXISTS question_article_links")
op.execute("DROP TABLE IF EXISTS articles")

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
from app.routers import study_tools, uploads, articles
from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler
@ -616,6 +616,7 @@ app.add_middleware(RequestLoggingMiddleware)
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(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

@ -9,6 +9,8 @@ from app.models.ai_model_config import AIModelConfig
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.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating, FlashcardQuestionLink, FlashcardArticleLink
__all__ = [
"User",
@ -23,4 +25,11 @@ __all__ = [
"Favorite",
"UserNote",
"LabReference",
"Article",
"QuestionArticleLink",
"FlashcardDeck",
"Flashcard",
"FlashcardDeckRating",
"FlashcardQuestionLink",
"FlashcardArticleLink",
]

View file

@ -0,0 +1,45 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, JSON, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship
from app.database import Base
class Article(Base):
"""Educator-authored topic reading with stable section IDs for linking."""
__tablename__ = "articles"
id = Column(Integer, primary_key=True, index=True)
slug = Column(String(120), unique=True, nullable=False, index=True)
title = Column(String(300), nullable=False)
summary = Column(Text, nullable=True)
content = Column(Text, nullable=True) # Whole-article introduction (markdown).
# Stable subsections: [{"id": uuid-hex, "slug": "...", "title": "...", "content": "markdown"}].
sections = Column(JSON, nullable=False, default=list)
category_id = Column(Integer, ForeignKey("question_categories.id", ondelete="SET NULL"), nullable=True)
section_id = Column(Integer, ForeignKey("sections.id", ondelete="SET NULL"), nullable=True) # Optional PDF source range.
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
status = Column(String, default="draft") # draft | published
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
category = relationship("QuestionCategory")
links = relationship("QuestionArticleLink", back_populates="article", cascade="all, delete-orphan")
class QuestionArticleLink(Base):
"""Question -> whole article or one stable section."""
__tablename__ = "question_article_links"
__table_args__ = (UniqueConstraint("question_id", "article_id", "section_id", name="uq_question_article_section"),)
id = Column(Integer, primary_key=True, index=True)
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False)
article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False)
section_id = Column(String(64), nullable=True) # Article.sections[].id; None = whole article.
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
article = relationship("Article", back_populates="links")

View file

@ -38,3 +38,22 @@ class Flashcard(Base):
image_path = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
deck = relationship("FlashcardDeck", back_populates="cards")
class FlashcardQuestionLink(Base):
__tablename__ = "flashcard_question_links"
__table_args__ = (UniqueConstraint("flashcard_id", "question_id", name="uq_card_question"),)
id = Column(Integer, primary_key=True, index=True)
flashcard_id = Column(Integer, ForeignKey("flashcards.id", ondelete="CASCADE"), nullable=False)
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False)
class FlashcardArticleLink(Base):
__tablename__ = "flashcard_article_links"
__table_args__ = (UniqueConstraint("flashcard_id", "article_id", "article_section_id", name="uq_card_article_section"),)
id = Column(Integer, primary_key=True, index=True)
flashcard_id = Column(Integer, ForeignKey("flashcards.id", ondelete="CASCADE"), nullable=False)
article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False)
article_section_id = Column(String(64), nullable=True)

View file

@ -0,0 +1,342 @@
"""Topic/article library with stable subsection links and card/question associations."""
import re
import uuid
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, QuestionArticleLink
from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardArticleLink
from app.models.question import Question
from app.models.user import User
from app.services.quiz_builder import bank_question_predicate
from app.services.quiz_builder import category_breadcrumbs
from app.models.question_category import QuestionCategory
from app.utils.auth import get_current_user, require_moderator
router = APIRouter()
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
class ArticleSection(BaseModel):
id: str
slug: str
title: str
content: str = ""
class ArticleWrite(BaseModel):
title: str
slug: str
summary: str | None = None
content: str | None = None
sections: list[ArticleSection] = []
category_id: int | None = None
section_id: int | None = None
@field_validator("slug")
@classmethod
def slug_shape(cls, value):
value = value.strip()
if not SLUG_RE.match(value) or len(value) > 120:
raise ValueError("Slug must be lowercase letters, digits and single hyphens (max 120)")
return value
@field_validator("title")
@classmethod
def title_shape(cls, value):
value = value.strip()
if not value or len(value) > 300:
raise ValueError("Title is required (max 300 characters)")
return value
class ArticlePublish(BaseModel):
published: bool
class ArticleLinkIn(BaseModel):
question_id: int
section_id: str | None = None
def _validate_sections(sections: list[ArticleSection]):
ids, slugs = set(), set()
for section in sections:
if not re.fullmatch(r"[0-9a-f]{32}", section.id):
raise HTTPException(400, "Section IDs must be stable 32-character hex identifiers")
if not SLUG_RE.match(section.slug.strip()) or len(section.slug) > 120:
raise HTTPException(400, "Invalid section slug")
if not section.title.strip() or len(section.title) > 300:
raise HTTPException(400, "Section titles are required (max 300 characters)")
if section.id in ids or section.slug.strip() in slugs:
raise HTTPException(400, "Section IDs and slugs must be unique within an article")
ids.add(section.id)
slugs.add(section.slug.strip())
def _section_ids(article):
return {section["id"] for section in (article.sections or [])}
def _article_json(article: Article) -> dict:
return {
"id": article.id,
"slug": article.slug,
"title": article.title,
"summary": article.summary,
"content": article.content,
"sections": article.sections,
"category_id": article.category_id,
"section_id": article.section_id,
"user_id": article.user_id,
"status": article.status,
"created_at": article.created_at,
"updated_at": article.updated_at,
}
@router.get("/")
def list_articles(
category_id: int | None = Query(None),
q: str | None = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Published articles for everyone; educators additionally see their own drafts."""
query = db.query(Article)
if category_id:
query = query.filter(Article.category_id == category_id)
if q and q.strip():
query = query.filter(Article.title.ilike(f"%{q.strip()}%"))
articles = query.order_by(Article.updated_at.desc()).all()
if not current_user.is_moderator:
articles = [a for a in articles if a.status == "published"]
return [_article_json(a) for a in articles]
@router.post("/")
def create_article(
data: ArticleWrite,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
_validate_sections(data.sections)
if data.category_id and not db.get(QuestionCategory, data.category_id):
raise HTTPException(400, "Category not found")
if db.query(Article.id).filter(Article.slug == data.slug).first():
raise HTTPException(400, "Slug is already in use")
article = Article(
slug=data.slug, title=data.title, summary=data.summary, content=data.content,
sections=[section.model_dump() for section in data.sections],
category_id=data.category_id, section_id=data.section_id,
user_id=current_user.id, status="draft",
)
db.add(article)
db.commit()
db.refresh(article)
return _article_json(article)
@router.get("/linked")
def articles_for_question(
question_id: int = Query(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Articles linked to a bank-visible question (published, or drafts for educators)."""
if not db.query(Question.id).filter(Question.id == question_id, bank_question_predicate(current_user)).first():
raise HTTPException(404, "Question not found")
rows = db.query(QuestionArticleLink, Article).join(Article, Article.id == QuestionArticleLink.article_id).filter(
QuestionArticleLink.question_id == question_id,
).all()
result = []
for link, article in rows:
if article.status != "published" and not current_user.is_moderator:
continue
result.append({**_article_json(article), "section_id": link.section_id})
return result
@router.get("/{article_id}")
def get_article(
article_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
article = db.get(Article, article_id)
if not article:
raise HTTPException(404, "Article not found")
if article.status != "published" and not current_user.is_moderator and article.user_id != current_user.id:
raise HTTPException(404, "Article not found")
data = _article_json(article)
categories = db.query(QuestionCategory).all()
data["category_breadcrumbs"] = category_breadcrumbs(categories, article.category_id) if article.category_id else []
return data
@router.get("/{article_id}/questions")
def article_questions(
article_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
article = db.get(Article, article_id)
if not article:
raise HTTPException(404, "Article not found")
if article.status != "published" and not current_user.is_moderator:
raise HTTPException(404, "Article not found")
rows = db.query(QuestionArticleLink, Question).join(Question, Question.id == QuestionArticleLink.question_id).filter(
QuestionArticleLink.article_id == article_id,
).all()
questions = []
for link, question in rows:
if not current_user.is_moderator and not db.query(Question.id).filter(
Question.id == question.id, bank_question_predicate(current_user)).first():
continue
questions.append({
"question_id": question.id, "question_text": question.question_text,
"correct_answer": question.correct_answer, "explanation": question.explanation,
"section_id": link.section_id,
})
return questions
@router.get("/{article_id}/cards")
def article_cards(
article_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
article = db.get(Article, article_id)
if not article:
raise HTTPException(404, "Article not found")
if article.status != "published" and not current_user.is_moderator:
raise HTTPException(404, "Article not found")
rows = db.query(FlashcardArticleLink, Flashcard, FlashcardDeck).join(
Flashcard, Flashcard.id == FlashcardArticleLink.flashcard_id,
).join(FlashcardDeck, FlashcardDeck.id == Flashcard.deck_id).filter(
FlashcardArticleLink.article_id == article_id, FlashcardDeck.deleted_at.is_(None),
).all()
cards = []
for link, card, deck in rows:
if not current_user.is_moderator and deck.user_id != current_user.id and not deck.is_shared:
continue
cards.append({"card_id": card.id, "deck_id": deck.id, "deck_title": deck.title,
"front": card.front, "back": card.back, "article_section_id": link.article_section_id})
return cards
@router.patch("/{article_id}")
def update_article(
article_id: int,
data: ArticleWrite,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
article = db.get(Article, article_id)
if not article:
raise HTTPException(404, "Article not found")
if not (current_user.is_moderator or article.user_id == current_user.id):
raise HTTPException(403, "Not your article")
_validate_sections(data.sections)
if data.category_id and not db.get(QuestionCategory, data.category_id):
raise HTTPException(400, "Category not found")
if db.query(Article.id).filter(Article.slug == data.slug, Article.id != article.id).first():
raise HTTPException(400, "Slug is already in use")
article.slug, article.title, article.summary, article.content = data.slug, data.title, data.summary, data.content
article.sections = [section.model_dump() for section in data.sections]
article.category_id, article.section_id = data.category_id, data.section_id
# Remediate links whose section was removed; whole-article links survive renames.
kept = _section_ids(article)
db.query(QuestionArticleLink).filter(
QuestionArticleLink.article_id == article.id,
QuestionArticleLink.section_id.isnot(None),
QuestionArticleLink.section_id.notin_(kept) if kept else True,
).delete(synchronize_session=False)
db.query(FlashcardArticleLink).filter(
FlashcardArticleLink.article_id == article.id,
FlashcardArticleLink.article_section_id.isnot(None),
FlashcardArticleLink.article_section_id.notin_(kept) if kept else True,
).delete(synchronize_session=False)
db.commit()
db.refresh(article)
return _article_json(article)
@router.delete("/{article_id}", status_code=204)
def delete_article(
article_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
article = db.get(Article, article_id)
if not article:
raise HTTPException(404, "Article not found")
if not (current_user.is_moderator or article.user_id == current_user.id):
raise HTTPException(403, "Not your article")
db.delete(article)
db.commit()
@router.post("/{article_id}/publish")
def publish_article(
article_id: int,
data: ArticlePublish,
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")
article.status = "published" if data.published else "draft"
db.commit()
return _article_json(article)
@router.put("/{article_id}/links")
def link_question(
article_id: int,
data: ArticleLinkIn,
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")
if data.section_id is not None and data.section_id not in _section_ids(article):
raise HTTPException(400, "Section not found in this article")
if not db.query(Question.id).filter(Question.id == data.question_id, bank_question_predicate(current_user)).first():
raise HTTPException(404, "Question not found")
existing = db.query(QuestionArticleLink).filter_by(
question_id=data.question_id, article_id=article_id, section_id=data.section_id,
).first()
if existing:
return {"linked": False}
db.add(QuestionArticleLink(question_id=data.question_id, article_id=article_id,
section_id=data.section_id, user_id=current_user.id))
db.commit()
return {"linked": True}
@router.delete("/{article_id}/links/{question_id}", status_code=204)
def unlink_question(
article_id: int,
question_id: int,
section_id: str | None = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
db.query(QuestionArticleLink).filter_by(
question_id=question_id, article_id=article_id, section_id=section_id,
).delete(synchronize_session=False)
db.commit()
@router.post("/{article_id}/section-id")
def new_section_id():
"""Clients may mint stable section IDs offline; this helper exists for parity."""
return {"id": uuid.uuid4().hex}

View file

@ -7,9 +7,15 @@ from sqlalchemy import or_, text as sa_text
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating
from app.models.article import Article
from app.models.flashcard import (
FlashcardDeck, Flashcard, FlashcardDeckRating,
FlashcardQuestionLink, FlashcardArticleLink,
)
from app.models.question import Question
from app.models.section import Section
from app.models.user import User
from app.services.quiz_builder import bank_question_predicate
from app.utils.auth import get_current_user, require_moderator
router = APIRouter()
@ -510,3 +516,179 @@ def delete_flashcard(
if deck and deck.card_count > 0:
deck.card_count -= 1
db.commit()
# ── Manual cards and question/article associations ────────────────────
class CardCreate(BaseModel):
front: str
back: str
image_path: str | None = None
class CardQuestionLinkIn(BaseModel):
question_id: int
class CardArticleLinkIn(BaseModel):
article_id: int
article_section_id: str | None = None
@router.post("/decks/{deck_id}/cards")
def create_flashcard_manually(
deck_id: int,
data: CardCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Add a single card to an existing deck. Owner or admin."""
deck = _own_deck_or_404(deck_id, current_user, db)
if not data.front.strip() or not data.back.strip():
raise HTTPException(status_code=400, detail="Both sides are required")
card = Flashcard(deck_id=deck.id, front=data.front.strip(), back=data.back.strip(),
image_path=(data.image_path or None) or None)
db.add(card)
deck.card_count = (deck.card_count or 0) + 1
db.commit()
db.refresh(card)
return {"id": card.id, "deck_id": deck.id, "front": card.front, "back": card.back, "image_path": card.image_path}
def _own_card_or_404(card_id: int, current_user: User, db: Session) -> Flashcard:
card = db.get(Flashcard, card_id)
if not card:
raise HTTPException(status_code=404, detail="Card not found")
deck = db.get(FlashcardDeck, card.deck_id)
if deck and deck.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Not your card")
return card
@router.get("/cards/{card_id}/links")
def list_card_links(
card_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Questions and articles associated with a card (access follows the deck)."""
card = db.get(Flashcard, card_id)
if not card:
raise HTTPException(status_code=404, detail="Card not found")
deck = db.get(FlashcardDeck, card.deck_id)
if deck and deck.user_id != current_user.id and not deck.is_shared and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Not your card")
questions = db.query(Question.id, Question.question_text).join(
FlashcardQuestionLink, FlashcardQuestionLink.question_id == Question.id,
).filter(FlashcardQuestionLink.flashcard_id == card_id).all()
articles = db.query(Article.id, Article.title, Article.status, FlashcardArticleLink.article_section_id).join(
FlashcardArticleLink, FlashcardArticleLink.article_id == Article.id,
).filter(FlashcardArticleLink.flashcard_id == card_id).all()
return {
"questions": [{"id": qid, "question_text": text} for qid, text in questions],
"articles": [{"id": aid, "title": title, "status": status, "article_section_id": sid}
for aid, title, status, sid in articles],
}
@router.put("/cards/{card_id}/links/question")
def link_card_question(
card_id: int,
data: CardQuestionLinkIn,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_own_card_or_404(card_id, current_user, db)
if not db.query(Question.id).filter(Question.id == data.question_id, bank_question_predicate(current_user)).first():
raise HTTPException(status_code=404, detail="Question not found")
if db.query(FlashcardQuestionLink.id).filter_by(flashcard_id=card_id, question_id=data.question_id).first():
return {"linked": False}
db.add(FlashcardQuestionLink(flashcard_id=card_id, question_id=data.question_id))
db.commit()
return {"linked": True}
@router.delete("/cards/{card_id}/links/question/{question_id}", status_code=204)
def unlink_card_question(
card_id: int,
question_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_own_card_or_404(card_id, current_user, db)
db.query(FlashcardQuestionLink).filter_by(flashcard_id=card_id, question_id=question_id).delete(synchronize_session=False)
db.commit()
@router.put("/cards/{card_id}/links/article")
def link_card_article(
card_id: int,
data: CardArticleLinkIn,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_own_card_or_404(card_id, current_user, db)
article = db.get(Article, data.article_id)
if not article:
raise HTTPException(status_code=404, detail="Article not found")
if data.article_section_id is not None and data.article_section_id not in {
section["id"] for section in (article.sections or [])}:
raise HTTPException(status_code=400, detail="Section not found in this article")
if db.query(FlashcardArticleLink.id).filter_by(
flashcard_id=card_id, article_id=data.article_id, article_section_id=data.article_section_id).first():
return {"linked": False}
db.add(FlashcardArticleLink(flashcard_id=card_id, article_id=data.article_id,
article_section_id=data.article_section_id))
db.commit()
return {"linked": True}
@router.delete("/cards/{card_id}/links/article/{article_id}", status_code=204)
def unlink_card_article(
card_id: int,
article_id: int,
article_section_id: str | None = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_own_card_or_404(card_id, current_user, db)
db.query(FlashcardArticleLink).filter_by(
flashcard_id=card_id, article_id=article_id, article_section_id=article_section_id,
).delete(synchronize_session=False)
db.commit()
@router.get("/cards/linked")
def cards_for_target(
question_id: int | None = Query(None),
article_id: int | None = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Cards linked to a question or article; results follow deck sharing/ownership."""
if (question_id is None) == (article_id is None):
raise HTTPException(400, "Provide exactly one of question_id or article_id")
if question_id is not None and not db.query(Question.id).filter(
Question.id == question_id, bank_question_predicate(current_user)).first():
raise HTTPException(404, "Question not found")
query = db.query(Flashcard, FlashcardDeck).join(
FlashcardDeck, FlashcardDeck.id == Flashcard.deck_id,
).filter(FlashcardDeck.deleted_at.is_(None))
if question_id is not None:
query = query.join(FlashcardQuestionLink, FlashcardQuestionLink.flashcard_id == Flashcard.id).filter(
FlashcardQuestionLink.question_id == question_id)
else:
article = db.get(Article, article_id)
if not article:
raise HTTPException(404, "Article not found")
if article.status != "published" and not current_user.is_moderator:
raise HTTPException(404, "Article not found")
query = query.join(FlashcardArticleLink, FlashcardArticleLink.flashcard_id == Flashcard.id).filter(
FlashcardArticleLink.article_id == article_id)
cards = []
for card, deck in query.all():
if not current_user.is_moderator and deck.user_id != current_user.id and not deck.is_shared:
continue
cards.append({"id": card.id, "deck_id": deck.id, "deck_title": deck.title,
"front": card.front, "back": card.back, "image_path": card.image_path})
return cards

View file

@ -0,0 +1,160 @@
"""Article library and card association routes on disposable SQLite; no network/AI."""
import unittest
import test_quiz_builder as fixtures
from app.models.article import Article, QuestionArticleLink
from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardQuestionLink, FlashcardArticleLink
from app.models.question import Question
from app.routers import articles, flashcards
class ArticlesCardsTests(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(flashcards.router, prefix='/flashcards')
def tearDown(self):
self.bank.tearDown()
def create(self, **overrides):
payload = {"title": "Topic article", "slug": "topic-article",
"summary": "Summary", "content": "Intro",
"sections": [{"id": "a" * 32, "slug": "first-section", "title": "First section", "content": "Body"}],
**overrides}
return self.client.post('/articles/', json=payload)
def test_article_status_visibility_and_editing(self):
self.bank.user = self.bank.mod
article = self.create().json()
self.assertIn('a' * 32, [s['id'] for s in article['sections']])
self.bank.user = self.bank.owner
self.assertEqual(self.client.get('/articles/').json(), [])
self.assertEqual(self.client.get(f"/articles/{article['id']}").status_code, 404)
self.bank.user = self.bank.mod
self.client.post(f"/articles/{article['id']}/publish", json={'published': True})
self.bank.user = self.bank.owner
listing = self.client.get('/articles/').json()
self.assertEqual([a['id'] for a in listing], [article['id']])
detail = self.client.get(f"/articles/{article['id']}").json()
self.assertEqual(detail['category_breadcrumbs'], [])
self.bank.user = self.bank.peer
self.assertEqual(self.client.patch(f"/articles/{article['id']}", json={
"title": "Topic article", "slug": "topic-article", "content": "Intro", "sections": []}).status_code, 403)
self.assertEqual(self.client.delete(f"/articles/{article['id']}").status_code, 403)
self.bank.user = self.bank.mod
for payload in [{"title": " ", "slug": "x"}, {"title": "T", "slug": "Bad Slug"},
{"title": "T", "slug": "ok", "sections": [{"id": "short", "slug": "s", "title": "S", "content": ""}]},
{"title": "T", "slug": "ok", "sections": [{"id": "a" * 32, "slug": "dup", "title": "A"},
{"id": "b" * 32, "slug": "dup", "title": "B"}]}]:
self.assertIn(self.create(**payload).status_code, (400, 422), payload)
self.assertEqual(self.create(slug='topic-article').status_code, 400)
self.assertEqual(self.client.post(f"/articles/{article['id']}/publish", json={'published': False}).status_code, 200)
self.bank.user = self.bank.owner
self.assertEqual(self.client.get('/articles/').json(), [])
self.assertEqual(self.client.get(f"/articles/{article['id']}").status_code, 404)
def test_section_stability_remediation_and_question_links(self):
self.bank.user = self.bank.mod
article = self.create().json()
section_id = article['sections'][0]['id']
link = self.client.put(f"/articles/{article['id']}/links", json={'question_id': 1, 'section_id': section_id})
self.assertEqual(link.status_code, 200, link.text)
self.assertEqual(link.json()['linked'], True)
self.assertEqual(self.client.put(f"/articles/{article['id']}/links", json={'question_id': 1, 'section_id': section_id}).json()['linked'], False)
self.assertEqual(self.client.put(f"/articles/{article['id']}/links", json={'question_id': 1, 'section_id': 'f' * 32}).status_code, 400)
self.assertEqual(self.client.put(f"/articles/{article['id']}/links", json={'question_id': 4, 'section_id': section_id}).status_code, 404)
self.bank.user = self.bank.owner
self.assertEqual(self.client.put(f"/articles/{article['id']}/links", json={'question_id': 1}).status_code, 403)
self.bank.user = self.bank.mod
self.client.post(f"/articles/{article['id']}/publish", json={'published': True})
self.bank.user = self.bank.owner
self.assertEqual(self.client.get('/articles/linked', params={'question_id': 1}).json()[0]['section_id'], section_id)
# Whole-article link survives section renames/removal of OTHER sections.
self.bank.user = self.bank.mod
self.client.put(f"/articles/{article['id']}/links", json={'question_id': 2})
patch = self.client.patch(f"/articles/{article['id']}", json={
"title": "Renamed", "slug": "renamed", "content": "Intro",
"sections": [{"id": section_id, "slug": "renamed-section", "title": "Renamed section", "content": "New body"}]})
self.assertEqual(patch.status_code, 200, patch.text)
self.assertEqual(self.bank.db.query(QuestionArticleLink).filter_by(article_id=article['id']).count(), 2)
# Removing the section remediates only that section link.
self.client.patch(f"/articles/{article['id']}", json={
"title": "Renamed", "slug": "renamed", "content": "Intro",
"sections": [{"id": "c" * 32, "slug": "other", "title": "Other", "content": ""}]})
links = self.bank.db.query(QuestionArticleLink).filter_by(article_id=article['id']).all()
self.assertEqual({(l.question_id, l.section_id) for l in links}, {(2, None)})
self.bank.user = self.bank.peer
linked = self.client.get('/articles/linked', params={'question_id': 2}).json()
self.assertEqual([l['section_id'] for l in linked], [None])
published_questions = self.client.get(f"/articles/{article['id']}/questions")
self.assertEqual(published_questions.status_code, 200)
self.assertEqual([q['question_id'] for q in published_questions.json()], [2])
def test_article_questions_visibility_and_publish(self):
self.bank.user = self.bank.mod
article = self.create().json()
self.client.put(f"/articles/{article['id']}/links", json={'question_id': 1})
self.client.post(f"/articles/{article['id']}/publish", json={'published': True})
self.bank.user = self.bank.owner
questions = self.client.get(f"/articles/{article['id']}/questions").json()
self.assertEqual([q['question_id'] for q in questions], [1])
self.bank.db.get(Question, 1).is_shared = 0
self.bank.db.commit()
self.bank.user = self.bank.peer
self.assertEqual(self.client.get(f"/articles/{article['id']}/questions").json(), [])
self.bank.user = self.bank.mod
self.assertEqual(len(self.client.get(f"/articles/{article['id']}/questions").json()), 1)
def test_manual_cards_links_and_target_listing(self):
deck = FlashcardDeck(user_id=3, title='Educator deck', is_shared=0)
private = FlashcardDeck(user_id=2, title='Private deck', is_shared=0)
self.bank.db.add_all([deck, private])
self.bank.db.flush()
self.bank.db.commit()
self.bank.user = self.bank.mod
response = self.client.post(f'/flashcards/decks/{deck.id}/cards', json={'front': 'Front', 'back': 'Back'})
self.assertEqual(response.status_code, 200, response.text)
card = response.json()
self.assertEqual(self.bank.db.get(FlashcardDeck, deck.id).card_count, 1)
self.assertEqual(self.client.post(f'/flashcards/decks/{deck.id}/cards', json={'front': '', 'back': ''}).status_code, 400)
article = self.create().json()
self.bank.user = self.bank.owner
self.assertEqual(self.client.post(f'/flashcards/decks/{deck.id}/cards', json={'front': 'F', 'back': 'B'}).status_code, 403)
self.bank.user = self.bank.mod
self.assertEqual(self.client.put(f"/flashcards/cards/{card['id']}/links/question", json={'question_id': 1}).status_code, 200)
self.assertEqual(self.client.put(f"/flashcards/cards/{card['id']}/links/question", json={'question_id': 4}).status_code, 404)
self.assertEqual(self.client.put(f"/flashcards/cards/{card['id']}/links/article",
json={'article_id': article['id'], 'article_section_id': 'f' * 32}).status_code, 400)
self.assertEqual(self.client.put(f"/flashcards/cards/{card['id']}/links/article",
json={'article_id': article['id']}).status_code, 200)
links = self.client.get(f"/flashcards/cards/{card['id']}/links").json()
self.assertEqual([q['id'] for q in links['questions']], [1])
self.assertEqual([a['id'] for a in links['articles']], [article['id']])
self.client.post(f"/articles/{article['id']}/publish", json={'published': True})
# Before sharing, peers cannot see links or linked cards; mutating links stays owner/admin-only.
self.bank.user = self.bank.peer
self.assertEqual(self.client.get('/flashcards/cards/linked', params={'question_id': 1}).json(), [])
self.assertEqual(self.client.get(f"/flashcards/cards/{card['id']}/links").status_code, 403)
self.assertEqual(self.client.delete(f"/flashcards/cards/{card['id']}/links/question/1").status_code, 403)
# Sharing the deck grants read access to linked content.
self.bank.db.get(FlashcardDeck, deck.id).is_shared = 1
self.bank.db.commit()
self.bank.user = self.bank.owner
self.assertEqual([c['id'] for c in self.client.get('/flashcards/cards/linked', params={'question_id': 1}).json()], [card['id']])
self.assertEqual([c['id'] for c in self.client.get('/flashcards/cards/linked', params={'article_id': article['id']}).json()], [card['id']])
self.bank.user = self.bank.mod
self.assertEqual(self.client.delete(f"/flashcards/cards/{card['id']}/links/question/1").status_code, 204)
self.assertEqual(self.bank.db.query(FlashcardQuestionLink).count(), 0)
self.client.delete(f"/flashcards/cards/{card['id']}/links/article/{article['id']}")
self.assertEqual(self.bank.db.query(FlashcardArticleLink).count(), 0)
self.assertEqual(self.client.get('/flashcards/cards/linked', params={'question_id': 1, 'article_id': 1}).status_code, 400)
# Deleting an article cascades its links.
self.client.delete(f"/articles/{article['id']}")
self.assertEqual(self.bank.db.query(QuestionArticleLink).count(), 0)
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,44 @@
"""Offline PostgreSQL DDL checks for the articles/cards migration; no database access."""
import io
import os
import unittest
from pathlib import Path
from unittest.mock import patch
os.environ['DATABASE_URL'] = 'sqlite:///:memory:'
from alembic import command
from alembic.config import Config
from alembic.script import ScriptDirectory
class ArticlesMigrationTests(unittest.TestCase):
def test_models_register_fresh_and_offline_upgrade_downgrade(self):
import subprocess
import sys
result = subprocess.run([sys.executable, '-c',
"import app.models; from app.database import Base; "
"assert {'articles','question_article_links','flashcard_question_links','flashcard_article_links'} <= set(Base.metadata.tables)"],
capture_output=True, text=True, env={**os.environ, 'DATABASE_URL': 'sqlite:///:memory:'})
self.assertEqual(result.returncode, 0, result.stderr)
output = io.StringIO()
config = Config(output_buffer=output)
config.set_main_option('script_location', str(Path(__file__).resolve().parents[1] / 'alembic'))
scripts = ScriptDirectory.from_config(config)
self.assertEqual(len(scripts.get_heads()), 1)
self.assertEqual(scripts.get_revision('e8d4f1a27c93').down_revision, 'd94a26b8f302')
with patch.dict(os.environ, {'DATABASE_URL': 'postgresql://unused@127.0.0.1/offline_only'}):
command.upgrade(config, 'd94a26b8f302:e8d4f1a27c93', sql=True)
sql = output.getvalue()
for table in ('articles', 'question_article_links', 'flashcard_question_links', 'flashcard_article_links'):
self.assertIn(f'CREATE TABLE IF NOT EXISTS {table}', sql)
self.assertIn('UNIQUE (question_id, article_id, section_id)', sql)
self.assertIn('ON DELETE CASCADE', sql)
output.seek(0)
output.truncate()
command.downgrade(config, 'e8d4f1a27c93:d94a26b8f302', sql=True)
for table in ('flashcard_article_links', 'flashcard_question_links', 'question_article_links', 'articles'):
self.assertIn(f'DROP TABLE IF EXISTS {table}', output.getvalue())
if __name__ == '__main__':
unittest.main()

View file

@ -67,6 +67,14 @@ Verification: 37 backend tests in the exact deployed image (new card-moderator,
Known boundaries: only course_files/course_thumbnails/scorm/certificates retain legacy LMS policy; already-cached public responses cannot be recalled by new no-store headers; downloaded offline content likewise. Privacy work is not committed or deployed yet.
## Milestone 3 — linked articles and cards (under verification)
Added an educator-authored topic library with draft/published states, stable section IDs (heading renames never break links; removed sections remediate their links), category breadcrumbs and whole-article/section question links. Articles reuse the Milkdown rich editor; reading is side-by-side on desktop with a drawer on mobile. External references stay links. Empty content is honestly labeled.
Reused the existing flashcard system and added manual card creation plus card↔question and card↔article associations. Linked content follows bank visibility and deck ownership/sharing; mutation stays owner/admin-only. New migration `e8d4f1a27c93` (after `d94a26b8f302`) must run before deployment.
Verification: 42 backend tests in the deployed image, 72 frontend tests across 12 suites and production build, plus a real isolated PostgreSQL upgrade/repeat/downgrade/re-upgrade with defaults and FK enforcement. Independent review and browser checks pending.
## Next
Continue with the Orthobullets-inspired runner/results UI, question navigation and study tools; then article/subsection reading, linked flashcards, educator AI authoring and moderated comments. Complete related-content privacy work and end-to-end desktop/mobile validation before deployment.

View file

@ -26,6 +26,8 @@ const ResetPasswordPage = lazy(() => import('./pages/ResetPasswordPage'))
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'))
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 FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage'))
const CoursesPage = lazy(() => import('./pages/CoursesPage'))
const CourseDetailPage = lazy(() => import('./pages/CourseDetailPage'))
@ -89,6 +91,8 @@ function AppRoutes() {
<Route path="/settings" element={<SettingsPage />} />
<Route path="/question-bank" element={<QuestionBankPage />} />
<Route path="/flashcards" element={<FlashcardsPage />} />
<Route path="/articles" element={<ArticlesPage />} />
<Route path="/articles/:id" element={<ArticlePage />} />
<Route path="/flashcards/:deckId/study" element={<FlashcardStudyPage />} />
<Route path="/courses" element={<CoursesPage />} />
<Route path="/courses/:courseId" element={<CourseDetailPage />} />

View file

@ -92,6 +92,7 @@ export default function Navbar({ onSignIn, onRegister }) {
{ to: '/', label: 'Dashboard' },
{ to: '/quizzes', label: 'Quizzes' },
{ to: '/question-bank', label: 'Question Bank' },
{ to: '/articles', label: 'Reading' },
{ to: '/flashcards', label: 'Flashcards' },
{ to: '/courses', label: 'Courses' },
{ to: '/settings', label: '⚙ Settings' },

View file

@ -0,0 +1,29 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../api/client'
export default function QuestionReadingLinks({ questionId }) {
const [links, setLinks] = useState(null)
useEffect(() => {
if (!questionId) { setLinks([]); return }
api.get('/articles/linked', { params: { question_id: questionId } })
.then(res => setLinks(res.data)).catch(() => setLinks([]))
}, [questionId])
if (!links || links.length === 0) return null
return (
<div className="question-reading" data-testid="question-reading">
<strong>Topic reading</strong>
<ul>
{links.map(link => (
<li key={`${link.id}-${link.section_id || 'all'}`}>
<Link to={link.section_id ? `/articles/${link.id}?section=${link.section_id}` : `/articles/${link.id}`}>
{link.title}
{link.section_id && link.sections?.find(s => s.id === link.section_id)?.title
? `${link.sections.find(s => s.id === link.section_id).title}` : ''}
</Link>
</li>
))}
</ul>
</div>
)
}

View file

@ -0,0 +1,48 @@
.articles-page, .article-page { max-width: 1080px; margin: 0 auto; padding: 4px 0 24px; }
.articles-header { display: flex; justify-content: space-between; align-items: flex-end; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
.articles-header h1, .article-header h1 { margin: 0 0 4px; font-size: 1.5rem; }
.articles-subtitle { margin: 0; color: var(--text-muted); font-size: 0.88rem; }
.articles-toolbar { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
.articles-toolbar .input { max-width: 320px; }
.articles-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
.article-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--card-radius); padding: 16px; text-decoration: none; color: var(--text); transition: box-shadow .15s ease, transform .15s ease; }
.article-card:hover { box-shadow: 0 8px 24px rgba(2, 70, 145, .12); transform: translateY(-2px); }
.article-card h3 { margin: 0 0 6px; font-size: 1.02rem; }
.article-card p { margin: 0 0 8px; color: var(--text-muted); font-size: .85rem; }
.article-card-meta { color: var(--text-muted); font-size: .75rem; }
.article-status-draft { font-size: .65rem; font-weight: 700; text-transform: uppercase; color: #92400e; background: #fef3c7; padding: 2px 7px; border-radius: 10px; margin-left: 6px; vertical-align: middle; }
.breadcrumbs { font-size: .8rem; color: var(--text-muted); margin-bottom: 8px; }
.breadcrumbs a { color: var(--primary); text-decoration: none; }
.article-header { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.article-header-actions { display: flex; gap: 8px; }
.article-layout { display: grid; grid-template-columns: 240px 1fr; gap: 20px; align-items: start; }
.article-sections { position: sticky; top: 80px; background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--card-radius); padding: 14px; }
.article-sections h4 { margin: 0 0 8px; font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); }
.article-sections ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.section-link { width: 100%; text-align: left; background: none; border: none; border-radius: 6px; padding: 7px 10px; font-size: .85rem; color: var(--text); cursor: pointer; }
.section-link:hover { background: var(--hover, #eef4fb); }
.section-link.active { background: var(--primary-soft, #dcebfa); color: var(--primary); font-weight: 600; }
.article-content { min-width: 0; background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--card-radius); padding: 22px 26px; }
.article-content h2 { margin-top: 0; }
.article-content img { max-width: 100%; border-radius: 8px; }
.article-content table { border-collapse: collapse; width: 100%; margin: 12px 0; }
.article-content td, .article-content th { border: 1px solid var(--border); padding: 6px 10px; }
.article-summary { font-size: .95rem; color: var(--text-muted); border-left: 3px solid var(--primary); padding-left: 10px; margin: 0 0 16px; }
.article-drawer-toggle { display: none; margin-bottom: 10px; }
.article-linked { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 14px; }
.article-linked h3 { margin: 0 0 10px; font-size: 1rem; }
.linked-question { border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; margin-bottom: 10px; display: flex; flex-direction: column; gap: 6px; }
.linked-question-text { font-weight: 600; }
.linked-answer { color: var(--correct-fg); font-size: .88rem; }
.linked-explanation { color: var(--text-muted); font-size: .86rem; }
.linked-section-tag { font-size: .72rem; color: var(--text-muted); }
.linked-card { display: flex; justify-content: space-between; align-items: center; gap: 10px; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; flex-wrap: wrap; }
.article-edit { display: flex; flex-direction: column; gap: 8px; }
.article-edit .form-label { margin-top: 6px; }
@media (max-width: 820px) {
.article-layout { grid-template-columns: 1fr; }
.article-sections { position: static; display: none; }
.article-sections.open { display: block; }
.article-drawer-toggle { display: inline-block; }
.article-content { padding: 16px; }
}

View file

@ -0,0 +1,324 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
import RichEditor from '../components/RichEditor'
import { markdownImageUrl } from '../utils/uploads'
import './ArticlesPage.css'
const sectionId = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('')
function Markdown({ children, attemptId }) {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]} components={{
img: ({ node, src, ...props }) => <img {...props} src={markdownImageUrl(src, attemptId)} />,
a: ({ node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
}}>
{children || ''}
</ReactMarkdown>
)
}
function DraftBadge({ status }) {
if (status === 'published') return null
return <span className="article-status-draft">Draft</span>
}
export default function ArticlesPage() {
const { user } = useAuth()
const [articles, setArticles] = useState([])
const [categories, setCategories] = useState([])
const [categoryId, setCategoryId] = useState('')
const [query, setQuery] = useState('')
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [title, setTitle] = useState('')
const [slug, setSlug] = useState('')
const [error, setError] = useState('')
const navigate = useNavigate()
const load = useCallback(() => {
const params = {}
if (categoryId) params.category_id = categoryId
if (query.trim()) params.q = query.trim()
api.get('/articles', { params }).then(res => setArticles(res.data)).finally(() => setLoading(false))
}, [categoryId, query])
useEffect(() => { load() }, [load])
useEffect(() => {
api.get('/question-categories').then(res => setCategories(res.data)).catch(() => {})
}, [])
const create = async () => {
setError('')
if (!title.trim() || !slug.trim()) { setError('Title and slug are required'); return }
try {
const res = await api.post('/articles', { title, slug: slug.trim().toLowerCase(), content: '', sections: [] })
setShowCreate(false)
navigate(`/articles/${res.data.id}?edit=1`)
} catch (err) {
setError(err.response?.data?.detail || 'Could not create article')
}
}
return (
<div className="articles-page">
<div className="articles-header">
<div>
<h1>Topic reading</h1>
<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>
{showCreate && (
<div className="card" style={{ marginBottom: 16 }}>
<h3>New article</h3>
<label className="form-label" htmlFor="article-title">Title</label>
<input id="article-title" className="input" value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. Febrile seizures" />
<label className="form-label" htmlFor="article-slug">Slug (lowercase, hyphens)</label>
<input id="article-slug" className="input" value={slug} onChange={e => setSlug(e.target.value)} placeholder="febrile-seizures" />
{error && <div className="form-error" role="alert">{error}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<button className="btn btn-primary btn-sm" onClick={create}>Create draft</button>
<button className="btn btn-secondary btn-sm" onClick={() => setShowCreate(false)}>Cancel</button>
</div>
</div>
)}
<div className="articles-toolbar">
<input className="input" value={query} onChange={e => setQuery(e.target.value)} placeholder="Search articles" aria-label="Search articles" />
<select className="input" value={categoryId} onChange={e => setCategoryId(e.target.value)} aria-label="Filter by category">
<option value="">All categories</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
{loading ? <div className="loading"><div className="spinner" /></div> : articles.length === 0 ? (
<div className="card empty-state">No articles yet. Educators add and refine articles gradually.</div>
) : (
<div className="articles-grid">
{articles.map(article => (
<Link key={article.id} to={`/articles/${article.id}`} className="article-card">
<h3>{article.title} <DraftBadge status={article.status} /></h3>
{article.summary && <p>{article.summary}</p>}
<span className="article-card-meta">{article.sections?.length || 0} sections</span>
</Link>
))}
</div>
)}
</div>
)
}
export function ArticlePage() {
const { user } = useAuth()
const { id } = useParams()
const [searchParams, setSearchParams] = useSearchParams()
const [article, setArticle] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [activeSection, setActiveSection] = useState('')
const [drawerOpen, setDrawerOpen] = useState(false)
const [questions, setQuestions] = useState([])
const [cards, setCards] = useState([])
const [editing, setEditing] = useState(searchParams.get('edit') === '1')
const [saving, setSaving] = useState(false)
const [form, setForm] = useState(null)
const [linkQuestionId, setLinkQuestionId] = useState('')
const [linkSection, setLinkSection] = useState('')
const [linkError, setLinkError] = useState('')
const navigate = useNavigate()
const load = useCallback(() => {
api.get(`/articles/${id}`).then(res => {
setArticle(res.data)
if (!activeSection && res.data.sections?.length) setActiveSection(res.data.sections[0].id)
setForm({ title: res.data.title, slug: res.data.slug, summary: res.data.summary || '', content: res.data.content || '', sections: res.data.sections || [] })
}).catch(err => setError(err.response?.status === 404 ? 'Article not found' : 'Could not load article')).finally(() => setLoading(false))
api.get(`/articles/${id}/questions`).then(res => setQuestions(res.data)).catch(() => setQuestions([]))
api.get(`/articles/${id}/cards`).then(res => setCards(res.data)).catch(() => setCards([]))
}, [id, activeSection])
useEffect(() => { load() }, [load])
const section = article?.sections?.find(s => s.id === activeSection)
const chosenSection = searchParams.get('section') || ''
const save = async (publish = null) => {
setSaving(true)
setError('')
try {
const payload = { ...form }
const res = await api.patch(`/articles/${id}`, payload)
setArticle(res.data)
if (publish !== null) {
await api.post(`/articles/${id}/publish`, { published: publish })
setArticle(prev => ({ ...prev, status: publish ? 'published' : 'draft' }))
}
setEditing(false)
setSearchParams({})
load()
} catch (err) {
setError(typeof err.response?.data?.detail === 'string' ? err.response.data.detail : 'Could not save')
} finally { setSaving(false) }
}
const linkQuestion = async () => {
setLinkError('')
if (!linkQuestionId.trim()) return
try {
await api.put(`/articles/${id}/links`, { question_id: parseInt(linkQuestionId, 10), section_id: linkSection || null })
setLinkQuestionId('')
load()
} catch (err) { setLinkError(typeof err.response?.data?.detail === 'string' ? err.response.data.detail : 'Could not link question') }
}
const unlinkQuestion = async (questionId, linkSectionId) => {
await api.delete(`/articles/${id}/links/${questionId}`, { params: linkSectionId ? { section_id: linkSectionId } : {} })
load()
}
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
const canEdit = user?.is_moderator || article.user_id === user?.id
return (
<div className="article-page">
<nav className="breadcrumbs" aria-label="Breadcrumb">
<Link to="/articles">Reading</Link>
{(article.category_breadcrumbs || []).map(crumb => <span key={crumb.id}> / {crumb.name}</span>)}
<span> / {article.title}</span>
</nav>
<div className="article-header">
<h1>{article.title} <DraftBadge status={article.status} /></h1>
<div className="article-header-actions">
{canEdit && !editing && (
<>
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(true)}>Edit</button>
<button className="btn btn-secondary btn-sm" onClick={() => save(article.status === 'published' ? false : true)}>
{article.status === 'published' ? 'Unpublish' : 'Publish'}
</button>
</>
)}
{editing && <button className="btn btn-primary btn-sm" onClick={() => save(null)} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>}
</div>
</div>
{error && <div className="form-error" role="alert">{error}</div>}
{editing && form ? (
<div className="article-edit">
<label className="form-label" htmlFor="edit-title">Title</label>
<input id="edit-title" className="input" value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} />
<label className="form-label" htmlFor="edit-slug">Slug</label>
<input id="edit-slug" className="input" value={form.slug} onChange={e => setForm(f => ({ ...f, slug: e.target.value }))} />
<label className="form-label" htmlFor="edit-summary">Summary</label>
<textarea id="edit-summary" className="input" rows={2} value={form.summary} onChange={e => setForm(f => ({ ...f, summary: e.target.value }))} />
<label className="form-label">Introduction</label>
<RichEditor value={form.content} onChange={content => setForm(f => ({ ...f, content }))} height={180} />
<h3 style={{ marginTop: 20 }}>Sections</h3>
{form.sections.map((sec, i) => (
<div key={sec.id} className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<input className="input" style={{ flex: 2 }} value={sec.title} aria-label="Section title"
onChange={e => setForm(f => ({ ...f, sections: f.sections.map((s, j) => j === i ? { ...s, title: e.target.value } : s) }))} />
<input className="input" style={{ flex: 1 }} value={sec.slug} aria-label="Section slug"
onChange={e => setForm(f => ({ ...f, sections: f.sections.map((s, j) => j === i ? { ...s, slug: e.target.value } : s) }))} />
<button className="btn btn-sm btn-danger" onClick={() => setForm(f => ({ ...f, sections: f.sections.filter((_, j) => j !== i) }))}
title="Removing a section also removes links to it">Remove</button>
</div>
<RichEditor value={sec.content} height={220} onChange={content => setForm(f => ({ ...f, sections: f.sections.map((s, j) => j === i ? { ...s, content } : s) }))} />
</div>
))}
<button className="btn btn-secondary btn-sm" onClick={() => setForm(f => ({
...f, sections: [...f.sections, { id: sectionId(), slug: `section-${f.sections.length + 1}`, title: '', content: '' }],
}))}>Add section</button>
<div className="card" style={{ marginTop: 16 }}>
<h4>Link a question</h4>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>Enter a bank question ID, optionally scoped to one section.</p>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<input className="input" style={{ maxWidth: 180 }} value={linkQuestionId} onChange={e => setLinkQuestionId(e.target.value)} placeholder="Question ID" aria-label="Question ID" />
<select className="input" value={linkSection} onChange={e => setLinkSection(e.target.value)} aria-label="Section scope">
<option value="">Whole article</option>
{form.sections.map(s => <option key={s.id} value={s.id}>{s.title || `Section ${s.slug}`}</option>)}
</select>
<button className="btn btn-secondary btn-sm" onClick={linkQuestion}>Link</button>
</div>
{linkError && <div className="form-error" role="alert">{linkError}</div>}
</div>
</div>
) : (
<div className="article-layout">
<button className="article-drawer-toggle" onClick={() => setDrawerOpen(v => !v)} aria-expanded={drawerOpen}>
{drawerOpen ? '✕ Close sections' : '☰ Sections'}
</button>
<aside className={`article-sections ${drawerOpen ? 'open' : ''}`}>
<h4>In this article</h4>
<ul>
<li>
<button className={!chosenSection && !activeSection ? 'section-link active' : 'section-link'}
onClick={() => { setActiveSection(''); setSearchParams({}) }}>
Overview
</button>
</li>
{(article.sections || []).map(sec => (
<li key={sec.id}>
<button className={activeSection === sec.id && !chosenSection ? 'section-link active' : 'section-link'}
onClick={() => { setActiveSection(sec.id); setSearchParams({}) }}>
{sec.title}
</button>
</li>
))}
</ul>
</aside>
<main className="article-content">
{(article.summary || article.content) && !section && (
<>
{article.summary && <p className="article-summary">{article.summary}</p>}
{article.content && <Markdown>{article.content}</Markdown>}
</>
)}
{section && (
<>
<h2>{section.title}</h2>
<Markdown>{section.content}</Markdown>
</>
)}
{!article.sections?.length && (!article.summary && !article.content) && (
<div className="empty-state">Content is being prepared by educators.</div>
)}
{questions.length > 0 && (
<div className="article-linked">
<h3>Related questions</h3>
{questions.map(q => (
<div key={`${q.question_id}-${q.section_id}`} className="linked-question">
<div className="linked-question-text">{q.question_text}</div>
{q.correct_answer && <div className="linked-answer">Answer: <strong>{q.correct_answer}</strong></div>}
{q.explanation && <div className="linked-explanation">{q.explanation}</div>}
{q.section_id && <div className="linked-section-tag">Section: {(article.sections || []).find(s => s.id === q.section_id)?.title || 'removed'}</div>}
{canEdit && <button className="btn btn-sm btn-secondary" onClick={() => unlinkQuestion(q.question_id, q.section_id)}>Unlink</button>}
</div>
))}
</div>
)}
{cards.length > 0 && (
<div className="article-linked">
<h3>Related flashcards</h3>
{cards.map(card => (
<div key={card.card_id} className="linked-card">
<div><strong>{card.front}</strong> <span className="article-card-meta"> {card.back}</span></div>
<Link className="btn btn-sm btn-secondary" to={`/flashcards/${card.deck_id}/study`}>Study deck</Link>
</div>
))}
</div>
)}
</main>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import ArticlesPage, { ArticlePage } from './ArticlesPage'
import QuestionReadingLinks from '../components/QuestionReadingLinks'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: { id: 1, name: 'Learner', role: 'user' } }) }))
vi.mock('../components/RichEditor', () => ({ default: ({ value }) => <div data-testid="rich-editor">{value}</div> }))
vi.mock('../utils/uploads', () => ({ markdownImageUrl: (src) => src }))
const article = {
id: 1, slug: 'febrile-seizures', title: 'Febrile seizures', summary: 'Overview summary',
content: 'Introduction markdown', status: 'published', category_breadcrumbs: [{ id: 2, name: 'Neurology' }],
sections: [{ id: 'a'.repeat(32), slug: 'workup', title: 'Initial workup', content: 'Section markdown' }],
user_id: 3,
}
beforeEach(() => {
vi.resetAllMocks()
api.get.mockImplementation(url => {
if (url === '/question-categories') return Promise.resolve({ data: [] })
if (url === '/articles') return Promise.resolve({ data: [article] })
if (url === '/articles/linked') return Promise.resolve({ data: [article] })
if (url === '/articles/1') return Promise.resolve({ data: article })
if (url === '/articles/1/questions') return Promise.resolve({ data: [{ question_id: 1, question_text: 'Linked question text', correct_answer: 'yes', explanation: 'Why', section_id: null }] })
if (url === '/articles/1/cards') return Promise.resolve({ data: [] })
return Promise.resolve({ data: [] })
})
})
describe('topic reading', () => {
it('lists published articles with an honest empty state', async () => {
render(<MemoryRouter initialEntries={['/articles']}><Routes><Route path="/articles" element={<ArticlesPage />} /></Routes></MemoryRouter>)
expect(await screen.findByText('Febrile seizures')).toBeInTheDocument()
expect(screen.queryByText('No articles yet. Educators add and refine articles gradually.')).not.toBeInTheDocument()
})
it('renders sections, breadcrumbs and linked questions without hiding content', async () => {
render(<MemoryRouter initialEntries={['/articles/1']}><Routes><Route path="/articles/:id" element={<ArticlePage />} /></Routes></MemoryRouter>)
expect(await screen.findByRole('heading', { name: 'Febrile seizures' })).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toHaveTextContent('Neurology')
await userEvent.click(screen.getByRole('button', { name: 'Initial workup' }))
expect(await screen.findByText('Section markdown')).toBeInTheDocument()
expect(screen.getByText('Linked question text')).toBeInTheDocument()
expect(screen.getByText('Why')).toBeInTheDocument()
})
it('shows reading links for a question and nothing when there are none', async () => {
const { unmount } = render(<MemoryRouter><QuestionReadingLinks questionId={1} /></MemoryRouter>)
expect(await screen.findByTestId('question-reading')).toHaveTextContent('Febrile seizures')
unmount()
api.get.mockResolvedValue({ data: [] })
render(<MemoryRouter><QuestionReadingLinks questionId={9} /></MemoryRouter>)
expect(screen.queryByTestId('question-reading')).not.toBeInTheDocument()
})
})

View file

@ -40,6 +40,10 @@ export default function FlashcardsPage() {
const [editingDeckId, setEditingDeckId] = useState(null)
const [editingTitle, setEditingTitle] = useState('')
const [deletingCard, setDeletingCard] = useState(null)
const [addCardDeckId, setAddCardDeckId] = useState(null)
const [newFront, setNewFront] = useState('')
const [newBack, setNewBack] = useState('')
const [cardError, setCardError] = useState('')
const { user } = useAuth()
const navigate = useNavigate()
const isAdmin = user?.role === 'admin'
@ -156,6 +160,16 @@ export default function FlashcardsPage() {
} catch { }
}
const addCard = async (deckId) => {
setCardError('')
if (!newFront.trim() || !newBack.trim()) { setCardError('Both sides are required'); return }
try {
await api.post(`/flashcards/decks/${deckId}/cards`, { front: newFront, back: newBack })
setDecks(prev => prev.map(d => d.id === deckId ? { ...d, card_count: d.card_count + 1 } : d))
setNewFront(''); setNewBack(''); setAddCardDeckId(null)
} catch (err) { setCardError(err.response?.data?.detail || 'Could not add card') }
}
if (loading && decks.length === 0 && cards.length === 0) return <div className="loading"><div className="spinner" /></div>
return (
@ -239,8 +253,20 @@ export default function FlashcardsPage() {
<button className="btn btn-secondary btn-sm" onClick={() => toggleShare(deck.id)}>
{deck.is_shared ? 'Unshare' : 'Share'}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => { setAddCardDeckId(deck.id); setCardError('') }}>Add card</button>
<button className="btn btn-danger btn-sm" onClick={() => deleteDeck(deck.id)}>Delete</button>
</div>
{addCardDeckId === deck.id && (
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
<input className="input" value={newFront} onChange={e => setNewFront(e.target.value)} placeholder="Front (question)" aria-label="Card front" />
<input className="input" value={newBack} onChange={e => setNewBack(e.target.value)} placeholder="Back (answer)" aria-label="Card back" />
{cardError && <div className="form-error" role="alert">{cardError}</div>}
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary btn-sm" onClick={() => addCard(deck.id)}>Save card</button>
<button className="btn btn-secondary btn-sm" onClick={() => setAddCardDeckId(null)}>Cancel</button>
</div>
</div>
)}
</div>
))}
</div>

View file

@ -7,6 +7,7 @@ import { useDialog } from '../hooks/useDialog'
const TeachChat = lazy(() => import('../components/TeachChat'))
const RichEditor = lazy(() => import('../components/RichEditor'))
import QuestionReadingLinks from '../components/QuestionReadingLinks'
function apiError(err, fallback) {
const detail = err?.response?.data?.detail
@ -84,10 +85,13 @@ function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite }
)}
</div>
)}
{answered && <div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<button className="btn btn-secondary btn-sm" onClick={() => setAnswered(null)}>Try again</button>
<button className="btn btn-secondary btn-sm" onClick={onClose}>Close</button>
</div>}
<QuestionReadingLinks questionId={question.id} />
{answered && (
<div style={{ marginTop: 14, display: 'flex', gap: 8 }}>
<button className="btn btn-secondary btn-sm" onClick={() => setAnswered(null)}>Try again</button>
<button className="btn btn-secondary btn-sm" onClick={onClose}>Close</button>
</div>
)}
</div>
</div>
{/* AI Tutor — z-index above the modal */}

View file

@ -1,4 +1,5 @@
import { uploadUrl } from '../utils/uploads'
import QuestionReadingLinks from '../components/QuestionReadingLinks'
import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
@ -1272,6 +1273,7 @@ const timerStarted = timeLeft !== null
)}
</div>
)}
<QuestionReadingLinks 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

@ -71,3 +71,8 @@
.quiz-response-track { width: min(48vw, 240px); }
}
@media (max-width: 430px) { .quiz-topbar { flex-wrap: wrap; } .quiz-top-actions { justify-content: flex-start; width: 100%; } }
.question-reading { margin-top: 12px; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; background: var(--card-bg); }
.question-reading strong { display: block; margin-bottom: 6px; font-size: .9rem; }
.question-reading ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; }
.question-reading a { color: var(--primary); font-size: .86rem; }

View file

@ -8,6 +8,7 @@ import InProgressQuizzes from '../components/InProgressQuizzes'
import { useDialog } from '../hooks/useDialog'
const TeachChat = lazy(() => import('../components/TeachChat'))
import QuestionReadingLinks from '../components/QuestionReadingLinks'
function PastAttemptsSection() {
const [open, setOpen] = useState(false)
@ -172,6 +173,7 @@ function QuestionStudyModal({ question, query, onClose }) {
)}
</div>
)}
<QuestionReadingLinks questionId={question.id} />
{answered && (
<div style={{ marginTop: 14, display: 'flex', gap: 8 }}>
<button className="btn btn-secondary btn-sm" onClick={() => setAnswered(null)}>Try again</button>