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.
305 lines
10 KiB
Python
305 lines
10 KiB
Python
"""Paper endpoints (论文) — the writing surface of the application.
|
|
|
|
The shape of the API follows the shape of the work:
|
|
|
|
* ``/papers`` is the library: create, list, edit, delete.
|
|
* ``/papers/{id}/document`` is the paper as it reads — the template's
|
|
paragraphs in order, each with the sentences stored at its position. It is
|
|
one request, because a client should never have to stitch the structure and
|
|
the content together and risk ordering them differently than the server does.
|
|
* ``/papers/{id}/paragraphs/{sort}`` is the writer's unit of work: read one
|
|
paragraph, write it back whole.
|
|
|
|
Two rules are enforced here rather than in the schema, because they reference
|
|
rows in other tables: a paper may only point at a template that exists, and a
|
|
template may not be deleted while a paper is written against it.
|
|
"""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.crud import paper as crud
|
|
from app.db.session import get_db
|
|
from app.models import Paper, PaperSentence, Template
|
|
from app.schemas.common import BatchDeleteRequest, BatchDeleteResult, PageResult
|
|
from app.schemas.paper import (
|
|
PaperCreate,
|
|
PaperDocumentRead,
|
|
PaperListItem,
|
|
PaperRead,
|
|
PaperUpdate,
|
|
ParagraphListRead,
|
|
ParagraphUpdate,
|
|
SentenceCreate,
|
|
SentenceRead,
|
|
SentenceUpdate,
|
|
)
|
|
|
|
router = APIRouter(prefix="/papers", tags=["papers"])
|
|
|
|
|
|
def _get_or_404(db: Session, paper_id: int) -> Paper:
|
|
paper = crud.get(db, paper_id)
|
|
if paper is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"论文 {paper_id} 不存在",
|
|
)
|
|
return paper
|
|
|
|
|
|
def _get_sentence_or_404(db: Session, paper: Paper, sentence_id: int) -> PaperSentence:
|
|
"""Find a sentence *within* this paper.
|
|
|
|
Scoping the lookup to the paper is what stops a sentence id from one paper
|
|
being edited through another paper's URL.
|
|
"""
|
|
for sentence in paper.sentences:
|
|
if sentence.id == sentence_id:
|
|
return sentence
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"句子 {sentence_id} 不属于论文 {paper.id}",
|
|
)
|
|
|
|
|
|
def _assert_template_exists(db: Session, template_id: int | None) -> None:
|
|
"""Reject a paper pointing at a template that is not there.
|
|
|
|
Checked in Python rather than by a foreign key, because TiDB parses but
|
|
does not enforce ``FOREIGN KEY`` — an unchecked write would happily leave a
|
|
paper with no structure and no way to notice.
|
|
"""
|
|
if template_id is None:
|
|
return
|
|
if db.get(Template, template_id) is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"模板 {template_id} 不存在",
|
|
)
|
|
|
|
|
|
@router.get("", response_model=PageResult[PaperListItem], summary="List papers")
|
|
def list_papers(
|
|
db: Session = Depends(get_db),
|
|
keyword: str | None = Query(default=None, description="按标题、作者或关键词模糊搜索"),
|
|
status_filter: str | None = Query(
|
|
default=None,
|
|
alias="status",
|
|
description="按状态过滤:draft / writing / done",
|
|
),
|
|
template_id: int | None = Query(default=None, description="按所用模板过滤"),
|
|
page: int = Query(default=1, ge=1),
|
|
page_size: int = Query(default=20, ge=1, le=200),
|
|
) -> PageResult[PaperListItem]:
|
|
"""Browse papers, most recently edited first."""
|
|
items, total = crud.list_papers(
|
|
db,
|
|
keyword=keyword,
|
|
status=status_filter,
|
|
template_id=template_id,
|
|
page=page,
|
|
page_size=page_size,
|
|
)
|
|
return PageResult.build(items=items, total=total, page=page, page_size=page_size)
|
|
|
|
|
|
@router.post(
|
|
"",
|
|
response_model=PaperRead,
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Create a paper",
|
|
)
|
|
def create_paper(payload: PaperCreate, db: Session = Depends(get_db)) -> PaperRead:
|
|
"""Create a paper against a template.
|
|
|
|
Nothing is written into the sentence table: the outline is the template's,
|
|
read live on every render, so a new paper is already the right shape with
|
|
every paragraph empty.
|
|
"""
|
|
_assert_template_exists(db, payload.template_id)
|
|
return crud.read(crud.create(db, payload))
|
|
|
|
|
|
@router.post(
|
|
"/batch-delete",
|
|
response_model=BatchDeleteResult,
|
|
summary="Delete several papers",
|
|
)
|
|
def batch_delete_papers(
|
|
payload: BatchDeleteRequest,
|
|
db: Session = Depends(get_db),
|
|
) -> BatchDeleteResult:
|
|
"""Delete the given papers, with all of their sentences and citations."""
|
|
return BatchDeleteResult(deleted=crud.delete_many(db, payload.ids))
|
|
|
|
|
|
@router.get("/{paper_id}", response_model=PaperRead, summary="Fetch one paper")
|
|
def get_paper(paper_id: int, db: Session = Depends(get_db)) -> PaperRead:
|
|
"""Return a paper's metadata and its written/defined paragraph counts."""
|
|
return crud.read(_get_or_404(db, paper_id))
|
|
|
|
|
|
@router.get(
|
|
"/{paper_id}/document",
|
|
response_model=PaperDocumentRead,
|
|
summary="Fetch one paper as a document",
|
|
)
|
|
def get_paper_document(
|
|
paper_id: int, db: Session = Depends(get_db)
|
|
) -> PaperDocumentRead:
|
|
"""Return the whole paper: headings, sentences, citations, warnings.
|
|
|
|
Paragraphs arrive in ascending position order. Positions the template does
|
|
not define are included when content exists there, with ``matched: false``
|
|
and no heading — the client renders those as 未设定 rather than dropping
|
|
them.
|
|
"""
|
|
return crud.build_document(db, _get_or_404(db, paper_id))
|
|
|
|
|
|
@router.patch("/{paper_id}", response_model=PaperRead, summary="Update a paper")
|
|
def update_paper(
|
|
paper_id: int,
|
|
payload: PaperUpdate,
|
|
db: Session = Depends(get_db),
|
|
) -> PaperRead:
|
|
"""Update the paper's metadata, including which template it uses.
|
|
|
|
Changing ``template_id`` is the template switch. Sentences keep their
|
|
positions and gain the new template id, so the same content reappears under
|
|
the new template's headings — and switching back restores the old layout
|
|
exactly.
|
|
"""
|
|
paper = _get_or_404(db, paper_id)
|
|
|
|
if "template_id" in payload.model_fields_set:
|
|
_assert_template_exists(db, payload.template_id)
|
|
|
|
# Only the keys the client actually sent are applied: an omitted field must
|
|
# not be mistaken for an explicit null.
|
|
values = payload.model_dump(exclude_unset=True)
|
|
template_changed = (
|
|
"template_id" in values and values["template_id"] != paper.template_id
|
|
)
|
|
|
|
return crud.read(crud.update(db, paper, values=values, template_changed=template_changed))
|
|
|
|
|
|
@router.delete(
|
|
"/{paper_id}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
summary="Delete a paper",
|
|
)
|
|
def delete_paper(paper_id: int, db: Session = Depends(get_db)) -> Response:
|
|
"""Delete a paper with its sentences and citations."""
|
|
crud.delete(db, _get_or_404(db, paper_id))
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
@router.get(
|
|
"/{paper_id}/paragraphs/{field_sort}",
|
|
response_model=ParagraphListRead,
|
|
summary="Read one paragraph",
|
|
)
|
|
def get_paragraph(
|
|
paper_id: int,
|
|
field_sort: int,
|
|
db: Session = Depends(get_db),
|
|
) -> ParagraphListRead:
|
|
"""Return one paragraph, assembled exactly as the document renders it.
|
|
|
|
A position that neither the template nor the content knows about is a 404:
|
|
there is no paragraph there to edit.
|
|
"""
|
|
paper = _get_or_404(db, paper_id)
|
|
paragraph = crud.get_paragraph(db, paper, field_sort)
|
|
if paragraph is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"论文 {paper_id} 没有第 {field_sort} 段",
|
|
)
|
|
return ParagraphListRead(
|
|
paper_id=paper.id,
|
|
paper_title=paper.title,
|
|
template_id=paper.template_id,
|
|
paragraph=paragraph,
|
|
)
|
|
|
|
|
|
@router.put(
|
|
"/{paper_id}/paragraphs/{field_sort}",
|
|
response_model=PaperDocumentRead,
|
|
summary="Replace one paragraph",
|
|
)
|
|
def replace_paragraph(
|
|
paper_id: int,
|
|
field_sort: int,
|
|
payload: ParagraphUpdate,
|
|
db: Session = Depends(get_db),
|
|
) -> PaperDocumentRead:
|
|
"""Write a paragraph whole, and return the refreshed document.
|
|
|
|
The whole document comes back so the reader view updates in place: after a
|
|
move, the paragraph the editor had open no longer exists at that position,
|
|
and re-deriving the page from a fresh document is simpler than patching a
|
|
stale copy.
|
|
"""
|
|
paper = _get_or_404(db, paper_id)
|
|
crud.replace_paragraph(db, paper, field_sort, payload)
|
|
return crud.build_document(db, paper)
|
|
|
|
|
|
@router.post(
|
|
"/{paper_id}/sentences",
|
|
response_model=SentenceRead,
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Append one sentence",
|
|
)
|
|
def create_sentence(
|
|
paper_id: int,
|
|
payload: SentenceCreate,
|
|
db: Session = Depends(get_db),
|
|
) -> SentenceRead:
|
|
"""Add a single sentence to a paragraph, at the end unless told otherwise.
|
|
|
|
The paragraph does not have to exist in the template: a position the
|
|
template does not define is exactly the case the renderer already handles.
|
|
"""
|
|
paper = _get_or_404(db, paper_id)
|
|
return SentenceRead.model_validate(crud.append_sentence(db, paper, payload))
|
|
|
|
|
|
@router.patch(
|
|
"/{paper_id}/sentences/{sentence_id}",
|
|
response_model=SentenceRead,
|
|
summary="Update one sentence",
|
|
)
|
|
def update_sentence(
|
|
paper_id: int,
|
|
sentence_id: int,
|
|
payload: SentenceUpdate,
|
|
db: Session = Depends(get_db),
|
|
) -> SentenceRead:
|
|
"""Edit one sentence's text, its position, or its citations."""
|
|
paper = _get_or_404(db, paper_id)
|
|
sentence = _get_sentence_or_404(db, paper, sentence_id)
|
|
updated = crud.update_sentence(db, paper, sentence, payload)
|
|
return SentenceRead.model_validate(updated)
|
|
|
|
|
|
@router.delete(
|
|
"/{paper_id}/sentences/{sentence_id}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
summary="Delete one sentence",
|
|
)
|
|
def delete_sentence(
|
|
paper_id: int,
|
|
sentence_id: int,
|
|
db: Session = Depends(get_db),
|
|
) -> Response:
|
|
"""Delete one sentence and the citations attached to it."""
|
|
paper = _get_or_404(db, paper_id)
|
|
crud.delete_sentence(db, paper, _get_sentence_or_404(db, paper, sentence_id))
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|