"""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], }