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:
@@ -0,0 +1,165 @@
|
||||
"""create paper authoring schema
|
||||
|
||||
Adds the three tables behind the 论文 feature — writing a paper against a
|
||||
template:
|
||||
|
||||
``paper`` the document: title, author, status, keywords,
|
||||
target journal, and the template it is written
|
||||
against.
|
||||
``paper_sentence`` one row per sentence, addressed by the *position*
|
||||
(``paper_template_filed_sort``) of the paragraph it
|
||||
belongs to rather than by a foreign key to a
|
||||
``template_field`` row, plus its own ``sort``
|
||||
inside that paragraph.
|
||||
``paper_sentence_reference`` citations of one sentence, many per sentence.
|
||||
|
||||
Why the paragraph is addressed by position
|
||||
------------------------------------------
|
||||
Two templates have no rows in common; what they can share is a position. A
|
||||
sentence that remembers "I sit at position 7" therefore survives a template
|
||||
swap: it moves to whatever the new template puts at position 7, and a position
|
||||
the new template does not have is still rendered, in order, under an unset
|
||||
heading. Nothing is deleted by a swap and nothing is lost — see
|
||||
``app.crud.paper.build_document``.
|
||||
|
||||
Notes on the foreign keys, as elsewhere in this schema: TiDB parses
|
||||
``FOREIGN KEY`` for compatibility and then ignores it. They are declared to
|
||||
document the relationships and the integrity is enforced in the application
|
||||
layer. ``paper_sentence_reference.reference_id`` deliberately has **no**
|
||||
foreign key: the reference library table does not exist yet.
|
||||
|
||||
Revision ID: c41d7b09e5af
|
||||
Revises: 5030e3939a26
|
||||
Create Date: 2026-09-18
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c41d7b09e5af"
|
||||
down_revision: str | None = "5030e3939a26"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
#: ``CURRENT_TIMESTAMP`` rather than ``now()``: the spelling MySQL and TiDB
|
||||
#: both accept as a DATETIME column default without expression parentheses.
|
||||
_NOW = sa.text("CURRENT_TIMESTAMP")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"paper",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
# Nullable: a paper may exist before it has chosen a template.
|
||||
sa.Column("template_id", sa.Integer(), nullable=True),
|
||||
sa.Column("abstract", sa.Text(), nullable=True),
|
||||
sa.Column("author", sa.String(length=255), nullable=True),
|
||||
# 草稿 / 撰写中 / 已完成, stored as the short token.
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(length=16),
|
||||
server_default=sa.text("'draft'"),
|
||||
nullable=False,
|
||||
),
|
||||
# 关键词 as one comma-separated string.
|
||||
sa.Column("keywords", sa.String(length=255), nullable=True),
|
||||
sa.Column("target_journal", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=_NOW, nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=_NOW, nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["template_id"], ["paper_template.id"], ondelete="RESTRICT"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_paper_template_id", "paper", ["template_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"paper_sentence",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("paper_id", sa.Integer(), nullable=False),
|
||||
# Provenance: the template this sentence was written against. Cleared,
|
||||
# not cascaded, when that template is deleted.
|
||||
sa.Column("template_id", sa.Integer(), nullable=True),
|
||||
# The paragraph, as the template placement's `sort` — not a field id.
|
||||
sa.Column("paper_template_filed_sort", sa.Integer(), nullable=False),
|
||||
# This sentence's position inside that paragraph.
|
||||
sa.Column("sort", sa.Integer(), nullable=False),
|
||||
# TEXT: a "sentence" is whatever the writer treats as one line, and a
|
||||
# line can be long. No server default — MySQL and TiDB cannot default a
|
||||
# TEXT column, and the ORM always supplies a value.
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=_NOW, nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=_NOW, nullable=False),
|
||||
sa.ForeignKeyConstraint(["paper_id"], ["paper.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["template_id"], ["paper_template.id"], ondelete="SET NULL"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
# One index for the document read: every sentence of a paper, in paragraph
|
||||
# then sentence order.
|
||||
op.create_index(
|
||||
"ix_paper_sentence_paper_paragraph_sort",
|
||||
"paper_sentence",
|
||||
["paper_id", "paper_template_filed_sort", "sort"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_paper_sentence_template_id", "paper_sentence", ["template_id"], unique=False
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"paper_sentence_reference",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("sentence_id", sa.Integer(), nullable=False),
|
||||
# No foreign key: the reference library does not exist yet. Plain
|
||||
# integer, nullable, so a citation can be written before it is linked.
|
||||
sa.Column("reference_id", sa.Integer(), nullable=True),
|
||||
# 引用内容 — required. A citation that does not say what it quotes is
|
||||
# rejected by the API.
|
||||
sa.Column("quote", sa.Text(), nullable=False),
|
||||
sa.Column("sort", sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["sentence_id"], ["paper_sentence.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_paper_sentence_reference_sentence_sort",
|
||||
"paper_sentence_reference",
|
||||
["sentence_id", "sort"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_paper_sentence_reference_reference_id",
|
||||
"paper_sentence_reference",
|
||||
["reference_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Reverse dependency order throughout.
|
||||
op.drop_index(
|
||||
"ix_paper_sentence_reference_reference_id",
|
||||
table_name="paper_sentence_reference",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_paper_sentence_reference_sentence_sort",
|
||||
table_name="paper_sentence_reference",
|
||||
)
|
||||
op.drop_table("paper_sentence_reference")
|
||||
|
||||
op.drop_index("ix_paper_sentence_template_id", table_name="paper_sentence")
|
||||
op.drop_index(
|
||||
"ix_paper_sentence_paper_paragraph_sort", table_name="paper_sentence"
|
||||
)
|
||||
op.drop_table("paper_sentence")
|
||||
|
||||
op.drop_index("ix_paper_template_id", table_name="paper")
|
||||
op.drop_table("paper")
|
||||
Reference in New Issue
Block a user