a5f884f440
Two tables were named after the concept they came from rather than the module
they belong to, so the schema read as if the template tables were part of the
paper module. Renamed (data preserved, `RENAME TABLE` moves rows in place):
paper_template -> template the 模板 module
section_field -> template_field_library the 字段库 the 模板 module owns
The paper tables and `template_field` already followed the rule. The rename
carries through everything that named a module:
models Template, TemplateField, TemplateFieldLibrary
schemas Template*, TemplateFieldLibrary*
crud app/crud/template.py, app/crud/template_field_library.py
API /template-field-library (was /section-fields); handlers are now
named after library entries, which removes the ambiguity with
TemplateField — a placement, a different thing entirely
client src/api/templateFieldLibrary.ts
`paper_template_filed_sort` is deliberately untouched: it is a column of the
paper module, spelled as the feature was specified.
TiDB v8.5 with tidb_enable_foreign_key on — as this cluster runs — enforces
foreign keys rather than ignoring them, so the docs' "TiDB does not enforce
foreign keys" was wrong. Corrected, with what actually follows from it: the
rename was rehearsed (RENAME TABLE carries a referencing constraint along), the
API keeps checking first so a violation names the row instead of surfacing a
driver error, and the ORM cascades stay so behaviour does not depend on a
cluster setting.
Revision f27a1c6d9e04 verified both ways; 40 smoke checks, type-check and build
all pass.
117 lines
4.8 KiB
Python
117 lines
4.8 KiB
Python
"""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.template.Template` 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.template import Template
|
|
|
|
#: 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("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["Template | 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}>"
|