Files
HartOMat/backend/app/api/routers/order_items.py
T
2026-03-05 22:12:38 +01:00

366 lines
12 KiB
Python

"""Order items router - manage individual line items within an order."""
import uuid
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from pydantic import BaseModel
from app.database import get_db
from app.models.cad_file import CadFile
from app.models.order import Order, OrderStatus
from app.models.order_item import OrderItem, ItemStatus
from app.models.user import User
from app.schemas.order import OrderItemOut
from app.utils.auth import get_current_user
router = APIRouter(prefix="/orders", tags=["order_items"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _is_privileged(user: User) -> bool:
return user.role.value in ("admin", "project_manager")
async def _get_order_and_item(
order_id: uuid.UUID,
item_id: uuid.UUID,
user: User,
db: AsyncSession,
) -> tuple[Order, OrderItem]:
"""Load order + item, enforcing ownership/admin access."""
order_result = await db.execute(select(Order).where(Order.id == order_id))
order = order_result.scalar_one_or_none()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if not _is_privileged(user) and order.created_by != user.id:
raise HTTPException(status_code=403, detail="Access denied")
item_result = await db.execute(
select(OrderItem)
.options(selectinload(OrderItem.cad_file))
.where(
OrderItem.id == item_id,
OrderItem.order_id == order_id,
)
)
item = item_result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Order item not found")
return order, item
# ---------------------------------------------------------------------------
# Request schemas
# ---------------------------------------------------------------------------
class OrderItemPatch(BaseModel):
ebene1: Optional[str] = None
ebene2: Optional[str] = None
baureihe: Optional[str] = None
pim_id: Optional[str] = None
produkt_baureihe: Optional[str] = None
gewaehltes_produkt: Optional[str] = None
name_cad_modell: Optional[str] = None
gewuenschte_bildnummer: Optional[str] = None
lagertyp: Optional[str] = None
medias_rendering: Optional[bool] = None
notes: Optional[str] = None
class ApproveRejectBody(BaseModel):
notes: Optional[str] = None
class CadPartMaterialEntry(BaseModel):
part_name: str
material: str
class CadPartMaterialsBody(BaseModel):
parts: list[CadPartMaterialEntry]
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/{order_id}/items", response_model=list[OrderItemOut])
async def list_order_items(
order_id: uuid.UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Return all items belonging to an order."""
order_result = await db.execute(select(Order).where(Order.id == order_id))
order = order_result.scalar_one_or_none()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if not _is_privileged(user) and order.created_by != user.id:
raise HTTPException(status_code=403, detail="Access denied")
items_result = await db.execute(
select(OrderItem)
.options(selectinload(OrderItem.cad_file))
.where(OrderItem.order_id == order_id)
.order_by(OrderItem.row_index)
)
items = items_result.scalars().all()
return [OrderItemOut.model_validate(i) for i in items]
@router.get("/{order_id}/items/{item_id}", response_model=OrderItemOut)
async def get_order_item(
order_id: uuid.UUID,
item_id: uuid.UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Return a single order item."""
_, item = await _get_order_and_item(order_id, item_id, user, db)
return OrderItemOut.model_validate(item)
@router.patch("/{order_id}/items/{item_id}", response_model=OrderItemOut)
async def update_order_item(
order_id: uuid.UUID,
item_id: uuid.UUID,
body: OrderItemPatch,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Edit the standard (non-component) fields of an order item."""
order, item = await _get_order_and_item(order_id, item_id, user, db)
# Only draft orders can be edited (admins may also edit submitted orders)
if not _is_privileged(user) and order.status != OrderStatus.draft:
raise HTTPException(
status_code=400,
detail="Order items can only be edited while the order is in draft status",
)
patch_data = body.model_dump(exclude_unset=True)
for field, value in patch_data.items():
setattr(item, field, value)
item.updated_at = datetime.utcnow()
await db.commit()
refreshed = await db.execute(
select(OrderItem).options(selectinload(OrderItem.cad_file)).where(OrderItem.id == item_id)
)
return OrderItemOut.model_validate(refreshed.scalar_one())
@router.post(
"/{order_id}/items/{item_id}/approve",
response_model=OrderItemOut,
status_code=status.HTTP_200_OK,
)
async def approve_order_item(
order_id: uuid.UUID,
item_id: uuid.UUID,
body: ApproveRejectBody = ApproveRejectBody(),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Mark an order item as approved (admin only)."""
if not _is_privileged(user):
raise HTTPException(status_code=403, detail="Only admins or PMs can approve items")
_, item = await _get_order_and_item(order_id, item_id, user, db)
if item.item_status == ItemStatus.approved:
raise HTTPException(status_code=400, detail="Item is already approved")
item.item_status = ItemStatus.approved
if body.notes is not None:
item.notes = body.notes
item.updated_at = datetime.utcnow()
await db.commit()
refreshed = await db.execute(
select(OrderItem).options(selectinload(OrderItem.cad_file)).where(OrderItem.id == item_id)
)
return OrderItemOut.model_validate(refreshed.scalar_one())
@router.post(
"/{order_id}/items/{item_id}/reject",
response_model=OrderItemOut,
status_code=status.HTTP_200_OK,
)
async def reject_order_item(
order_id: uuid.UUID,
item_id: uuid.UUID,
body: ApproveRejectBody = ApproveRejectBody(),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Mark an order item as rejected (admin only)."""
if not _is_privileged(user):
raise HTTPException(status_code=403, detail="Only admins or PMs can reject items")
_, item = await _get_order_and_item(order_id, item_id, user, db)
if item.item_status == ItemStatus.rejected:
raise HTTPException(status_code=400, detail="Item is already rejected")
item.item_status = ItemStatus.rejected
if body.notes is not None:
item.notes = body.notes
item.updated_at = datetime.utcnow()
await db.commit()
refreshed = await db.execute(
select(OrderItem).options(selectinload(OrderItem.cad_file)).where(OrderItem.id == item_id)
)
return OrderItemOut.model_validate(refreshed.scalar_one())
@router.put(
"/{order_id}/items/{item_id}/cad-materials",
response_model=OrderItemOut,
)
async def update_cad_materials(
order_id: uuid.UUID,
item_id: uuid.UUID,
body: CadPartMaterialsBody,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Save material assignments for each CAD part of an order item."""
_, item = await _get_order_and_item(order_id, item_id, user, db)
from sqlalchemy import update as sql_update
await db.execute(
sql_update(OrderItem)
.where(OrderItem.id == item_id)
.values(
cad_part_materials=[e.model_dump() for e in body.parts],
updated_at=datetime.utcnow(),
)
)
await db.commit()
# Re-fetch with cad_file eagerly loaded so cad_parsed_objects property works
refreshed = await db.execute(
select(OrderItem)
.options(selectinload(OrderItem.cad_file))
.where(OrderItem.id == item_id)
)
updated_item = refreshed.scalar_one()
# Queue thumbnail re-render with part colours if the item has a linked CAD file
if updated_item.cad_file_id and updated_item.cad_file:
parsed_objects = (updated_item.cad_file.parsed_objects or {}).get("objects", [])
if parsed_objects:
from app.services.step_processor import build_part_colors
from app.tasks.step_tasks import regenerate_thumbnail
part_colors = build_part_colors(
parsed_objects,
[e.model_dump() for e in body.parts],
)
regenerate_thumbnail.delay(str(updated_item.cad_file_id), part_colors)
return OrderItemOut.model_validate(updated_item)
@router.delete(
"/{order_id}/items/{item_id}/cad-file",
status_code=status.HTTP_204_NO_CONTENT,
)
async def unlink_cad_file(
order_id: uuid.UUID,
item_id: uuid.UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Unlink the STEP/CAD file from an order item.
Clears cad_file_id and cad_part_materials on the item.
If no other items reference the same CadFile, deletes the record and
removes the stored STEP, thumbnail, and glTF files from disk.
Only allowed while the order is in draft status.
"""
import os
from sqlalchemy import update as sql_update, func
order, item = await _get_order_and_item(order_id, item_id, user, db)
if order.status != OrderStatus.draft:
raise HTTPException(400, detail="CAD file can only be removed from draft orders")
if not item.cad_file_id:
raise HTTPException(400, detail="This item has no CAD file linked")
cad_id = item.cad_file_id
# Fetch the CadFile before unlinking
cad_result = await db.execute(select(CadFile).where(CadFile.id == cad_id))
cad_file = cad_result.scalar_one_or_none()
# Unlink item
from sqlalchemy import update as sql_update
await db.execute(
sql_update(OrderItem)
.where(OrderItem.id == item_id)
.values(cad_file_id=None, cad_part_materials=[], updated_at=datetime.utcnow())
)
await db.commit()
# Delete CadFile record + disk files if no other items still reference it
if cad_file:
remaining = await db.execute(
select(func.count()).where(OrderItem.cad_file_id == cad_id)
)
if remaining.scalar() == 0:
for path_attr in ("stored_path", "thumbnail_path", "gltf_path"):
fpath = getattr(cad_file, path_attr, None)
if fpath:
try:
os.remove(fpath)
except FileNotFoundError:
pass
await db.delete(cad_file)
await db.commit()
@router.post(
"/{order_id}/items/{item_id}/regenerate-thumbnail",
status_code=status.HTTP_202_ACCEPTED,
)
async def regenerate_item_thumbnail(
order_id: uuid.UUID,
item_id: uuid.UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Queue a thumbnail re-render for an order item's linked CAD file.
The thumbnail is re-generated with per-part colours derived from the
currently saved cad_part_materials. Returns immediately; the worker
processes the job asynchronously.
"""
_, item = await _get_order_and_item(order_id, item_id, user, db)
if not item.cad_file_id:
raise HTTPException(400, detail="No CAD file linked to this item")
if not item.cad_file:
raise HTTPException(400, detail="CAD file record not found")
parsed_objects = (item.cad_file.parsed_objects or {}).get("objects", [])
from app.services.step_processor import build_part_colors
from app.tasks.step_tasks import regenerate_thumbnail
part_colors = build_part_colors(parsed_objects, item.cad_part_materials or [])
task = regenerate_thumbnail.delay(str(item.cad_file_id), part_colors)
return {"status": "queued", "task_id": task.id, "cad_file_id": str(item.cad_file_id)}