366 lines
12 KiB
Python
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)}
|