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:
@@ -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",
|
||||
|
||||
@@ -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}>"
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Paper sentences (论文句子表) — the actual writing.
|
||||
|
||||
One row is one sentence of one paper. The two columns that place it are the
|
||||
whole design:
|
||||
|
||||
``paper_template_filed_sort``
|
||||
The ``sort`` of the template placement this sentence belongs to — *not* a
|
||||
foreign key to ``template_field``. It is deliberately the position rather
|
||||
than the row id, because the position is the only thing two templates can
|
||||
meaningfully have in common. Swap the paper's template and this sentence
|
||||
lands on whatever the new template puts at that position, with no per
|
||||
sentence editing. (The name keeps the spelling the feature was specified
|
||||
with; it reads ``paper_template_field_sort``.)
|
||||
|
||||
``sort``
|
||||
Where this sentence sits *inside* that paragraph. A paragraph is reassembled
|
||||
by reading its rows in ascending ``sort``, which is why the column is
|
||||
required and why the API never leaves it to chance. Values are sparse-
|
||||
friendly — 10, 20, 30 leaves room to insert — and ties are legal, broken by
|
||||
``id`` so the order is always total and stable.
|
||||
|
||||
``template_id`` records the template the sentence was *written against*. It is
|
||||
provenance, not a lookup key: rendering never filters on it, which is exactly
|
||||
why sentences survive a template switch. It is nullable because deleting an old
|
||||
template clears it rather than leaving a dangling reference behind.
|
||||
|
||||
Position in the paper is therefore not stored anywhere as a whole; the document
|
||||
is assembled on read from the paper's template plus these rows. See
|
||||
:func:`app.crud.paper.build_document`.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, Integer, 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 import Paper
|
||||
from app.models.paper_sentence_reference import PaperSentenceReference
|
||||
from app.models.paper_template import PaperTemplate
|
||||
|
||||
|
||||
class PaperSentence(TimestampMixin, Base):
|
||||
"""One sentence, at one position, inside one paragraph of one paper."""
|
||||
|
||||
__tablename__ = "paper_sentence"
|
||||
|
||||
__table_args__ = (
|
||||
# The document query is "every sentence of this paper, in paragraph then
|
||||
# sentence order", so one composite index covers filter and sort both.
|
||||
Index(
|
||||
"ix_paper_sentence_paper_paragraph_sort",
|
||||
"paper_id",
|
||||
"paper_template_filed_sort",
|
||||
"sort",
|
||||
),
|
||||
Index("ix_paper_sentence_template_id", "template_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
paper_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("paper.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
#: The template this sentence was written against — provenance only. Reads
|
||||
#: never filter on it; see the module docstring.
|
||||
template_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("paper_template.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
#: Which paragraph, expressed as the template placement's ``sort``.
|
||||
#: Required: a sentence with no paragraph has nowhere to be rendered.
|
||||
paper_template_filed_sort: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
#: This sentence's position inside that paragraph. Ascending, ties broken
|
||||
#: by ``id``. Required for the same reason.
|
||||
sort: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
#: The sentence itself. May be empty: an empty sentence is a legitimate
|
||||
#: placeholder and renders as a blank line rather than disappearing.
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
|
||||
paper: Mapped["Paper"] = relationship(back_populates="sentences")
|
||||
|
||||
#: References quoted by this sentence, in citation order. Eager-loaded
|
||||
#: because the document render always needs them, and one query per
|
||||
#: sentence would be the difference between two queries and two hundred.
|
||||
citations: Mapped[list["PaperSentenceReference"]] = relationship(
|
||||
back_populates="sentence",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PaperSentenceReference.sort, PaperSentenceReference.id",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
||||
return (
|
||||
f"<PaperSentence id={self.id} paper_id={self.paper_id} "
|
||||
f"paragraph={self.paper_template_filed_sort} sort={self.sort}>"
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Citations attached to a sentence (句子引用关联表).
|
||||
|
||||
A sentence may quote **several** references, which makes this a real
|
||||
many-to-many relation rather than a column on ``paper_sentence`` — hence a
|
||||
table of its own.
|
||||
|
||||
Why there is no foreign key to a reference table
|
||||
------------------------------------------------
|
||||
References are going to be maintained in their own table later. This one
|
||||
therefore stores a plain ``reference_id`` integer and declares **no** foreign
|
||||
key at all: a key to a table that does not exist yet cannot be validated,
|
||||
declared or migrated. The column is nullable so a citation can be written
|
||||
before it has been linked to a reference row.
|
||||
|
||||
What makes a citation valid
|
||||
---------------------------
|
||||
``quote`` — the quoted content — is required and must not be blank. A citation
|
||||
that points at something without saying what it points at is not usable in a
|
||||
document, so the API rejects it (``app.schemas.paper.CitationInput``) rather
|
||||
than storing a dangling half-record. The reverse is fine: a citation may carry
|
||||
its quoted content with ``reference_id`` still empty, and be linked later.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||
from app.models.paper_sentence import PaperSentence
|
||||
|
||||
|
||||
class PaperSentenceReference(Base):
|
||||
"""One citation of one sentence: what is quoted, and optionally by which id."""
|
||||
|
||||
__tablename__ = "paper_sentence_reference"
|
||||
|
||||
__table_args__ = (
|
||||
# Reads are "the citations of this sentence, in order".
|
||||
Index("ix_paper_sentence_reference_sentence_sort", "sentence_id", "sort"),
|
||||
# The reference side is indexed for the lookup that arrives with the
|
||||
# reference table: "which sentences quote reference N".
|
||||
Index("ix_paper_sentence_reference_reference_id", "reference_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
sentence_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("paper_sentence.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
#: The id this citation will resolve to once the reference library exists.
|
||||
#: Deliberately not a foreign key yet — see the module docstring.
|
||||
reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
#: 引用内容 — required, non-blank. The citation's whole payload today.
|
||||
quote: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
#: Order among the citations of one sentence. Assigned from the order the
|
||||
#: client sent them, so ``[1]`` in the text is the first stored row.
|
||||
sort: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
sentence: Mapped["PaperSentence"] = relationship(back_populates="citations")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
||||
return (
|
||||
f"<PaperSentenceReference sentence_id={self.sentence_id} "
|
||||
f"reference_id={self.reference_id} sort={self.sort}>"
|
||||
)
|
||||
Reference in New Issue
Block a user