45926af966
A template's abstract paragraph (`0 Abstract`) *is* the paper's abstract: it has a position in the document, the heading and typography the template gives it, and the same sentence-by-sentence editing as everything else. `paper.abstract` was a second home for that text — one the document never reads, so a paper could show two different abstracts and the column could drift from the body. The column is gone, from the table, the model, the schemas, the API payloads, the paper form, the list subtitle and the paper page. Nothing else changed. The text already written into it is not gone. Revision a83f5c21d7b6 writes each stored abstract into the paper's body first — one sentence per 。 at the paragraph carrying the abstract heading, appended after anything already there rather than replacing it. A template without such a heading keeps the text too, one position above its first paragraph, where the document renders it under 未设定. Verified against the one paper that had an abstract: 450 characters in, 450 out, identical including `|J| ≤ α · I⁻ᵝ`, split into six sentences in the `0 Abstract` paragraph, still with its own edit button. The smoke test no longer assumes an empty database: it records the paper total before it starts and compares against that, so it can run on a real one.
120 lines
5.1 KiB
Python
120 lines
5.1 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
|
|
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,
|
|
)
|
|
|
|
#: 作者. 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)
|
|
|
|
#: There is deliberately no ``abstract`` column. A paper's abstract is a
|
|
#: paragraph of its body — the template's ``0 Abstract`` field — so it has a
|
|
#: position in the document, the template's own heading, and the same
|
|
#: sentence-by-sentence editing as everything else. A column here would be a
|
|
#: second home for the same text, and the document never reads it.
|
|
|
|
#: 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}>"
|