feat: initial commit
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
"""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)}
|
||||
Reference in New Issue
Block a user