"""Data access for the reusable template-field library (字段管理).""" from collections.abc import Sequence from sqlalchemy import Select, func, select from sqlalchemy.orm import Session from app.crud.filters import LIKE_ESCAPE, like_pattern from app.models import TemplateField, TemplateFieldLibrary from app.schemas.template_field_library import ( TemplateFieldLibraryCreate, TemplateFieldLibraryUpdate, ) def _conditions(keyword: str | None, level: int | None) -> list: """Translate the list filters into SQLAlchemy predicates.""" conditions = [] if keyword: conditions.append( TemplateFieldLibrary.name.like(like_pattern(keyword), escape=LIKE_ESCAPE) ) if level is not None: conditions.append(TemplateFieldLibrary.level == level) return conditions def _ordered(stmt: Select) -> Select: """Apply the library's canonical browse order. Grouped by ``level`` first so the picker reads as an outline, then by insertion order so a field stays where the user put it. Deliberately *not* ordered by ``name``: names carry hand-written numbering ("1.", "10.", "2."), and string ordering would scramble it. """ return stmt.order_by(TemplateFieldLibrary.level.asc(), TemplateFieldLibrary.id.asc()) def list_fields( db: Session, *, keyword: str | None = None, level: int | None = None, page: int = 1, page_size: int = 20, ) -> tuple[list[TemplateFieldLibrary], int]: """Return one page of the field library, plus the unpaged total.""" conditions = _conditions(keyword, level) total = db.scalar( select(func.count(TemplateFieldLibrary.id)).where(*conditions) ) or 0 stmt = _ordered( select(TemplateFieldLibrary) .where(*conditions) .offset((page - 1) * page_size) .limit(page_size) ) return list(db.scalars(stmt).all()), total def get(db: Session, field_id: int) -> TemplateFieldLibrary | None: """Return one field, or ``None``.""" return db.get(TemplateFieldLibrary, field_id) def get_many(db: Session, field_ids: Sequence[int]) -> list[TemplateFieldLibrary]: """Return every field whose id is in ``field_ids`` (missing ids ignored).""" if not field_ids: return [] stmt = select(TemplateFieldLibrary).where(TemplateFieldLibrary.id.in_(list(field_ids))) return list(db.scalars(stmt).all()) def usage_counts(db: Session, field_ids: Sequence[int]) -> dict[int, int]: """Count how many *distinct templates* place each field. Drives the refusal message when a field in use is deleted. ``DISTINCT`` matters because one template may legitimately place the same field twice. """ if not field_ids: return {} stmt = ( select( TemplateField.field_id, func.count(func.distinct(TemplateField.template_id)), ) .where(TemplateField.field_id.in_(list(field_ids))) .group_by(TemplateField.field_id) ) return {field_id: count for field_id, count in db.execute(stmt).all()} def create(db: Session, data: TemplateFieldLibraryCreate) -> TemplateFieldLibrary: """Insert a field.""" field = TemplateFieldLibrary(**data.model_dump()) db.add(field) db.commit() db.refresh(field) return field def update( db: Session, field: TemplateFieldLibrary, data: TemplateFieldLibraryUpdate, ) -> TemplateFieldLibrary: """Apply a partial update to a field. ``exclude_unset`` is what makes PATCH semantics work: a key the client did not send leaves the column alone, while an explicit ``null`` — which the schemas reject for every nullable-typed column here — would not. """ for key, value in data.model_dump(exclude_unset=True).items(): setattr(field, key, value) db.commit() db.refresh(field) return field def delete(db: Session, field: TemplateFieldLibrary) -> None: """Delete a field. Callers must check :func:`usage_counts` first.""" db.delete(field) db.commit()