pdf-quiz-generator/backend/app/routers/articles.py
Daniel 158930d532
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 28s
Tests / e2e (push) Failing after 36s
feat: section links in prose, a picker that writes them, and cards that render
**Cross-references can name a section.** `[[264#workup|the workup]]` opens the
reader at that heading, which is what a sentence about one part of a long
article actually means. Whole-article `[[264|label]]` is unchanged, and a
section renamed since is not a broken link — it lands at the top of the right
article, which is a mild disappointment rather than a dead end.

**A picker that writes the marker for you.** 🔗 Link an article, in the editor:
type a few words, click the article — or one of its sections — and the marker
is on the clipboard with the right title as its label. Getting an id used to
mean opening the library in another tab, finding the article and reading the
number out of the address bar, which is four steps and a chance to mistype,
every time. Its own small endpoint, because the listing deliberately does not
carry sections and this needs nothing else.

**Three things about cards that were built but never drawn:**

- A card can carry an image. The column is there, the API returns it, the
  editor accepts one — and no view in the app rendered it, so every picture
  anybody attached to a card was stored and never seen. Both card views show it
  now, small until clicked like every other figure.
- The deck browser printed `[[331|Epiglottitis]]` as brackets and a number. The
  study view has rendered them as links for a while; now both do.
- There was no way to make a deck by hand. Every deck came out of a model —
  generated from a document section or an article — so an educator who wanted
  to write six cards had nowhere to put them, and the add-a-card route could
  only add to a deck that did not exist yet. `+ New deck` on the cards page.

**Generate cards ran in silence.** It starts a real job, and the only place its
progress was drawn was inside the refine panel — which lives in the editor and
is shut. Pressing it on the reading page did nothing visible for ninety
seconds. It now says what it is doing where it was pressed.

**Overlays were invisible to learners.** A stored width is a fraction of the
image, and the stroke is drawn with `non-scaling-stroke`, which makes
`stroke-width` a count of screen pixels — so 0.006 meant six thousandths of a
pixel. The editor has always multiplied by its rendered width; the viewer now
does the same sum. Every region an educator has ever marked was invisible to
everyone who was not editing it.

Also: the figure viewer no longer scrolls, at any width, and the page behind it
is pinned properly (`overflow: hidden` on the body does nothing on iOS, so a
figure opened half-way down an article drifted while it was read). Options are
full width on a phone. The question toolbar's seven glyphs are four, with the
rest folded into the ⋯ that was already there, spelled out in words. The jobs
popover closes on a click anywhere outside it. And the editor has a way back to
Editorial — "back to the article", from an article you opened to edit, is a
loop.

The contract snapshot caught both new routes on the way through, which is what
it is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 02:05:02 +02:00

1163 lines
49 KiB
Python

