383 lines
11 KiB
Python
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)
|