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:
parent
1ce3eec7cb
commit
325f7f400f
21 changed files with 1387 additions and 6 deletions
67
backend/alembic/versions/e8d4f1a27c93_articles_cards.py
Normal file
67
backend/alembic/versions/e8d4f1a27c93_articles_cards.py
Normal 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")
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
45
backend/app/models/article.py
Normal file
45
backend/app/models/article.py
Normal 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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
342
backend/app/routers/articles.py
Normal file
342
backend/app/routers/articles.py
Normal 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}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
160
backend/tests/test_articles_cards.py
Normal file
160
backend/tests/test_articles_cards.py
Normal 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()
|
||||
44
backend/tests/test_articles_migration.py
Normal file
44
backend/tests/test_articles_migration.py
Normal 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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
29
frontend/src/components/QuestionReadingLinks.jsx
Normal file
29
frontend/src/components/QuestionReadingLinks.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
frontend/src/pages/ArticlesPage.css
Normal file
48
frontend/src/pages/ArticlesPage.css
Normal 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; }
|
||||
}
|
||||
324
frontend/src/pages/ArticlesPage.jsx
Normal file
324
frontend/src/pages/ArticlesPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
frontend/src/pages/ArticlesPage.test.jsx
Normal file
59
frontend/src/pages/ArticlesPage.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue