from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.crud import attachment_point as attachment_point_crud from app.crud import audit as audit_crud from app.crud import fit as fit_crud from app.crud import fit_note as fit_note_crud from app.deps import require_permission from app.models.fit import FitLabel from app.models.user import User from app.schemas.fit import ( FitCreate, FitNoteCreate, FitNoteRead, FitRead, FitRecomputeResult, FitUpdate, ) from app.services import fit_matching router = APIRouter() @router.get("", response_model=list[FitRead]) async def list_fits( augmented_part_id: int | None = None, label: FitLabel | None = None, skip: int = 0, limit: int = 100, db: AsyncSession = Depends(get_db), _: User = Depends(require_permission("fits:read")), ): if augmented_part_id is not None: fits = await fit_crud.list_for_part(db, augmented_part_id) if label is not None: fits = [f for f in fits if f.label == label] return fits[skip : skip + limit] return await fit_crud.list_all(db, skip=skip, limit=limit, label=label) @router.get("/{fit_id}", response_model=FitRead) async def get_fit( fit_id: int, db: AsyncSession = Depends(get_db), _: User = Depends(require_permission("fits:read")), ): fit = await fit_crud.get(db, fit_id) if fit is None: raise HTTPException(status_code=404, detail="Fit not found") return fit @router.post("", response_model=FitRead, status_code=status.HTTP_201_CREATED) async def create_fit( payload: FitCreate, db: AsyncSession = Depends(get_db), actor: User = Depends(require_permission("fits:write")), ): """Manually declare a fit between two attachment points; recorded as Exact.""" if payload.attachment_point_a_id == payload.attachment_point_b_id: raise HTTPException( status_code=422, detail="attachment points must be different" ) ap_a = await attachment_point_crud.get(db, payload.attachment_point_a_id) ap_b = await attachment_point_crud.get(db, payload.attachment_point_b_id) if ap_a is None or ap_b is None: raise HTTPException(status_code=404, detail="Attachment point not found") if ap_a.augmented_part_id == ap_b.augmented_part_id: raise HTTPException( status_code=422, detail="a part cannot fit itself" ) existing = await fit_crud.get_by_attachment_points(db, ap_a, ap_b) if existing is not None: raise HTTPException(status_code=409, detail="Fit already exists") fit = await fit_crud.create(db, ap_a=ap_a, ap_b=ap_b, label=FitLabel.EXACT) await audit_crud.log( db, actor=actor, action="create", resource_type="Fit", resource_id=fit.id, description=audit_crud.describe_create( "Fit", fit.id, f"attachment points {ap_a.id}↔{ap_b.id}" ), ) return fit @router.patch("/{fit_id}", response_model=FitRead) async def update_fit_label( fit_id: int, payload: FitUpdate, db: AsyncSession = Depends(get_db), actor: User = Depends(require_permission("fits:write")), ): """Promote a Possible fit to Exact, or mark a pairing as None (impossible).""" fit = await fit_crud.get(db, fit_id) if fit is None: raise HTTPException(status_code=404, detail="Fit not found") fit = await fit_crud.set_label(db, fit, payload.label) await audit_crud.log( db, actor=actor, action="update", resource_type="Fit", resource_id=fit.id, description=audit_crud.describe_update( "Fit", fit.id, {"label": payload.label.value} ), ) return fit @router.delete("/{fit_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_fit( fit_id: int, db: AsyncSession = Depends(get_db), actor: User = Depends(require_permission("fits:delete")), ): fit = await fit_crud.get(db, fit_id) if fit is None: raise HTTPException(status_code=404, detail="Fit not found") label = f"attachment points {fit.attachment_point_a_id}↔{fit.attachment_point_b_id}" await fit_crud.delete(db, fit) await audit_crud.log( db, actor=actor, action="delete", resource_type="Fit", resource_id=fit_id, description=audit_crud.describe_delete("Fit", fit_id, label), ) @router.post("/recompute", response_model=FitRecomputeResult) async def recompute_fits( augmented_part_id: int | None = None, db: AsyncSession = Depends(get_db), actor: User = Depends(require_permission("fits:write")), ): """Re-run the matching algorithm, scoped to one part or across all parts.""" if augmented_part_id is not None: result = await fit_matching.recompute_for_part(db, augmented_part_id) else: result = await fit_matching.recompute_all(db) await audit_crud.log( db, actor=actor, action="update", resource_type="Fit", resource_id=augmented_part_id, description=f"Recomputed fits: {result.created} created, {result.removed} removed", ) return FitRecomputeResult(created=result.created, removed=result.removed) @router.post( "/{fit_id}/notes", response_model=FitNoteRead, status_code=status.HTTP_201_CREATED ) async def create_fit_note( fit_id: int, payload: FitNoteCreate, db: AsyncSession = Depends(get_db), actor: User = Depends(require_permission("fits:write")), ): fit = await fit_crud.get(db, fit_id) if fit is None: raise HTTPException(status_code=404, detail="Fit not found") return await fit_note_crud.create(db, fit=fit, author=actor, body=payload.body)