backend: add the paper authoring schema, API, and smoke test

A paper is a document written against a template. Its structure is never
copied into it: `paper` points at a template and the outline is read live on
every render, so switching `template_id` re-shapes the whole document in one
write.

Sentences are addressed by *position* rather than by a template row id:
`paper_sentence.paper_template_filed_sort` holds the sort of the placement the
sentence belongs to, and `sort` holds its place inside that paragraph. That
indirection is what makes a template switch non-destructive — a sentence that
remembers "position 7" lands on whatever the new template puts at position 7 —
and it is why a paragraph is any position either the template or the content
mentions: the structure survives with no content, and content whose position
the template does not define is still rendered, in order, under 未设定.

Citations are a table rather than a column, since one sentence may quote
several references. `quote` is required — a citation that does not say what it
quotes is refused with 422 — while `reference_id` is a plain nullable integer
with no foreign key, because the reference library does not exist yet.

Deleting a template a paper is written against is refused with 409 and a count,
matching how the field library refuses to drop a field still in use.

scripts/smoke_papers.py walks the whole loop — create, empty structure, write a
paragraph with citations, switch templates, keep unmatched positions, move a
paragraph, delete — in 40 checks, and cleans up after itself.
This commit is contained in:
2026-09-18 17:29:12 +08:00
parent 0d0aa20be2
commit 06d7e922bd
13 changed files with 2173 additions and 5 deletions
+42 -2
View File
@@ -4,11 +4,17 @@ A template is created from two pieces of free text plus a free selection of
library fields. Nothing constrains the selection: the same field may be picked
twice, the picks may arrive in any order, and the only thing that decides how
the outline reads is ``sort``.
A template that a paper is written against is also *structure* for that paper,
not only configuration: deleting it would leave the paper with nothing to
render. That delete is therefore refused, with the same 409 the field library
uses for a field still placed in a template.
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from app.crud import paper as paper_crud
from app.crud import paper_template as crud
from app.db.session import get_db
from app.models import PaperTemplate
@@ -41,6 +47,32 @@ def _assert_name_free(db: Session, name: str, *, exclude_id: int | None = None)
)
def _assert_not_used_by_papers(db: Session, template_ids: list[int]) -> None:
"""Refuse to delete templates that papers are written against.
All offenders are reported at once, so a batch delete does not turn into
trial and error. The fix is a template switch on the paper, which is one
click in the paper's own view — the message says so.
"""
counts = paper_crud.template_paper_counts(db, template_ids)
if not counts:
return
blockers = []
for template_id, count in sorted(counts.items()):
template = db.get(PaperTemplate, template_id)
name = template.name if template is not None else template_id
blockers.append(f"{name}”({count} 篇论文)")
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"以下模板正被论文使用,请先在论文里切换模板:"
+ "".join(blockers)
),
)
def _assert_fields_exist(db: Session, field_ids: list[int]) -> None:
"""Reject a selection that references fields the library does not have.
@@ -108,6 +140,7 @@ def batch_delete_templates(
db: Session = Depends(get_db),
) -> BatchDeleteResult:
"""Delete the given templates and all of their placement rows."""
_assert_not_used_by_papers(db, list(payload.ids))
return BatchDeleteResult(deleted=crud.delete_many(db, payload.ids))
@@ -162,6 +195,13 @@ def update_template(
summary="Delete a paper template",
)
def delete_template(template_id: int, db: Session = Depends(get_db)) -> Response:
"""Delete a template. Library fields it referenced are left alone."""
crud.delete(db, _get_or_404(db, template_id))
"""Delete a template. Library fields it referenced are left alone.
Refused while a paper is written against the template: the outline is the
paper's structure, and removing it would empty the paper rather than tidy
up configuration.
"""
template = _get_or_404(db, template_id)
_assert_not_used_by_papers(db, [template_id])
crud.delete(db, template)
return Response(status_code=status.HTTP_204_NO_CONTENT)