A question's stem image is two to four megabytes of scanned radiograph, and a
media grid is forty of those pulled at full size to draw forty postage stamps.
`?w=256` and `?w=640` now serve a WebP copy instead, made on the first ask and
kept beside the original under `thumbs/{width}/{key}` — same bucket, so nothing
new has to be configured for them to be backed up or thrown away.
Three rules, all about not making this a way to spend the server's afternoon.
Those two widths and no others: any other `?w=` is refused with a 400, because
an endpoint that resizes to whatever the query string asks for is a CPU sink
anybody can point at. Never enlarged: a 180px image asked for at 640 is served
as it is, since scaling up invents detail and charges bytes for it. And best
effort throughout — a PDF, an SVG, a truncated upload or a file that is not the
image its name claims all serve their original rather than failing, because a
preview must never take down the page that wanted it.
Authorisation is unchanged and still runs first: a thumbnail of a file you may
not read is a file you may not read. They stay `private, no-store` like
everything else here — they are behind authentication, so there is nothing for
a shared cache to do with them, and the win is the byte count.
EXIF rotation is read before anything measures the image. Every phone stores a
portrait photograph sideways with a flag; a thumbnail made without reading it
is a sideways thumbnail.
Pillow rather than sharp, which is Node. It is not pinned in requirements: the
pin invalidates the pip layer, and that layer no longer builds because
litellm==1.28.13 has been withdrawn from PyPI. Re-pinning litellm is a
deliberate upgrade of the AI layer, not something to slip into this. Noted in
the TODO.
Also: the article hover-card excerpt was printing `[[288|eczema]]` at readers.
The generic markdown-link rule does not know our own cross-reference syntax, so
it left the brackets and the id behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
861 lines
34 KiB
Python
861 lines
34 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.orm import Session
|
|
|
|
from app.database import get_db
|
|
from app.services.search_service import hybrid_ids
|
|
from app.services import embedding_service
|
|
from app.models.article import (
|
|
Article, ArticleRevision, ArticleSectionIndex, ArticleSlug, ArticleView,
|
|
QuestionArticleLink,
|
|
)
|
|
from app.services import article_service
|
|
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
|
|
from app.models.question_category import QuestionCategory
|
|
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 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)
|
|
|
|
|
|
def _reembed(db, article) -> None:
|
|
"""Embed the article and reproject its sections. Failures wait for the retry task."""
|
|
try:
|
|
embedding_service.embed_record(article, "article")
|
|
_rebuild_section_index(db, article)
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
log.warning("Could not embed article %s; leaving it for the retry task", article.id, exc_info=True)
|
|
|
|
|
|
def _rebuild_section_index(db, article) -> None:
|
|
"""Mirror the article's JSON sections into their own searchable rows.
|
|
|
|
Sections live in a JSON column, so they cannot carry a vector or a full-text
|
|
index themselves. Projecting them lets a citation point at the right section
|
|
rather than the whole article. Rows are keyed by section id, so editing a
|
|
section updates it and removing one deletes it.
|
|
"""
|
|
sections = article.sections or []
|
|
keep = set()
|
|
for section in sections:
|
|
section_id = section.get("id")
|
|
if not section_id:
|
|
continue
|
|
keep.add(section_id)
|
|
row = db.query(ArticleSectionIndex).filter_by(
|
|
article_id=article.id, section_id=section_id).first()
|
|
text_changed = True
|
|
if row is None:
|
|
row = ArticleSectionIndex(article_id=article.id, section_id=section_id)
|
|
db.add(row)
|
|
else:
|
|
text_changed = (row.title != section.get("title")) or (row.content != section.get("content"))
|
|
row.title = section.get("title")
|
|
row.content = section.get("content")
|
|
# Only pay for an embedding when the text actually changed.
|
|
if text_changed or row.embedding is None:
|
|
embedding_service.embed_record(row, "article_section")
|
|
|
|
stale = db.query(ArticleSectionIndex).filter(ArticleSectionIndex.article_id == article.id)
|
|
if keep:
|
|
stale = stale.filter(~ArticleSectionIndex.section_id.in_(keep))
|
|
stale.delete(synchronize_session=False)
|
|
|
|
|
|
@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)
|
|
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.
|
|
ranked, _ = hybrid_ids(db, q.strip(), "article", 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.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)
|
|
_reembed(db, 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("/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("/{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")
|
|
_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
|
|
|
|
|
|
@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 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)
|
|
_reembed(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}", 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 can_edit_article(db, current_user, article):
|
|
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("/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}
|
|
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)
|
|
_reembed(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).all()
|
|
linked = {row[0] for row in db.query(QuestionArticleLink.article_id).distinct().all()}
|
|
|
|
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"),
|
|
},
|
|
# 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],
|
|
}
|