pdf-quiz-generator/backend/app/services/article_service.py
Daniel aafea65a52 feat: delete an article, and a trash for the ones that were published
There was no way to delete an article from anywhere in the interface. The API
had one; the only route to it was curl.

Now there is a control at the foot of the editor, and it does one of two things
depending on the article's history — and says which before it is pressed:

* A draft that was **never published** is deleted outright. There is nothing to
  restore, and a trash full of abandoned stubs is a second list to maintain.
* Anything that has been published, even once, is **marked** and appears in the
  trash on Editorial, restorable exactly as it was. Somewhere there is a
  learner's note against one of its sections, a question linked to it, and a
  link somebody sent a colleague; a DELETE typed in the afternoon should not
  settle any of that.

`first_published_at` is what decides, stamped on the first publish and never
cleared — unpublishing does not make an article unseen, so it does not make
deleting it safe either. Backfilled from `reviewed_at` for everything currently
published, because an article with a null stamp reads to the rule as a
never-published draft.

A binned article is out of the listing, the editorial queue, every slug and id
lookup, and — immediately — the search index, so it cannot still answer a
learner's question from the trash.

Also on Editorial, because a hundred rows is a queue you work through and not a
page you scroll past on the way to the next queue: each bucket keeps its own
box, its own scrollbar and its own filter.

And the editor finally has a way out that is not Save: Back and Discard, with
an inline confirmation when there are unsaved changes. The way out was the
browser's back button, which throws the sitting away without saying so.

Migration j0a1b2c3d4e5.

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

285 lines
12 KiB
Python

