"""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)