Files

383 lines
11 KiB
Python

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