"""Materials router — CRUD for the shared material library.""" import uuid from datetime import datetime from typing import Optional, Literal from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from sqlalchemy.orm import selectinload from pydantic import BaseModel from app.database import get_db from app.models.material import Material from app.models.material_alias import MaterialAlias from app.models.user import User from app.utils.auth import get_current_user, require_admin_or_pm router = APIRouter(prefix="/materials", tags=["materials"]) class MaterialOut(BaseModel): id: uuid.UUID name: str description: str | None source: str hartomat_code: int | None = None created_by_name: str | None = None aliases: list[str] = [] created_at: datetime updated_at: datetime model_config = {"from_attributes": True} class MaterialAliasOut(BaseModel): id: uuid.UUID alias: str created_at: datetime model_config = {"from_attributes": True} class MaterialCreate(BaseModel): name: str description: str | None = None source: str = "manual" hartomat_code: int | None = None class MaterialUpdate(BaseModel): name: str | None = None description: str | None = None class AliasCreate(BaseModel): alias: str def _to_out(mat: Material) -> MaterialOut: creator_name = None if mat.creator is not None: creator_name = mat.creator.full_name or mat.creator.email alias_names = [a.alias for a in mat.aliases] if mat.aliases else [] return MaterialOut( id=mat.id, name=mat.name, description=mat.description, source=mat.source, hartomat_code=mat.hartomat_code, created_by_name=creator_name, aliases=alias_names, created_at=mat.created_at, updated_at=mat.updated_at, ) # --- Static-path endpoints (before /{material_id}) --- @router.get("/next-code") async def get_next_code( type_prefix: str, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Find the next available consecutive number for a given type+subtype prefix. type_prefix is the 4-digit prefix e.g. "0101" for Metals/Steel. Returns {"next_code": 10106, "prefix": "0101", "next_consecutive": 6} """ if len(type_prefix) != 4 or not type_prefix.isdigit(): raise HTTPException(400, "type_prefix must be exactly 4 digits") prefix_int = int(type_prefix) * 100 # e.g. "0101" -> 10100 range_start = prefix_int range_end = prefix_int + 99 result = await db.execute( select(func.max(Material.hartomat_code)).where( Material.hartomat_code >= range_start, Material.hartomat_code <= range_end, ) ) max_code = result.scalar_one_or_none() if max_code is None: next_consecutive = 1 else: next_consecutive = (max_code % 100) + 1 return { "next_code": prefix_int + next_consecutive, "prefix": type_prefix, "next_consecutive": next_consecutive, } @router.post("/seed-hartomat") async def seed_hartomat_materials( user: User = Depends(require_admin_or_pm), db: AsyncSession = Depends(get_db), ): """Bulk-create the 35 standard HartOMat materials. Skips existing by name.""" from app.data.hartomat_materials import HARTOMAT_MATERIALS inserted = 0 for mat_data in HARTOMAT_MATERIALS: existing = await db.execute( select(Material).where(Material.name == mat_data["name"]) ) if existing.scalar_one_or_none(): continue mat = Material( name=mat_data["name"], description=mat_data["description"], source=mat_data["source"], hartomat_code=mat_data["hartomat_code"], created_by=user.id, ) db.add(mat) inserted += 1 await db.commit() return {"inserted": inserted, "total": len(HARTOMAT_MATERIALS)} @router.post("/seed-aliases") async def seed_aliases( user: User = Depends(require_admin_or_pm), db: AsyncSession = Depends(get_db), ): """Bulk-seed aliases from naming_scheme.xlsx Materialmapping data. Skips existing.""" from app.data.material_alias_seeds import MATERIAL_ALIAS_SEEDS inserted = 0 total = 0 for entry in MATERIAL_ALIAS_SEEDS: mat_result = await db.execute( select(Material).where(Material.name == entry["material_name"]) ) mat = mat_result.scalar_one_or_none() if not mat: continue for alias_str in entry["aliases"]: total += 1 existing = await db.execute( select(MaterialAlias).where(func.lower(MaterialAlias.alias) == alias_str.lower()) ) if existing.scalar_one_or_none(): continue db.add(MaterialAlias(material_id=mat.id, alias=alias_str)) inserted += 1 await db.commit() return {"inserted": inserted, "total": total} class BatchAliasMapping(BaseModel): alias: str material_id: uuid.UUID class BatchAliasCreate(BaseModel): mappings: list[BatchAliasMapping] @router.post("/batch-aliases") async def batch_create_aliases( body: BatchAliasCreate, user: User = Depends(require_admin_or_pm), db: AsyncSession = Depends(get_db), ): """Create multiple material aliases in one request. Skips aliases that already exist (case-insensitive). Validates that each material_id exists. """ created = 0 skipped = 0 for mapping in body.mappings: alias_str = mapping.alias.strip() if not alias_str: skipped += 1 continue # Verify material exists mat_result = await db.execute( select(Material).where(Material.id == mapping.material_id) ) if not mat_result.scalar_one_or_none(): raise HTTPException( status.HTTP_404_NOT_FOUND, detail=f"Material {mapping.material_id} not found", ) # Check if alias already exists (case-insensitive) existing = await db.execute( select(MaterialAlias).where( func.lower(MaterialAlias.alias) == alias_str.lower() ) ) if existing.scalar_one_or_none(): skipped += 1 continue db.add(MaterialAlias(material_id=mapping.material_id, alias=alias_str)) created += 1 await db.commit() return {"created": created, "skipped": skipped} @router.delete("/aliases/{alias_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_alias( alias_id: uuid.UUID, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(MaterialAlias).where(MaterialAlias.id == alias_id)) alias_obj = result.scalar_one_or_none() if not alias_obj: raise HTTPException(404, detail="Alias not found") await db.delete(alias_obj) await db.commit() # --- Standard CRUD --- @router.get("", response_model=list[MaterialOut]) async def list_materials( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute( select(Material) .options(selectinload(Material.creator), selectinload(Material.aliases)) .order_by(Material.name) ) return [_to_out(m) for m in result.scalars().all()] @router.post("", response_model=MaterialOut, status_code=status.HTTP_201_CREATED) async def create_material( body: MaterialCreate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): existing = await db.execute(select(Material).where(Material.name == body.name)) if existing.scalar_one_or_none(): raise HTTPException(400, detail=f"Material '{body.name}' already exists") mat = Material( name=body.name, description=body.description, source=body.source, hartomat_code=body.hartomat_code, created_by=user.id, ) db.add(mat) await db.commit() await db.refresh(mat) result = await db.execute( select(Material) .options(selectinload(Material.creator), selectinload(Material.aliases)) .where(Material.id == mat.id) ) return _to_out(result.scalar_one()) @router.patch("/{material_id}", response_model=MaterialOut) async def update_material( material_id: uuid.UUID, body: MaterialUpdate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute( select(Material) .options(selectinload(Material.creator), selectinload(Material.aliases)) .where(Material.id == material_id) ) mat = result.scalar_one_or_none() if not mat: raise HTTPException(404, detail="Material not found") if body.name is not None: mat.name = body.name if body.description is not None: mat.description = body.description mat.updated_at = datetime.utcnow() await db.commit() await db.refresh(mat) result2 = await db.execute( select(Material) .options(selectinload(Material.creator), selectinload(Material.aliases)) .where(Material.id == mat.id) ) return _to_out(result2.scalar_one()) @router.delete("/{material_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_material( material_id: uuid.UUID, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(Material).where(Material.id == material_id)) mat = result.scalar_one_or_none() if not mat: raise HTTPException(404, detail="Material not found") await db.delete(mat) await db.commit() # --- Alias sub-resource endpoints --- @router.get("/{material_id}/aliases", response_model=list[MaterialAliasOut]) async def list_aliases( material_id: uuid.UUID, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute( select(MaterialAlias) .where(MaterialAlias.material_id == material_id) .order_by(MaterialAlias.alias) ) return [MaterialAliasOut.model_validate(a) for a in result.scalars().all()] @router.post("/{material_id}/aliases", response_model=MaterialAliasOut, status_code=status.HTTP_201_CREATED) async def add_alias( material_id: uuid.UUID, body: AliasCreate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): # Verify material exists mat_result = await db.execute(select(Material).where(Material.id == material_id)) if not mat_result.scalar_one_or_none(): raise HTTPException(404, detail="Material not found") alias_str = body.alias.strip() if not alias_str: raise HTTPException(400, detail="Alias cannot be empty") # Check case-insensitive uniqueness existing = await db.execute( select(MaterialAlias).where(func.lower(MaterialAlias.alias) == alias_str.lower()) ) dup = existing.scalar_one_or_none() if dup: raise HTTPException( status.HTTP_409_CONFLICT, detail=f"Alias '{alias_str}' already exists (assigned to material {dup.material_id})", ) alias_obj = MaterialAlias(material_id=material_id, alias=alias_str) db.add(alias_obj) await db.commit() await db.refresh(alias_obj) return MaterialAliasOut.model_validate(alias_obj)