06d7e922bd
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.
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.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}>"
|