"""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""