"""Article editing rules: variants, revisions, slugs and cross-reference markers.
Three things live here because they are decisions rather than plumbing, and each
one has a failure mode worth naming.
**Variants.** One article is read three ways — short, long, and the clinical view
that carries management and doses. They are one article rather
than three because they describe one condition: split into three rows they drift
apart, and a question linked to "Bronchiolitis" would have to pick which of the
three it meant. Each section carries the variant it belongs to.
**Slugs.** A cross-reference written today has to survive a rename tomorrow, so
every slug an article has ever had is kept and old ones redirect. Without this a
rename silently breaks every article pointing at the old name, and the person who
renamed it never finds out.
**Markers.** `[[7|Febrile seizures]]` resolves by id and displays the text. The
id is what makes it rename-proof; the text is what makes the prose readable while
you are writing it.
"""
import logging
import re
from datetime import datetime
from sqlalchemy.orm import Session
from app.models.article import (
Article, ArticleRevision, ArticleSectionIndex, ArticleSlug,
)
from app.services import embedding_service
logger = logging.getLogger(__name__)
# Short first: it is the fastest way to tell whether this is the article you
# wanted. Long is one click away and is what an article written before variants
# existed becomes, since that is what it was.
VARIANTS = ("short", "long", "clinical")
DEFAULT_VARIANT = "long"
# [[7|Febrile seizures]] — id first, because the id is the part that must not
# change. [[febrile-seizures]] is the older slug form and still resolves.
MARKER_RE = re.compile(r"\[\[(?:(\d+)\|([^\]]+)|([a-z0-9][a-z0-9-]*))\]\]")
STATUSES = ("draft", "in_review", "published")
def normalise_sections(sections: list[dict]) -> list[dict]:
"""Stamp every section with a variant, defaulting to the full article.
Articles written before variants existed have no such field; treating them
as the long version is what keeps them rendering unchanged.
"""
out = []
for section in sections:
row = dict(section)
if row.get("variant") not in VARIANTS:
row["variant"] = DEFAULT_VARIANT
out.append(row)
return out
def sections_for(article: Article, variant: str) -> list[dict]:
"""The sections of one view, in order."""
variant = variant if variant in VARIANTS else DEFAULT_VARIANT
return [s for s in normalise_sections(article.sections or [])
if s.get("variant") == variant]
def section_title(article: Article, section_id: str | None) -> str | None:
"""The name of the section a link points at, or None for a whole-article link.
A link whose section has since been deleted reads as a whole-article link
rather than as nothing: the article is still the right reading, only the
part of it is gone.
"""
if not section_id:
return None
for section in article.sections or []:
if section.get("id") == section_id:
return section.get("title") or section.get("slug")
return None
def available_variants(article: Article) -> list[str]:
"""Which views this article actually has, so the toggle offers no empty one."""
present = {s.get("variant") for s in normalise_sections(article.sections or [])}
return [v for v in VARIANTS if v in present]
def record_slug(db: Session, article: Article) -> None:
"""Remember the article's current slug, so it keeps resolving after a rename."""
existing = db.query(ArticleSlug).filter(ArticleSlug.slug == article.slug).first()
if existing is None:
db.add(ArticleSlug(slug=article.slug, article_id=article.id))
elif existing.article_id != article.id:
# Another article now owns this name. The newest claim wins, because the
# alternative is a redirect that sends readers to the wrong condition.
existing.article_id = article.id
def resolve_slug(db: Session, slug: str) -> Article | None:
"""Find an article by numeric id, by its current slug, or by an old one.
Cross-references in prose are written `[[403|urethritis]]` — by id, because
an id survives a rename and a slug does not. So whatever resolves a
reference has to accept both: looking only at slugs made every one of those
links dead, with no preview and a 404 behind it.
"""
reference = (slug or "").strip().lower()
if not reference:
return None
def alive(article):
return article if article is not None and article.deleted_at is None else None
if reference.isdigit():
return alive(db.get(Article, int(reference)))
article = db.query(Article).filter(Article.slug == reference,
Article.deleted_at.is_(None)).first()
if article:
return article
historical = db.query(ArticleSlug).filter(ArticleSlug.slug == reference).first()
return alive(db.get(Article, historical.article_id)) if historical else None
def snapshot(db: Session, article: Article, user_id: int | None, note: str | None = None) -> None:
"""Keep what the article was, before it becomes something else."""
db.add(ArticleRevision(
article_id=article.id, title=article.title, summary=article.summary,
content=article.content, sections=article.sections or [],
references_json=article.references_json, status=article.status,
note=(note or "")[:200] or None, created_by=user_id,
))
def marker_targets(text: str) -> tuple[set[int], set[str]]:
"""The article ids and legacy slugs a piece of prose points at."""
ids, slugs = set(), set()
for match in MARKER_RE.finditer(text or ""):
if match.group(1):
ids.add(int(match.group(1)))
elif match.group(3):
slugs.add(match.group(3))
return ids, slugs
def broken_markers(db: Session, article: Article) -> list[str]:
"""Markers in this article that point at nothing.
Checked when an article is saved, because that is the last moment the person
who wrote the link is still looking at it. A dead cross-reference found a
week later belongs to nobody.
"""
body = " ".join(filter(None, [
article.content or "",
*[(s.get("content") or "") for s in (article.sections or [])],
]))
ids, slugs = marker_targets(body)
broken = []
if ids:
known = {row[0] for row in db.query(Article.id).filter(Article.id.in_(ids)).all()}
broken.extend(f"[[{missing}|…]]" for missing in sorted(ids - known))
for slug in sorted(slugs):
if resolve_slug(db, slug) is None:
broken.append(f"[[{slug}]]")
return broken
def upgrade_markers(db: Session, text: str) -> str:
"""Rewrite legacy slug markers to the id form, keeping the title as the label.
Done on save rather than in a migration: an article nobody has touched is not
broken, and rewriting prose that no one asked to change is how you lose an
author's trust in the editor.
"""
def replace(match: re.Match) -> str:
if match.group(1):
return match.group(0)
slug = match.group(3)
article = resolve_slug(db, slug)
return f"[[{article.id}|{article.title}]]" if article else match.group(0)
return MARKER_RE.sub(replace, text or "")
def set_status(db: Session, article: Article, status: str, user_id: int | None) -> None:
"""Move an article through draft → in_review → published."""
if status not in STATUSES:
raise ValueError(f"Unknown status: {status}")
now = datetime.utcnow()
if status == "in_review" and article.status != "in_review":
article.submitted_at = now
if status == "published" and article.status != "published":
article.reviewed_at = now
article.reviewed_by = user_id
article.status = status
def readable_articles(db, user):
"""A query over the articles this person may read.
Published, plus their own drafts, plus everything if they moderate. The
rule was written out inline at every place that needed it, which is how a
draft ends up reachable from one route and not another.
"""
from app.models.article import Article
# Never the trash, for anybody — including a moderator. Restoring is done
# from the trash list, which is one place, rather than from wherever an
# article happens to still be referenced.
query = db.query(Article).filter(Article.deleted_at.is_(None))
if getattr(user, "is_moderator", False):
return query
return query.filter((Article.status == "published") | (Article.user_id == user.id))
def rebuild_section_index(db: Session, article: Article) -> int:
"""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, and it is the only place the body is embedded
in full — the article's own vector has room for excerpts, not for 8k
characters of prose.
Rows are keyed by section id, so editing a section updates it and removing
one deletes it. Returns how many vectors were generated, which is what a
bulk caller needs in order to report cost.
Lives here rather than beside the route that first needed it because
anything that writes `Article.sections` has to call it; a copy that skipped
it left 323 generated articles with no section rows at all.
"""
# Only what a reader can reach. A draft is unfinished by definition, and an
# index that carries it puts half-written prose into search results and
# into the shortlist the assistant answers from. Its rows go when it is
# unpublished, and come back when it is published again — this function is
# called on every save, so the index follows the status rather than
# needing to be told about it separately.
# A binned article is not readable whatever its status says, so it comes out
# of the index on the same rule as an unpublished one.
if (article.status or "") != "published" or getattr(article, "deleted_at", None) is not None:
db.query(ArticleSectionIndex).filter(
ArticleSectionIndex.article_id == article.id).delete(synchronize_session=False)
return 0
pending, keep = [], set()
for section in article.sections or []:
if not isinstance(section, dict):
continue
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:
pending.append(row)
embedded = embedding_service.embed_records(pending, "article_section") if pending else 0
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)
return embedded
def reindex(db: Session, article: 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()
logger.warning("Could not embed article %s; leaving it for the retry task",
article.id, exc_info=True)