"""Section-field library endpoints (字段管理). The library is global and reusable: a field exists once and is then placed into any number of templates. That is why deleting a field is guarded — the join table is the only thing keeping a template's outline intact, and silently dropping a live heading from every template would be data loss, not cleanup. """ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy.orm import Session from app.crud import section_field as crud from app.db.session import get_db from app.models import SectionField from app.schemas.common import BatchDeleteRequest, BatchDeleteResult, PageResult from app.schemas.section_field import ( SectionFieldCreate, SectionFieldRead, SectionFieldUpdate, ) router = APIRouter(prefix="/section-fields", tags=["section-fields"]) def _get_or_404(db: Session, field_id: int) -> SectionField: field = crud.get(db, field_id) if field is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"字段 {field_id} 不存在", ) return field def _assert_unused(db: Session, fields: list[SectionField]) -> None: """Refuse the delete if any field is still placed in a template. All offenders are reported at once rather than one per attempt, so a batch delete does not turn into trial and error. """ counts = crud.usage_counts(db, [field.id for field in fields]) if not counts: return by_id = {field.id: field.name for field in fields} blockers = "、".join( f"“{by_id.get(field_id, field_id)}”({count} 个模板)" for field_id, count in sorted(counts.items()) ) raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"以下字段正被模板使用,请先在模板中移除:{blockers}", ) @router.get( "", response_model=PageResult[SectionFieldRead], summary="List the field library", ) def list_fields( db: Session = Depends(get_db), keyword: str | None = Query(default=None, description="按字段名称模糊搜索"), level: int | None = Query(default=None, ge=1, le=9, description="按字段等级过滤"), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=500), ) -> PageResult[SectionFieldRead]: """Browse the field library, grouped by level then creation order.""" rows, total = crud.list_fields( db, keyword=keyword, level=level, page=page, page_size=page_size, ) return PageResult.build( items=[SectionFieldRead.model_validate(row) for row in rows], total=total, page=page, page_size=page_size, ) @router.post( "", response_model=SectionFieldRead, status_code=status.HTTP_201_CREATED, summary="Create a section field", ) def create_field( payload: SectionFieldCreate, db: Session = Depends(get_db), ) -> SectionFieldRead: """Add a heading to the library, with its typography.""" return SectionFieldRead.model_validate(crud.create(db, payload)) @router.post( "/batch-delete", response_model=BatchDeleteResult, summary="Delete several section fields", ) def batch_delete_fields( payload: BatchDeleteRequest, db: Session = Depends(get_db), ) -> BatchDeleteResult: """Delete the given fields, refusing wholesale if any is still in use.""" fields = crud.get_many(db, payload.ids) _assert_unused(db, fields) for field in fields: crud.delete(db, field) return BatchDeleteResult(deleted=len(fields)) @router.get( "/{field_id}", response_model=SectionFieldRead, summary="Fetch one section field", ) def get_field(field_id: int, db: Session = Depends(get_db)) -> SectionFieldRead: """Return a single field.""" return SectionFieldRead.model_validate(_get_or_404(db, field_id)) @router.patch( "/{field_id}", response_model=SectionFieldRead, summary="Update a section field", ) def update_field( field_id: int, payload: SectionFieldUpdate, db: Session = Depends(get_db), ) -> SectionFieldRead: """Rename or restyle a field. The change is visible in every template that places the field, because templates store a reference rather than a copy. """ field = _get_or_404(db, field_id) return SectionFieldRead.model_validate(crud.update(db, field, payload)) @router.delete( "/{field_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a section field", ) def delete_field(field_id: int, db: Session = Depends(get_db)) -> Response: """Delete an unused field.""" field = _get_or_404(db, field_id) _assert_unused(db, [field]) crud.delete(db, field) return Response(status_code=status.HTTP_204_NO_CONTENT)