{article.summary}
} + {article.content &&diff --git a/backend/alembic/versions/e8d4f1a27c93_articles_cards.py b/backend/alembic/versions/e8d4f1a27c93_articles_cards.py
new file mode 100644
index 0000000..117ddcf
--- /dev/null
+++ b/backend/alembic/versions/e8d4f1a27c93_articles_cards.py
@@ -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")
diff --git a/backend/app/main.py b/backend/app/main.py
index 2572c02..98b521a 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -11,7 +11,7 @@ from app.logging_config import setup_logging
setup_logging(settings.LOG_LEVEL)
from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote
-from app.routers import study_tools, uploads
+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"])
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 3ac7fd4..d318ef3 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -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",
]
diff --git a/backend/app/models/article.py b/backend/app/models/article.py
new file mode 100644
index 0000000..9cd7bef
--- /dev/null
+++ b/backend/app/models/article.py
@@ -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")
diff --git a/backend/app/models/flashcard.py b/backend/app/models/flashcard.py
index 51c327a..2ea23fc 100644
--- a/backend/app/models/flashcard.py
+++ b/backend/app/models/flashcard.py
@@ -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)
diff --git a/backend/app/routers/articles.py b/backend/app/routers/articles.py
new file mode 100644
index 0000000..97040e5
--- /dev/null
+++ b/backend/app/routers/articles.py
@@ -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}
diff --git a/backend/app/routers/flashcards.py b/backend/app/routers/flashcards.py
index 2b08fa3..c2dee5e 100644
--- a/backend/app/routers/flashcards.py
+++ b/backend/app/routers/flashcards.py
@@ -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
diff --git a/backend/tests/test_articles_cards.py b/backend/tests/test_articles_cards.py
new file mode 100644
index 0000000..8ce98eb
--- /dev/null
+++ b/backend/tests/test_articles_cards.py
@@ -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()
diff --git a/backend/tests/test_articles_migration.py b/backend/tests/test_articles_migration.py
new file mode 100644
index 0000000..76f6c94
--- /dev/null
+++ b/backend/tests/test_articles_migration.py
@@ -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()
diff --git a/docs/quiz-revamp-progress.md b/docs/quiz-revamp-progress.md
index 5b5f20f..86ee088 100644
--- a/docs/quiz-revamp-progress.md
+++ b/docs/quiz-revamp-progress.md
@@ -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.
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index ee4d2b1..4de519e 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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() {
Educator-reviewed articles linked to questions and cards.
+{article.summary}
} + {article.sections?.length || 0} sections + + ))} +{article.summary}
} + {article.content &&