06d7e922bd
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.
208 lines
7.0 KiB
Python
208 lines
7.0 KiB
Python
"""Paper-template endpoints (模板管理).
|
|
|
|
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
|
|
from app.schemas.common import BatchDeleteRequest, BatchDeleteResult, PageResult
|
|
from app.schemas.paper_template import (
|
|
PaperTemplateCreate,
|
|
PaperTemplateListItem,
|
|
PaperTemplateRead,
|
|
PaperTemplateUpdate,
|
|
)
|
|
|
|
router = APIRouter(prefix="/templates", tags=["templates"])
|
|
|
|
|
|
def _get_or_404(db: Session, template_id: int) -> PaperTemplate:
|
|
template = crud.get(db, template_id)
|
|
if template is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"模板 {template_id} 不存在",
|
|
)
|
|
return template
|
|
|
|
|
|
def _assert_name_free(db: Session, name: str, *, exclude_id: int | None = None) -> None:
|
|
if crud.name_taken(db, name, exclude_id=exclude_id):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"模板名称“{name}”已存在",
|
|
)
|
|
|
|
|
|
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.
|
|
|
|
Checked in Python rather than by a foreign key because TiDB parses but does
|
|
not enforce ``FOREIGN KEY``, so an unchecked write would happily leave a
|
|
template pointing at nothing.
|
|
"""
|
|
missing = crud.missing_field_ids(db, field_ids)
|
|
if missing:
|
|
joined = "、".join(str(field_id) for field_id in missing)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"字段不存在:{joined}",
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"",
|
|
response_model=PageResult[PaperTemplateListItem],
|
|
summary="List paper templates",
|
|
)
|
|
def list_templates(
|
|
db: Session = Depends(get_db),
|
|
keyword: str | None = Query(default=None, description="按模板名称或摘要模糊搜索"),
|
|
page: int = Query(default=1, ge=1),
|
|
page_size: int = Query(default=20, ge=1, le=200),
|
|
) -> PageResult[PaperTemplateListItem]:
|
|
"""Browse templates, most recently edited first."""
|
|
items, total = crud.list_templates(
|
|
db, keyword=keyword, page=page, page_size=page_size
|
|
)
|
|
return PageResult.build(items=items, total=total, page=page, page_size=page_size)
|
|
|
|
|
|
@router.post(
|
|
"",
|
|
response_model=PaperTemplateRead,
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Create a paper template",
|
|
)
|
|
def create_template(
|
|
payload: PaperTemplateCreate,
|
|
db: Session = Depends(get_db),
|
|
) -> PaperTemplateRead:
|
|
"""Create a template from a name, an abstract, and a field selection."""
|
|
_assert_name_free(db, payload.name)
|
|
_assert_fields_exist(db, [item.field_id for item in payload.fields])
|
|
|
|
template = crud.create(
|
|
db,
|
|
name=payload.name,
|
|
abstract=payload.abstract,
|
|
fields=payload.fields,
|
|
)
|
|
return PaperTemplateRead.from_model(template)
|
|
|
|
|
|
@router.post(
|
|
"/batch-delete",
|
|
response_model=BatchDeleteResult,
|
|
summary="Delete several paper templates",
|
|
)
|
|
def batch_delete_templates(
|
|
payload: BatchDeleteRequest,
|
|
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))
|
|
|
|
|
|
@router.get(
|
|
"/{template_id}",
|
|
response_model=PaperTemplateRead,
|
|
summary="Fetch one paper template with its outline",
|
|
)
|
|
def get_template(template_id: int, db: Session = Depends(get_db)) -> PaperTemplateRead:
|
|
"""Return a template; ``fields`` arrives already ordered by ``sort``."""
|
|
return PaperTemplateRead.from_model(_get_or_404(db, template_id))
|
|
|
|
|
|
@router.patch(
|
|
"/{template_id}",
|
|
response_model=PaperTemplateRead,
|
|
summary="Update a paper template",
|
|
)
|
|
def update_template(
|
|
template_id: int,
|
|
payload: PaperTemplateUpdate,
|
|
db: Session = Depends(get_db),
|
|
) -> PaperTemplateRead:
|
|
"""Update the name, the abstract, the field selection, or any combination.
|
|
|
|
``fields`` is a full replacement when present. Omitting it leaves the
|
|
outline untouched; sending ``[]`` clears it.
|
|
"""
|
|
template = _get_or_404(db, template_id)
|
|
|
|
if payload.name is not None:
|
|
_assert_name_free(db, payload.name, exclude_id=template_id)
|
|
if payload.fields is not None:
|
|
_assert_fields_exist(db, [item.field_id for item in payload.fields])
|
|
|
|
updated = crud.update(
|
|
db,
|
|
template,
|
|
name=payload.name,
|
|
abstract=payload.abstract,
|
|
# Both "field omitted" and "field set to null" arrive as None; only
|
|
# model_fields_set records which one the client actually sent.
|
|
abstract_provided="abstract" in payload.model_fields_set,
|
|
fields=payload.fields,
|
|
)
|
|
return PaperTemplateRead.from_model(updated)
|
|
|
|
|
|
@router.delete(
|
|
"/{template_id}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
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.
|
|
|
|
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)
|