"""Topic/article library with stable subsection links and card/question associations."""
import re
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.search_service import article_ids_with_sections
from app.models.article import (
Article, ArticleTopicClaim, ArticleRevision, ArticleSlug, ArticleView,
QuestionArticleLink,
)
from app.services import article_service, topic_claims
from app.models.flashcard import Flashcard, FlashcardDeck, 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.services.quiz_builder import category_breadcrumbs, category_descendants
from app.models.question_category import QuestionCategory, QuestionCategoryLink
from app.utils.auth import get_current_user, require_moderator
from app.utils.category_grants import can_edit_article
import logging
from datetime import datetime
router = APIRouter()
log = logging.getLogger(__name__)
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
class ArticleSection(BaseModel):
id: str
slug: str
title: str
content: str = ""
# A section may sit under another one, which is how "ROS questionnaire"
# belongs to "Review of systems" rather than standing alongside it. Absent
# or null means top level, so every article written before this stays valid.
parent_id: str | None = None
# Which of the three readings this section belongs to: the full article, the
# key points, or the clinical view carrying management and doses.
variant: str = "long"
class ArticleReference(BaseModel):
"""Where a fact came from. Named sources, not markers in the prose."""
title: str = Field(min_length=1, max_length=300)
author: str | None = Field(default=None, max_length=300)
pages: list[int] = Field(default_factory=list, max_length=40)
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
# Absent means "leave them alone": a caller that predates references must not
# silently strip the ones generation attached.
references: list[ArticleReference] | 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
class ArticleCategoryLinkIn(BaseModel):
category_id: int
section_id: str | None = None
#: A topic's subtopics come with it by default, because that is what an
#: educator means by "Cardiology" — and it is the same rule the test
#: builder and the analysis already use for a chosen category.
include_subtopics: bool = True
class ArticleAIDraft(BaseModel):
topic: str
instructions: str | None = None
@field_validator("topic")
@classmethod
def topic_shape(cls, value):
value = value.strip()
if not value or len(value) > 300:
raise ValueError("Topic is required (max 300 characters)")
return value
class ArticleAIRefine(BaseModel):
instructions: str = ""
@field_validator("instructions")
@classmethod
def instructions_shape(cls, value):
if len(value) > 2000:
raise ValueError("Instructions are too long (max 2000 characters)")
return value
def _queue_article_job(db, user, job_id, job_title):
import redis as redis_lib
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
r.set(f"extraction:status:{job_id}", "pending", ex=3600)
r.lpush(f"extraction:user_jobs:{user.id}", job_id)
r.expire(f"extraction:user_jobs:{user.id}", 86400)
r.set(f"extraction:job_title:{job_id}", job_title, ex=3600)
def _validate_sections(sections: list[ArticleSection]):
ids, slugs = set(), set()
for section in sections:
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())
for section in sections:
if section.variant not in article_service.VARIANTS:
raise HTTPException(400, f"Unknown section variant: {section.variant}")
# Nesting is one level deep and points backwards: a parent has to be a
# section of this article that was already listed, which rules out a cycle
# and a sub-section that renders before the heading it belongs to.
parents = set()
seen: set[str] = set()
for section in sections:
if section.parent_id is not None:
if section.parent_id == section.id or section.parent_id not in seen:
raise HTTPException(400, "A sub-section must sit under an earlier section of this article")
parent = next(s for s in sections if s.id == section.parent_id)
# A sub-section belongs to the same reading as its parent, or the
# contents rail would list a child that its view never renders.
if parent.variant != section.variant:
raise HTTPException(400, "A sub-section must be in the same view as the section it sits under")
parents.add(section.parent_id)
seen.add(section.id)
nested = {s.id for s in sections if s.parent_id is not None}
if parents & nested:
raise HTTPException(400, "Sub-sections cannot themselves hold sub-sections")
def _section_ids(article):
return {section["id"] for section in (article.sections or [])}
def _validate_source_section(db, section_id):
if section_id is not None and not db.get(Section, section_id):
raise HTTPException(400, "Source section not found")
def _views_for(db, user) -> list[str]:
"""The readings this learner's objective offers."""
from app.models.exam import Exam
from app.routers.exams import views_for
exam_id = getattr(user, "active_exam_id", None)
return views_for(db.get(Exam, exam_id) if exam_id else None)
def _article_card_json(article: Article) -> dict:
"""An article as it appears in a list: enough to find it, not to read it.
The listing used to send `content` and `sections` for every article — 214KB
of prose across the library, none of which a list renders. A browser that
waits for the whole corpus before it can draw a column of titles is slow
for no reason.
"""
sections = article.sections or []
return {
"id": article.id,
"slug": article.slug,
"title": article.title,
"summary": article.summary,
"category_id": article.category_id,
"section_id": article.section_id,
"user_id": article.user_id,
"status": article.status,
"section_count": len(sections),
"variants": article_service.available_variants(article),
"generated_by": article.generated_by,
"reviewed_at": article.reviewed_at,
"submitted_at": article.submitted_at,
"created_at": article.created_at,
"updated_at": article.updated_at,
}
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,
"references": article.references_json or [],
"variants": article_service.available_variants(article),
"generated_by": article.generated_by,
"reviewed_at": article.reviewed_at,
"submitted_at": article.submitted_at,
"created_at": article.created_at,
"updated_at": article.updated_at,
}
def _record_view(db, user, article) -> None:
"""Remember that this learner opened the article; one row per user/article.
Best effort: a reading page must not fail because a bookkeeping write did.
"""
try:
view = db.query(ArticleView).filter_by(user_id=user.id, article_id=article.id).first()
if view:
view.viewed_at = datetime.utcnow()
else:
db.add(ArticleView(user_id=user.id, article_id=article.id))
db.commit()
except Exception:
db.rollback()
log.warning("Could not record article view", exc_info=True)
@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 drafts."""
query = db.query(Article).filter(Article.deleted_at.is_(None))
if category_id:
query = query.filter(Article.category_id == category_id)
if q and q.strip():
# Hybrid retrieval, same as the question bank: a title substring match
# could not find an article that says the same thing in other words.
# Sections are searched alongside the article row, because the body is
# in them — searching only the article row found nothing for a term that
# appears once, in one section, which is most of what anyone looks up.
# Section hits inside this are reranked by a cross-encoder before they
# decide an article's place; see `search_service.article_ids_with_sections`.
ranked, _ = article_ids_with_sections(db, q.strip(), limit=200)
if not ranked:
return []
query = query.filter(Article.id.in_(ranked))
rank_of = {article_id: position for position, article_id in enumerate(ranked)}
articles = query.order_by(Article.updated_at.desc()).all()
if q and q.strip():
articles.sort(key=lambda article: rank_of.get(article.id, len(rank_of)))
if not current_user.is_moderator:
articles = [a for a in articles if a.status == "published"]
return [_article_card_json(a) for a in articles]
@router.get("/link-targets")
def link_targets(
q: str | None = Query(None, description="What the writer typed"),
limit: int = Query(8, le=25),
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Articles and their sections, for the picker that writes a cross-reference.
Its own endpoint rather than the listing, because the two want opposite
things: the listing is a page of cards and deliberately does not carry
sections, and this needs the sections and almost nothing else. Eight
results, because a picker is for choosing rather than for browsing.
"""
query = db.query(Article).filter(Article.deleted_at.is_(None))
ranked: list[int] = []
if q and q.strip():
ranked, _ = article_ids_with_sections(db, q.strip(), limit=60)
if not ranked:
return []
query = query.filter(Article.id.in_(ranked))
articles = query.order_by(Article.updated_at.desc()).all()
if ranked:
rank_of = {article_id: position for position, article_id in enumerate(ranked)}
articles.sort(key=lambda article: rank_of.get(article.id, len(rank_of)))
return [{
"id": article.id,
"title": article.title,
"slug": article.slug,
"status": article.status,
# Only the sections that have somewhere to land: a section with no id
# cannot be addressed, and offering it would produce a dead marker.
"sections": [
{"id": section.get("id"), "title": section.get("title") or section.get("slug") or "Untitled",
"variant": section.get("variant")}
for section in (article.sections or []) if section.get("id")
],
} for article in articles[:limit]]
@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")
_validate_source_section(db, data.section_id)
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.flush()
article_service.record_slug(db, article)
db.commit()
db.refresh(article)
article_service.reindex(db, article)
return _article_json(article)
@router.get("/recent")
def recently_viewed(
limit: int = Query(5, ge=1, le=20),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Articles this learner opened most recently, newest first."""
rows = (
db.query(Article, ArticleView.viewed_at)
.join(ArticleView, ArticleView.article_id == Article.id)
.filter(ArticleView.user_id == current_user.id, Article.status == "published")
.order_by(ArticleView.viewed_at.desc())
.limit(limit)
.all()
)
return [{"id": article.id, "title": article.title, "slug": article.slug,
"viewed_at": viewed_at.isoformat() if viewed_at else None}
for article, viewed_at in rows]
def _plain_excerpt(article: Article, limit: int = 260) -> str:
"""First readable prose in an article, with the markup taken out.
A preview shows what the link leads to, so it wants the opening sentences —
not a heading, a table pipe, or an image tag rendered as `![](…)`.
"""
source = article.summary or article.content or ""
if not source.strip() and article.sections:
source = (article.sections[0] or {}).get("content") or ""
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", source) # images
# Our own cross-reference syntax, before the plain-link rule below gets at
# it: `[[288|eczema]]` and `[[Eczema|eczema]]` both mean a word, and the
# generic rule left "[[288 eczema]]" on the card. Which half is the word
# depends on which half is a number.
text = re.sub(r"\[\[(?:(\d+)\|([^\]]+)|([^\]|]+?)(?:\|[a-z0-9-]+)?)\]\]",
lambda m: (m.group(2) or m.group(3) or "").strip(), text)
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text) # links keep their words
text = re.sub(r"^\s{0,3}#{1,6}\s*", "", text, flags=re.M) # headings
text = re.sub(r"[*_`>|]|^\s*[-+]\s", " ", text, flags=re.M)
text = re.sub(r"\s+", " ", text).strip()
return text[:limit].rstrip() + "" if len(text) > limit else text
@router.get("/by-slug/{slug}")
def article_by_slug(slug: str, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
"""Resolve a slug — current or historical — to the article it names."""
article = article_service.resolve_slug(db, slug)
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")
# `moved` tells the caller to correct its URL rather than keep using the old one.
return {"id": article.id, "slug": article.slug, "title": article.title,
"moved": article.slug != slug.strip().lower()}
@router.get("/preview/{slug}")
def preview_article(
slug: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""What a cross-reference points at, for a hover card.
Deliberately small: a link preview that fetched whole articles would pull
down the library a paragraph at a time as somebody reads.
"""
article = article_service.resolve_slug(db, slug)
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")
return {
"id": article.id,
"slug": article.slug,
"title": article.title,
"excerpt": _plain_excerpt(article),
"section_count": len(article.sections or []),
"status": article.status,
}
@router.get("/trash")
def list_trash(db: Session = Depends(get_db), current_user: User = Depends(require_moderator)):
"""What has been deleted and can still be brought back.
Only ever articles that were published at some point: a draft nobody saw is
deleted outright, because there is nothing to restore and a trash full of
abandoned stubs is a second list to maintain.
"""
rows = db.query(Article).filter(Article.deleted_at.isnot(None)).order_by(
Article.deleted_at.desc()).all()
return [{**_article_card_json(article),
"deleted_at": article.deleted_at,
"first_published_at": article.first_published_at} for article in rows]
@router.post("/{article_id}/restore")
def restore_article(article_id: int, db: Session = Depends(get_db),
current_user: User = Depends(require_moderator)):
"""Out of the trash, in the state it went in."""
article = db.get(Article, article_id)
if not article or article.deleted_at is None:
raise HTTPException(404, "Article not found in the trash")
article.deleted_at = None
article.deleted_by = None
db.commit()
# Its section rows were torn down when it was binned, so being published
# again is not enough on its own to make it findable.
article_service.reindex(db, article)
return _article_json(article)
@router.delete("/trash/{article_id}", status_code=204)
def purge_article(article_id: int, db: Session = Depends(get_db),
current_user: User = Depends(require_moderator)):
"""Gone for good, and only from the trash — so it is always the second
decision about the same article rather than the first."""
article = db.get(Article, article_id)
if not article or article.deleted_at is None:
raise HTTPException(404, "Article not found in the trash")
db.delete(article)
db.commit()
@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 or article.deleted_at is not None:
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")
_record_view(db, current_user, article)
data = _article_json(article)
allowed = _views_for(db, current_user)
data["variants"] = [v for v in data["variants"] if v in allowed]
data["sections"] = [s for s in article_service.normalise_sections(article.sections or [])
if s.get("variant") in allowed]
# An editor has to see the whole article to edit it; a learner does not.
if current_user.is_moderator or article.user_id == current_user.id:
data["all_variants"] = article_service.available_variants(article)
categories = db.query(QuestionCategory).all()
data["category_breadcrumbs"] = category_breadcrumbs(categories, article.category_id) if article.category_id else []
return data
class SectionNoteIn(BaseModel):
content: str = Field(default="", max_length=8000)
def _readable_article(db, user, article_id: int) -> Article:
"""The article, if this person may read it at all."""
article = db.get(Article, article_id)
if not article:
raise HTTPException(404, "Article not found")
if article.status != "published" and not user.is_moderator and article.user_id != user.id:
raise HTTPException(404, "Article not found")
return article
@router.get("/{article_id}/notes")
def read_section_notes(article_id: int, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
"""This reader's own notes on this article, section by section.
Private, and only ever this reader's. Feedback to whoever maintains the
article is a different route with a different table — a note somebody else
can read is not the thing a learner thought they were writing.
A note whose section has gone comes back marked `orphaned` rather than being
dropped. Section ids survive a rename, so a retitled heading keeps its
notes; a deleted one leaves writing with nowhere to sit, and quietly
discarding it is the one outcome worth engineering against. The reader shows
them at the end of the article under the heading they were written on.
"""
from app.models.user_note import ArticleSectionNote
article = _readable_article(db, current_user, article_id)
live = {section.get("id"): section.get("title")
for section in (article.sections or []) if isinstance(section, dict)}
rows = db.query(ArticleSectionNote).filter_by(
user_id=current_user.id, article_id=article_id).all()
return [{
"section_id": row.section_id,
"content": row.content,
# The heading as it reads now where the section is still there, and the
# one it was written under where it is not.
"section_title": live.get(row.section_id) or row.section_title,
"orphaned": row.section_id not in live,
"updated_at": row.updated_at,
} for row in sorted(rows, key=lambda r: (r.section_id not in live, r.id))]
@router.put("/{article_id}/notes/{section_id}")
def write_section_note(article_id: int, section_id: str, data: SectionNoteIn,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
"""Save or clear one section's note. Empty means delete, as for a question."""
from app.models.user_note import ArticleSectionNote
article = _readable_article(db, current_user, article_id)
title = next((section.get("title") for section in (article.sections or [])
if isinstance(section, dict) and section.get("id") == section_id), None)
row = db.query(ArticleSectionNote).filter_by(
user_id=current_user.id, article_id=article_id, section_id=section_id).first()
content = data.content.strip()
if not content:
if row:
db.delete(row)
db.commit()
return {"section_id": section_id, "content": ""}
# Writing on a section that no longer exists is refused; writing on one that
# does re-records its heading, so the snapshot stays useful if it is later
# renamed or removed.
if title is None and row is None:
raise HTTPException(404, "Section not found")
if row:
row.content = content
if title is not None:
row.section_title = title
else:
db.add(ArticleSectionNote(user_id=current_user.id, article_id=article_id,
section_id=section_id, section_title=title, content=content))
db.commit()
return {"section_id": section_id, "content": content}
@router.delete("/{article_id}/notes/{section_id}", status_code=204)
def delete_section_note(article_id: int, section_id: str, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
"""The writer's own delete — the only thing that removes a note on purpose."""
from app.models.user_note import ArticleSectionNote
db.query(ArticleSectionNote).filter_by(
user_id=current_user.id, article_id=article_id, section_id=section_id).delete(
synchronize_session=False)
db.commit()
@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,
"section_title": article_service.section_title(article, 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 can_edit_article(db, current_user, article):
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")
_validate_source_section(db, data.section_id)
if db.query(Article.id).filter(Article.slug == data.slug, Article.id != article.id).first():
raise HTTPException(400, "Slug is already in use")
# Keep what it was before it becomes something else.
article_service.snapshot(db, article, current_user.id)
article.slug, article.title, article.summary = data.slug, data.title, data.summary
article.content = article_service.upgrade_markers(db, data.content)
article.sections = article_service.normalise_sections([
{**section.model_dump(),
"content": article_service.upgrade_markers(db, section.content)}
for section in data.sections
])
article.category_id, article.section_id = data.category_id, data.section_id
if data.references is not None:
article.references_json = [ref.model_dump() for ref in data.references]
article_service.record_slug(db, article)
# 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)
article_service.reindex(db, article)
# Told at the moment of saving, which is the last point the person who wrote
# the link is still looking at it.
return {**_article_json(article), "broken_links": article_service.broken_markers(db, article)}
@router.delete("/{article_id}")
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 or article.deleted_at is not None:
raise HTTPException(404, "Article not found")
if not can_edit_article(db, current_user, article):
raise HTTPException(403, "Not your article")
# Anything the world has seen is only ever marked. Somewhere there is a
# learner's note against one of its sections, a question linked to it, and
# a link somebody sent a colleague — none of which a DELETE typed in the
# afternoon should be allowed to settle. A draft that was never published
# has none of that behind it, so it goes.
if article.first_published_at is None:
db.delete(article)
db.commit()
return {"deleted": "permanently"}
article.deleted_at = datetime.utcnow()
article.deleted_by = current_user.id
db.commit()
# Out of search and out of the assistant's shortlist immediately: a binned
# article that still answers questions is worse than one still listed.
article_service.reindex(db, article)
return {"deleted": "to_trash"}
@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"
if data.published and article.first_published_at is None:
# Stamped once and never cleared. Unpublishing does not make an article
# unseen, so it does not make deleting it safe either.
article.first_published_at = datetime.utcnow()
db.commit()
# Publication is what decides whether the body is searchable, so it is also
# what has to build or tear down the section index. Nothing else touches
# this article — an article published and never edited again would
# otherwise stay out of search for good.
article_service.reindex(db, article)
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}
class _ClaimLike:
"""Enough of a claim to price one, before it exists."""
def __init__(self, article_id, category_id, section_id, include_subtopics):
self.article_id = article_id
self.category_id = category_id
self.section_id = section_id
self.include_subtopics = include_subtopics
@router.get("/{article_id}/links/from-category")
def preview_category_link(
article_id: int,
category_id: int = Query(...),
section_id: str | None = Query(None),
include_subtopics: bool = Query(True),
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""How many questions claiming this topic would add, before it is claimed.
Asked separately so the button can carry the number. "Link 43 questions" is
a decision; "Link this topic" is a guess, and the difference matters when
the topic turns out to be the whole of Cardiology.
"""
article = db.get(Article, article_id)
if not article:
raise HTTPException(404, "Article not found")
if not db.query(QuestionCategory.id).filter(QuestionCategory.id == category_id).first():
raise HTTPException(404, "Category not found")
return topic_claims.preview(
db, _ClaimLike(article_id, category_id, section_id, include_subtopics))
@router.get("/{article_id}/claims")
def list_claims(article_id: int, db: Session = Depends(get_db),
current_user: User = Depends(require_moderator)):
"""The topics this article stands on, and how many questions each brings."""
rows = db.query(ArticleTopicClaim).filter(
ArticleTopicClaim.article_id == article_id).order_by(ArticleTopicClaim.id).all()
names = {row.id: row.name for row in db.query(QuestionCategory).all()}
sections = {section["id"]: section.get("title")
for section in ((db.get(Article, article_id) or Article()).sections or [])
if isinstance(section, dict) and section.get("id")}
out = []
for claim in rows:
covered = topic_claims.question_ids_in(
db, topic_claims.categories_under(db, claim.category_id, claim.include_subtopics))
out.append({
"id": claim.id,
"category_id": claim.category_id,
"category_name": names.get(claim.category_id, "A deleted topic"),
"section_id": claim.section_id,
"section_title": sections.get(claim.section_id) if claim.section_id else None,
"include_subtopics": claim.include_subtopics,
"questions": len(covered),
})
return out
@router.post("/{article_id}/links/from-category")
def link_category(
article_id: int,
data: ArticleCategoryLinkIn,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Claim a topic: every question in it links to this article, now and later.
Not a one-off copy. "The Cardiology article covers the Cardiology
questions" is a standing statement about the material, and a copy of it
stops being true the first time somebody files a new question — silently,
with nothing on any screen to say so.
What is stored is the claim; what everything *reads* is still
`question_article_links`, whose rows this makes. See
`services/topic_claims.py` for why it is built that way round.
"""
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(QuestionCategory.id).filter(
QuestionCategory.id == data.category_id).first():
raise HTTPException(404, "Category not found")
priced = topic_claims.preview(db, _ClaimLike(
article_id, data.category_id, data.section_id, data.include_subtopics))
if priced["would_link"] > topic_claims.MAX_LINKS_PER_CLAIM:
raise HTTPException(
400,
f"That topic holds {priced['would_link']} unlinked questions, over the "
f"{topic_claims.MAX_LINKS_PER_CLAIM} one claim may attach at once. "
"Claim a subtopic instead.")
claim = db.query(ArticleTopicClaim).filter_by(
article_id=article_id, category_id=data.category_id,
section_id=data.section_id).first()
if claim is None:
claim = ArticleTopicClaim(
article_id=article_id, category_id=data.category_id,
section_id=data.section_id, include_subtopics=data.include_subtopics,
user_id=current_user.id)
db.add(claim)
else:
claim.include_subtopics = data.include_subtopics
db.commit()
db.refresh(claim)
linked = topic_claims.apply(db, claim)
return {"linked": linked, "skipped": priced["total"] - linked,
"total": priced["total"], "claim_id": claim.id}
@router.delete("/{article_id}/claims/{claim_id}", status_code=204)
def drop_claim(article_id: int, claim_id: int, keep_links: bool = Query(True),
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator)):
"""Stop claiming a topic.
The links it made stay by default, because they are ordinary links and
somebody may have curated them since. `keep_links=false` removes the ones
this claim would make — the "undo the whole thing" case.
"""
claim = db.query(ArticleTopicClaim).filter_by(id=claim_id, article_id=article_id).first()
if claim is None:
raise HTTPException(404, "Claim not found")
if not keep_links:
covered = topic_claims.question_ids_in(
db, topic_claims.categories_under(db, claim.category_id, claim.include_subtopics))
if covered:
db.query(QuestionArticleLink).filter(
QuestionArticleLink.article_id == article_id,
QuestionArticleLink.section_id == claim.section_id,
QuestionArticleLink.question_id.in_(covered)).delete(synchronize_session=False)
db.delete(claim)
db.commit()
@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("/ai-draft")
def start_article_draft(
data: ArticleAIDraft,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Queue an AI draft; the saved article is a draft until an educator publishes it."""
import uuid
from app.tasks.quiz_tasks import generate_article_draft
job_id = str(uuid.uuid4())
_queue_article_job(db, current_user, job_id, f"Article draft: {data.topic[:60]}")
try:
generate_article_draft.delay(
job_id=job_id, user_id=current_user.id,
topic=data.topic, instructions=data.instructions or "", model_id=None,
)
except Exception:
raise HTTPException(503, "Task queue unavailable")
return {"job_id": job_id, "status": "pending"}
@router.post("/{article_id}/ai-refine")
def start_article_refine(
article_id: int,
data: ArticleAIRefine,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
article = db.get(Article, article_id)
if not article:
raise HTTPException(404, "Article not found")
import uuid
from app.tasks.quiz_tasks import generate_article_draft
job_id = str(uuid.uuid4())
_queue_article_job(db, current_user, job_id, f"Refine: {article.title[:60]}")
try:
generate_article_draft.delay(
job_id=job_id, user_id=current_user.id, topic=article.title,
instructions=data.instructions, article_id=article.id, model_id=None,
)
except Exception:
raise HTTPException(503, "Task queue unavailable")
return {"job_id": job_id, "status": "pending"}
@router.post("/{article_id}/ai-cards")
def start_article_cards(
article_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Generate cards into a private deck; sharing remains an explicit educator action."""
article = db.get(Article, article_id)
if not article:
raise HTTPException(404, "Article not found")
import uuid
from app.tasks.quiz_tasks import generate_article_cards
job_id = str(uuid.uuid4())
_queue_article_job(db, current_user, job_id, f"Cards: {article.title[:60]}")
try:
generate_article_cards.delay(job_id=job_id, user_id=current_user.id, article_id=article.id, model_id=None)
except Exception:
raise HTTPException(503, "Task queue unavailable")
return {"job_id": job_id, "status": "pending"}
@router.get("/job/{job_id}")
def get_article_job(job_id: str, current_user: User = Depends(get_current_user)):
"""Poll an article AI job for the current user."""
import json as _json
import redis as redis_lib
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
user_jobs = r.lrange(f"extraction:user_jobs:{current_user.id}", 0, 199)
if job_id not in user_jobs and not current_user.is_moderator:
raise HTTPException(404, "Job not found")
status = r.get(f"extraction:status:{job_id}") or "unknown"
steps = [_json.loads(s) for s in r.lrange(f"extraction:steps:{job_id}", 0, -1)]
result = {"job_id": job_id, "status": status, "steps": steps}
# What it made, so the page that started the job can open it. Without this
# a draft finished into a list somewhere and the educator who asked for it
# was left looking at a panel that had simply closed.
# Parsed defensively: an older job has no such key, and anything that is
# not a number means the same thing as nothing — no article to open.
article_id = r.get(f"extraction:article:{job_id}")
if article_id and str(article_id).isdigit():
result["article_id"] = int(article_id)
if status == "failed":
result["error"] = r.get(f"extraction:error:{job_id}") or "Unknown error"
return result
# ── Editorial: review workflow, revisions, and the queue of work ──────────────
class StatusIn(BaseModel):
status: str
@router.post("/{article_id}/status")
def set_article_status(article_id: int, data: StatusIn, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
"""Move an article through draft → in review → published.
An author may send their own work for review; only a moderator may publish,
because publishing is the point at which nobody checks it again.
"""
article = db.get(Article, article_id)
if not article:
raise HTTPException(404, "Article not found")
is_author = article.user_id == current_user.id
if not (current_user.is_moderator or is_author):
raise HTTPException(403, "Not your article")
if data.status == "published" and not current_user.is_moderator:
raise HTTPException(403, "Only a moderator can publish an article")
try:
article_service.set_status(db, article, data.status, current_user.id)
except ValueError as error:
raise HTTPException(400, str(error))
db.commit()
return {"id": article.id, "status": article.status}
@router.get("/{article_id}/revisions")
def list_revisions(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 can_edit_article(db, current_user, article):
raise HTTPException(403, "Not your article")
rows = db.query(ArticleRevision).filter(
ArticleRevision.article_id == article_id).order_by(ArticleRevision.id.desc()).limit(30).all()
return [{
"id": r.id, "title": r.title, "status": r.status, "note": r.note,
"created_at": r.created_at, "created_by": r.created_by,
"section_count": len(r.sections or []),
} for r in rows]
@router.get("/{article_id}/revisions/{revision_id}")
def read_revision(article_id: int, revision_id: int, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
revision = db.get(ArticleRevision, revision_id)
if not revision or revision.article_id != article_id:
raise HTTPException(404, "Revision not found")
article = db.get(Article, article_id)
if not (current_user.is_moderator or (article and article.user_id == current_user.id)):
raise HTTPException(403, "Not your article")
return {"id": revision.id, "title": revision.title, "summary": revision.summary,
"content": revision.content, "sections": revision.sections,
"references": revision.references_json or [], "created_at": revision.created_at}
@router.post("/{article_id}/revisions/{revision_id}/restore")
def restore_revision(article_id: int, revision_id: int, db: Session = Depends(get_db),
current_user: User = Depends(require_moderator)):
"""Put an old version back, keeping the current one as a revision of its own.
Restoring is itself an edit, so it is snapshotted too — otherwise the way
back from a mistaken restore is gone.
"""
revision = db.get(ArticleRevision, revision_id)
article = db.get(Article, article_id)
if not article or not revision or revision.article_id != article_id:
raise HTTPException(404, "Revision not found")
article_service.snapshot(db, article, current_user.id, note=f"before restoring #{revision.id}")
article.title, article.summary, article.content = revision.title, revision.summary, revision.content
article.sections = article_service.normalise_sections(revision.sections or [])
article.references_json = revision.references_json
db.commit()
db.refresh(article)
article_service.reindex(db, article)
return _article_json(article)
@router.get("/editorial/queue")
def editorial_queue(db: Session = Depends(get_db),
current_user: User = Depends(require_moderator)):
"""What still needs a person: the work, not a list of everything.
Each bucket is something an editor can act on today. Counting articles by
status alone would say how many exist, which is not a queue.
"""
articles = db.query(Article).filter(Article.deleted_at.is_(None)).all()
linked = {row[0] for row in db.query(QuestionArticleLink.article_id).distinct().all()}
binned = db.query(Article).filter(Article.deleted_at.isnot(None)).count()
def row(article):
return {"id": article.id, "slug": article.slug, "title": article.title,
"status": article.status, "generated_by": article.generated_by,
"updated_at": article.updated_at,
"variants": article_service.available_variants(article)}
awaiting = [row(a) for a in articles if a.status == "in_review"]
machine_drafts = [row(a) for a in articles
if a.status == "draft" and a.generated_by]
no_references = [row(a) for a in articles
if a.status != "draft" and not (a.references_json or [])]
no_questions = [row(a) for a in articles
if a.status == "published" and a.id not in linked]
thin = [row(a) for a in articles if len(a.sections or []) < 2]
return {
"counts": {
"total": len(articles),
"draft": sum(1 for a in articles if a.status == "draft"),
"in_review": len(awaiting),
"published": sum(1 for a in articles if a.status == "published"),
# Not a queue — the trash is not work to do — but a number an
# editor wants to see before they wonder where an article went.
"trashed": binned,
},
# Ordered by what blocks a learner soonest.
"awaiting_review": awaiting[:100],
"machine_drafts": machine_drafts[:100],
"published_without_references": no_references[:100],
"published_without_questions": no_questions[:100],
"thin": thin[:100],
}