Files
data-entry-app/backend/app/api/editor.py
T
2026-06-03 15:09:21 +12:00

285 lines
10 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload, selectinload
from app.api.deps import AuthSession, get_auth_session
from app.db.session import get_db
from app.models.mix import Mix
from app.models.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial
from app.schemas.editor import (
EditorMixUpdate,
EditorProductFormulaRead,
EditorProductIngredientCreate,
EditorProductIngredientUpdate,
EditorProductRow,
EditorProductUpdate,
)
from app.services.client_access_service import has_access_level
router = APIRouter(prefix="/api/editor", tags=["editor"])
def _serialize_row(product: Product) -> dict:
return {
"id": product.id,
"tenant_id": product.tenant_id,
"client_name": product.client_name,
"item_id": product.item_id,
"name": product.name,
"mix_id": product.mix_id,
"mix_client_name": product.mix.client_name if product.mix else "",
"mix_name": product.mix.name if product.mix else "",
"sale_type": product.sale_type,
"unit_of_measure": product.unit_of_measure,
"visible": product.visible,
"product_notes": product.notes,
"mix_notes": product.mix.notes if product.mix else None,
}
def _serialize_product_formula(product: Product) -> dict:
ingredients = [
{
"id": ingredient.id,
"raw_material_id": ingredient.raw_material_id,
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}",
"quantity_kg": ingredient.quantity_kg,
"sort_order": ingredient.sort_order,
"notes": ingredient.notes,
}
for ingredient in sorted(product.ingredients, key=lambda item: (item.sort_order, item.raw_material.name if item.raw_material else ""))
]
return {
"id": product.id,
"tenant_id": product.tenant_id,
"client_name": product.client_name,
"name": product.name,
"mix_id": product.mix_id,
"mix_name": product.mix.name if product.mix else "",
"ingredients": ingredients,
"total_kg": round(sum(ingredient["quantity_kg"] for ingredient in ingredients), 4),
}
def _load_editor_product_formula(db: Session, *, product_id: int, tenant_id: str) -> Product | None:
return db.scalar(
select(Product)
.where(Product.id == product_id, Product.tenant_id == tenant_id)
.options(
joinedload(Product.mix),
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material),
)
)
def _require_editor_session(
session: AuthSession = Depends(get_auth_session),
db: Session = Depends(get_db),
) -> AuthSession:
if session.role == "internal":
permissions = session.module_permissions or {}
if not has_access_level(permissions.get("client_access"), "manage"):
raise HTTPException(status_code=403, detail="Lean access is required")
if not has_access_level(permissions.get("products"), "edit"):
raise HTTPException(status_code=403, detail="products edit access is required")
if not has_access_level(permissions.get("mix_master"), "edit"):
raise HTTPException(status_code=403, detail="mix_master edit access is required")
if not session.tenant_id:
raise HTTPException(status_code=403, detail="Internal user context is missing")
return session
raise HTTPException(status_code=403, detail="Lean access is required")
@router.get("/products", response_model=list[EditorProductRow])
def list_editor_products(
q: str | None = Query(default=None, max_length=255),
client_name: str | None = Query(default=None, max_length=255),
limit: int = Query(default=500, ge=1, le=1000),
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
statement = (
select(Product)
.where(Product.tenant_id == session.tenant_id)
.options(joinedload(Product.mix))
.join(Product.mix)
.order_by(Product.client_name, Product.name, Product.id)
.limit(limit)
)
if client_name:
statement = statement.where(Product.client_name == client_name)
if q:
term = f"%{q.strip()}%"
statement = statement.where(
or_(
Product.client_name.ilike(term),
Product.name.ilike(term),
Product.item_id.ilike(term),
Product.unit_of_measure.ilike(term),
Mix.name.ilike(term),
)
)
return [_serialize_row(product) for product in db.scalars(statement).all()]
@router.patch("/products/{product_id}", response_model=EditorProductRow)
def update_editor_product(
product_id: int,
payload: EditorProductUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
product = db.scalar(
select(Product)
.where(Product.id == product_id, Product.tenant_id == session.tenant_id)
.options(joinedload(Product.mix))
)
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
if payload.mix_id is not None:
mix = db.scalar(select(Mix).where(Mix.id == payload.mix_id, Mix.tenant_id == session.tenant_id))
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(product, field, value)
db.commit()
db.refresh(product)
return _serialize_row(product)
@router.patch("/mixes/{mix_id}", response_model=list[EditorProductRow])
def update_editor_mix(
mix_id: int,
payload: EditorMixUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id))
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(mix, field, value)
db.commit()
products = db.scalars(
select(Product)
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
.options(joinedload(Product.mix))
.order_by(Product.client_name, Product.name, Product.id)
).all()
return [_serialize_row(product) for product in products]
@router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead)
def get_editor_product_ingredients(
product_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
return _serialize_product_formula(product)
@router.post("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead, status_code=201)
def add_editor_product_ingredient(
product_id: int,
payload: EditorProductIngredientCreate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Raw material not found")
next_sort_order = (
db.scalar(
select(func.coalesce(func.max(ProductIngredient.sort_order), 0)).where(ProductIngredient.product_id == product_id)
)
or 0
) + 1
db.add(
ProductIngredient(
tenant_id=session.tenant_id or "",
product_id=product_id,
raw_material_id=payload.raw_material_id,
quantity_kg=payload.quantity_kg,
sort_order=next_sort_order,
notes=payload.notes,
)
)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=400, detail="Raw material is already on this product") from exc
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
return _serialize_product_formula(product)
@router.patch("/products/{product_id}/ingredients/{ingredient_id}", response_model=EditorProductFormulaRead)
def update_editor_product_ingredient(
product_id: int,
ingredient_id: int,
payload: EditorProductIngredientUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
ingredient = db.scalar(
select(ProductIngredient)
.join(Product)
.where(
ProductIngredient.id == ingredient_id,
ProductIngredient.product_id == product_id,
Product.tenant_id == session.tenant_id,
)
)
if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(ingredient, field, value)
db.commit()
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
return _serialize_product_formula(product)
@router.delete("/products/{product_id}/ingredients/{ingredient_id}", response_model=EditorProductFormulaRead)
def delete_editor_product_ingredient(
product_id: int,
ingredient_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
ingredient = db.scalar(
select(ProductIngredient)
.join(Product)
.where(
ProductIngredient.id == ingredient_id,
ProductIngredient.product_id == product_id,
Product.tenant_id == session.tenant_id,
)
)
if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
db.delete(ingredient)
db.commit()
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
return _serialize_product_formula(product)