feat: initial commit
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
"""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
|
||||
schaeffler_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"
|
||||
schaeffler_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,
|
||||
schaeffler_code=mat.schaeffler_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.schaeffler_code)).where(
|
||||
Material.schaeffler_code >= range_start,
|
||||
Material.schaeffler_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-schaeffler")
|
||||
async def seed_schaeffler_materials(
|
||||
user: User = Depends(require_admin_or_pm),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Bulk-create the 35 standard Schaeffler materials. Skips existing by name."""
|
||||
from app.data.schaeffler_materials import SCHAEFFLER_MATERIALS
|
||||
|
||||
inserted = 0
|
||||
for mat_data in SCHAEFFLER_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"],
|
||||
schaeffler_code=mat_data["schaeffler_code"],
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(mat)
|
||||
inserted += 1
|
||||
|
||||
await db.commit()
|
||||
return {"inserted": inserted, "total": len(SCHAEFFLER_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}
|
||||
|
||||
|
||||
@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,
|
||||
schaeffler_code=body.schaeffler_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)
|
||||
Reference in New Issue
Block a user