**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
1163 lines
49 KiB
Python
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],
|
|
}
|