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.
208 lines
6.9 KiB
Python
208 lines
6.9 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 template as crud
|
|
from app.db.session import get_db
|
|
from app.models import Template
|
|
from app.schemas.common import BatchDeleteRequest, BatchDeleteResult, PageResult
|
|
from app.schemas.template import (
|
|
TemplateCreate,
|
|
TemplateListItem,
|
|
TemplateRead,
|
|
TemplateUpdate,
|
|
)
|
|
|
|
router = APIRouter(prefix="/templates", tags=["templates"])
|
|
|
|
|
|
def _get_or_404(db: Session, template_id: int) -> Template:
|
|
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(Template, 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[TemplateListItem],
|
|
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[TemplateListItem]:
|
|
"""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=TemplateRead,
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Create a paper template",
|
|
)
|
|
def create_template(
|
|
payload: TemplateCreate,
|
|
db: Session = Depends(get_db),
|
|
) -> TemplateRead:
|
|
"""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 TemplateRead.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=TemplateRead,
|
|
summary="Fetch one paper template with its outline",
|
|
)
|
|
def get_template(template_id: int, db: Session = Depends(get_db)) -> TemplateRead:
|
|
"""Return a template; ``fields`` arrives already ordered by ``sort``."""
|
|
return TemplateRead.from_model(_get_or_404(db, template_id))
|
|
|
|
|
|
@router.patch(
|
|
"/{template_id}",
|
|
response_model=TemplateRead,
|
|
summary="Update a paper template",
|
|
)
|
|
def update_template(
|
|
template_id: int,
|
|
payload: TemplateUpdate,
|
|
db: Session = Depends(get_db),
|
|
) -> TemplateRead:
|
|
"""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 TemplateRead.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)
|