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:
@@ -2,9 +2,10 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import health, section_fields, templates
|
||||
from app.api.routes import health, papers, section_fields, templates
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health.router)
|
||||
api_router.include_router(papers.router)
|
||||
api_router.include_router(section_fields.router)
|
||||
api_router.include_router(templates.router)
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"""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, PaperTemplate
|
||||
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(PaperTemplate, 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)
|
||||
@@ -4,11 +4,17 @@ A template is created from two pieces of free text plus a free selection of
|
||||
library fields. Nothing constrains the selection: the same field may be picked
|
||||
twice, the picks may arrive in any order, and the only thing that decides how
|
||||
the outline reads is ``sort``.
|
||||
|
||||
A template that a paper is written against is also *structure* for that paper,
|
||||
not only configuration: deleting it would leave the paper with nothing to
|
||||
render. That delete is therefore refused, with the same 409 the field library
|
||||
uses for a field still placed in a template.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.crud import paper as paper_crud
|
||||
from app.crud import paper_template as crud
|
||||
from app.db.session import get_db
|
||||
from app.models import PaperTemplate
|
||||
@@ -41,6 +47,32 @@ def _assert_name_free(db: Session, name: str, *, exclude_id: int | None = None)
|
||||
)
|
||||
|
||||
|
||||
def _assert_not_used_by_papers(db: Session, template_ids: list[int]) -> None:
|
||||
"""Refuse to delete templates that papers are written against.
|
||||
|
||||
All offenders are reported at once, so a batch delete does not turn into
|
||||
trial and error. The fix is a template switch on the paper, which is one
|
||||
click in the paper's own view — the message says so.
|
||||
"""
|
||||
counts = paper_crud.template_paper_counts(db, template_ids)
|
||||
if not counts:
|
||||
return
|
||||
|
||||
blockers = []
|
||||
for template_id, count in sorted(counts.items()):
|
||||
template = db.get(PaperTemplate, template_id)
|
||||
name = template.name if template is not None else template_id
|
||||
blockers.append(f"“{name}”({count} 篇论文)")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
"以下模板正被论文使用,请先在论文里切换模板:"
|
||||
+ "、".join(blockers)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _assert_fields_exist(db: Session, field_ids: list[int]) -> None:
|
||||
"""Reject a selection that references fields the library does not have.
|
||||
|
||||
@@ -108,6 +140,7 @@ def batch_delete_templates(
|
||||
db: Session = Depends(get_db),
|
||||
) -> BatchDeleteResult:
|
||||
"""Delete the given templates and all of their placement rows."""
|
||||
_assert_not_used_by_papers(db, list(payload.ids))
|
||||
return BatchDeleteResult(deleted=crud.delete_many(db, payload.ids))
|
||||
|
||||
|
||||
@@ -162,6 +195,13 @@ def update_template(
|
||||
summary="Delete a paper template",
|
||||
)
|
||||
def delete_template(template_id: int, db: Session = Depends(get_db)) -> Response:
|
||||
"""Delete a template. Library fields it referenced are left alone."""
|
||||
crud.delete(db, _get_or_404(db, template_id))
|
||||
"""Delete a template. Library fields it referenced are left alone.
|
||||
|
||||
Refused while a paper is written against the template: the outline is the
|
||||
paper's structure, and removing it would empty the paper rather than tidy
|
||||
up configuration.
|
||||
"""
|
||||
template = _get_or_404(db, template_id)
|
||||
_assert_not_used_by_papers(db, [template_id])
|
||||
crud.delete(db, template)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
Reference in New Issue
Block a user