"""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.domains.auth.models import PM_ROLES 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 PM_ROLES 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)}