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
+116
View File
@@ -0,0 +1,116 @@
"""Papers (论文表) — the document this tool exists to produce.
A paper is a bag of metadata plus two things that are deliberately kept apart:
* its **structure**, which is not stored here at all. Every render reads the
:class:`~app.models.paper_template.PaperTemplate` the paper points at, live.
Nothing is copied, so switching ``template_id`` re-shapes the whole document
in one write.
* its **content**, which lives in
:class:`~app.models.paper_sentence.PaperSentence` — one row per sentence,
addressed by the paragraph's ``sort`` inside that template rather than by a
foreign key to a ``template_field`` row.
That second choice is what makes the promised workflow work. Because a sentence
remembers "I belong at position 7", not "I belong to placement #42", swapping
the template moves every sentence to whatever the new template has at position
7. Sentences whose position the new template does not have are not lost and not
hidden: they are still rendered in position order, under an *unset* heading.
See :func:`app.crud.paper.build_document`.
``status`` is one of :data:`PAPER_STATUSES`. It is stored as the short token
(``draft``), not as the Chinese label, so the wording in the UI can change
without a migration.
"""
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.mixins import TimestampMixin
if TYPE_CHECKING: # pragma: no cover - typing only
from app.models.paper_sentence import PaperSentence
from app.models.paper_template import PaperTemplate
#: Writing state of a paper: 草稿 / 撰写中 / 已完成.
#:
#: Exactly the values a client may send; the API validates against this tuple,
#: so adding a state is a one-line change here plus one label in the UI.
PAPER_STATUSES: tuple[str, ...] = ("draft", "writing", "done")
STATUS_DRAFT = "draft"
STATUS_WRITING = "writing"
STATUS_DONE = "done"
class Paper(TimestampMixin, Base):
"""One paper: a working title, who is writing it, and where it is going."""
__tablename__ = "paper"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
#: Working title. Not unique: two papers may legitimately share a title
#: while one is being split off the other.
title: Mapped[str] = mapped_column(String(255), nullable=False)
#: The template this paper is currently written against, or ``None`` for a
#: paper that has not chosen one yet. Nullable only for robustness — the
#: UI asks for a template at creation time — and a paper without one simply
#: renders its sentences under unset headings.
#:
#: ``RESTRICT`` documents the intent (a template in use must not vanish);
#: TiDB does not enforce it, so the API refuses the template delete too.
template_id: Mapped[int | None] = mapped_column(
ForeignKey("paper_template.id", ondelete="RESTRICT"),
nullable=True,
)
#: 摘要 — the paper's own abstract, free text.
abstract: Mapped[str | None] = mapped_column(Text, nullable=True)
#: 作者. Free text, since authorship is written as it will be printed.
author: Mapped[str | None] = mapped_column(String(255), nullable=True)
#: One of :data:`PAPER_STATUSES`.
status: Mapped[str] = mapped_column(
String(16),
nullable=False,
default=STATUS_DRAFT,
server_default=text("'draft'"),
)
#: 关键词, stored as one comma-separated string. A list would need a table
#: of its own for something that is displayed as a row of tags and searched
#: as text; the split/join happens at the edges.
keywords: Mapped[str | None] = mapped_column(String(255), nullable=True)
#: 投稿目标期刊.
target_journal: Mapped[str | None] = mapped_column(String(255), nullable=True)
#: Eager-loaded with the paper: every read of a paper shows its template
#: name, and a lazy load there would be one query per row in the table.
template: Mapped["PaperTemplate | None"] = relationship(lazy="joined")
#: The paper's sentences, in document order.
#:
#: ``delete-orphan`` is doing real work: TiDB parses but does not enforce
#: ``ON DELETE CASCADE``, so removing a deleted paper's sentences is the
#: ORM's job. The ordering mirrors the render order exactly — paragraph
#: position first, then position inside the paragraph, then insertion.
sentences: Mapped[list["PaperSentence"]] = relationship(
back_populates="paper",
cascade="all, delete-orphan",
order_by=(
"PaperSentence.paper_template_filed_sort, "
"PaperSentence.sort, "
"PaperSentence.id"
),
lazy="selectin",
)
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"<Paper id={self.id} title={self.title!r} status={self.status}>"