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
+20
View File
@@ -3,14 +3,34 @@
Importing this package registers every model on ``Base.metadata``, which is
what ``alembic revision --autogenerate`` inspects. A new model therefore has to
be added to the imports below, not only to its own module.
The import order here is for readability only: relationships are declared by
name and resolved through the SQLAlchemy registry after every module has been
imported, so a cycle between two model modules is not a problem.
"""
from app.models.mixins import TimestampMixin
from app.models.paper import (
PAPER_STATUSES,
STATUS_DONE,
STATUS_DRAFT,
STATUS_WRITING,
Paper,
)
from app.models.paper_sentence import PaperSentence
from app.models.paper_sentence_reference import PaperSentenceReference
from app.models.paper_template import PaperTemplate
from app.models.section_field import SectionField
from app.models.template_field import TemplateField
__all__ = [
"PAPER_STATUSES",
"STATUS_DONE",
"STATUS_DRAFT",
"STATUS_WRITING",
"Paper",
"PaperSentence",
"PaperSentenceReference",
"PaperTemplate",
"SectionField",
"TemplateField",