v0.1.27
Fix: Throughput API v1 available - Details posted to Irving. POWERBI_KEY was missing from the .ENV file, so was not live. Add: Editor now supports editing a mix's resolved formula directly, with % and kg dual entry on ingredient rows Fix: Mix Editor should bring through correct ingredients. New resolved formula (same logic we use in Mix Calculator). Fix: Security headers on all API responses (hardening) Add: New mix button available on the Mix Editor. Add: New ingredient button available on the Ingredient Editor
This commit is contained in:
+208
-4
@@ -8,7 +8,9 @@ from app.db.session import get_db
|
||||
from app.models.mix import Mix, MixIngredient
|
||||
from app.models.product import Product, ProductIngredient
|
||||
from app.models.raw_material import RawMaterial
|
||||
from app.models.change_event import EditorChangeEvent
|
||||
from app.schemas.editor import (
|
||||
EditorChangeEventRead,
|
||||
EditorIngredientCreate,
|
||||
EditorIngredientRow,
|
||||
EditorIngredientUpdate,
|
||||
@@ -25,6 +27,14 @@ from app.schemas.editor import (
|
||||
EditorProductRow,
|
||||
EditorProductUpdate,
|
||||
EditorResolvedMixFormula,
|
||||
EditorResolvedMixIngredient,
|
||||
)
|
||||
from app.services.change_log import (
|
||||
ENTITY_INGREDIENT,
|
||||
ENTITY_MIX,
|
||||
diff_fields,
|
||||
list_changes,
|
||||
record_change,
|
||||
)
|
||||
from app.services.client_access_service import has_access_level
|
||||
from app.services.costing_engine import calculate_raw_material_cost, get_active_price
|
||||
@@ -126,6 +136,50 @@ def _serialize_mix_formula(mix: Mix) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _serialize_change_event(event: EditorChangeEvent) -> dict:
|
||||
return {
|
||||
"id": event.id,
|
||||
"entity_type": event.entity_type,
|
||||
"entity_id": event.entity_id,
|
||||
"action": event.action,
|
||||
"actor_name": event.actor_name,
|
||||
"actor_email": event.actor_email,
|
||||
"actor_role": event.actor_role,
|
||||
"summary": event.summary,
|
||||
"changes": event.changes or [],
|
||||
"created_at": event.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _format_kg(value: float) -> str:
|
||||
text = f"{value:.4f}".rstrip("0").rstrip(".")
|
||||
return f"{text or '0'} kg"
|
||||
|
||||
|
||||
def _formula_deltas(
|
||||
before: list[EditorResolvedMixIngredient] | list,
|
||||
after: list[EditorResolvedMixIngredient] | list,
|
||||
) -> list[dict]:
|
||||
"""Per-ingredient before/after deltas between two resolved formulas."""
|
||||
before_map = {row.raw_material_name: row.quantity_kg for row in before}
|
||||
after_map = {row.raw_material_name: row.quantity_kg for row in after}
|
||||
deltas: list[dict] = []
|
||||
for name in sorted(set(before_map) | set(after_map)):
|
||||
old = before_map.get(name)
|
||||
new = after_map.get(name)
|
||||
if old == new:
|
||||
continue
|
||||
deltas.append(
|
||||
{
|
||||
"field": name,
|
||||
"label": name,
|
||||
"before": _format_kg(old) if old is not None else None,
|
||||
"after": _format_kg(new) if new is not None else None,
|
||||
}
|
||||
)
|
||||
return deltas
|
||||
|
||||
|
||||
def _load_editor_mix_formula(db: Session, *, mix_id: int, tenant_id: str) -> Mix | None:
|
||||
return db.scalar(
|
||||
select(Mix)
|
||||
@@ -267,6 +321,15 @@ def create_editor_mix(
|
||||
notes=payload.notes,
|
||||
)
|
||||
db.add(mix)
|
||||
db.flush()
|
||||
record_change(
|
||||
db,
|
||||
session=session,
|
||||
entity_type=ENTITY_MIX,
|
||||
entity_id=mix.id,
|
||||
action="created",
|
||||
summary=f"Created mix “{mix.name}” for {mix.client_name}",
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(mix)
|
||||
# A brand-new mix has no products yet, so it reads as Inactive (no visible products).
|
||||
@@ -288,6 +351,16 @@ def update_editor_mix(
|
||||
# `visible` is a virtual field: it fans out to the visibility of every product
|
||||
# under the mix rather than mapping to a mix column.
|
||||
visible = updates.pop("visible", None)
|
||||
|
||||
before = {field: getattr(mix, field) for field in updates}
|
||||
if visible is not None:
|
||||
visible_before = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Product)
|
||||
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id, Product.visible)
|
||||
)
|
||||
before["visible"] = bool(visible_before)
|
||||
|
||||
for field, value in updates.items():
|
||||
setattr(mix, field, value)
|
||||
|
||||
@@ -297,6 +370,25 @@ def update_editor_mix(
|
||||
).all():
|
||||
product.visible = visible
|
||||
|
||||
after = dict(updates)
|
||||
if visible is not None:
|
||||
after["visible"] = visible
|
||||
deltas = diff_fields(
|
||||
before,
|
||||
after,
|
||||
{"name": "Mix name", "client_name": "Client", "notes": "Notes", "visible": "Status (active)"},
|
||||
)
|
||||
if deltas:
|
||||
record_change(
|
||||
db,
|
||||
session=session,
|
||||
entity_type=ENTITY_MIX,
|
||||
entity_id=mix.id,
|
||||
action="updated",
|
||||
summary=f"Updated {', '.join(delta['label'] for delta in deltas)}",
|
||||
changes=deltas,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
counts = _mix_product_counts(db, session.tenant_id or "")
|
||||
@@ -326,7 +418,10 @@ def add_editor_mix_ingredient(
|
||||
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
|
||||
if mix is None:
|
||||
raise HTTPException(status_code=404, detail="Mix not found")
|
||||
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None:
|
||||
raw_material = db.scalar(
|
||||
select(RawMaterial).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)
|
||||
)
|
||||
if raw_material is None:
|
||||
raise HTTPException(status_code=404, detail="Raw material not found")
|
||||
|
||||
db.add(
|
||||
@@ -338,6 +433,15 @@ def add_editor_mix_ingredient(
|
||||
notes=payload.notes,
|
||||
)
|
||||
)
|
||||
record_change(
|
||||
db,
|
||||
session=session,
|
||||
entity_type=ENTITY_MIX,
|
||||
entity_id=mix_id,
|
||||
action="ingredient_added",
|
||||
summary=f"Added {raw_material.name} ({_format_kg(payload.quantity_kg)})",
|
||||
changes=[{"field": raw_material.name, "label": raw_material.name, "before": None, "after": _format_kg(payload.quantity_kg)}],
|
||||
)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as exc:
|
||||
@@ -367,8 +471,22 @@ def update_editor_mix_ingredient(
|
||||
)
|
||||
if ingredient is None:
|
||||
raise HTTPException(status_code=404, detail="Ingredient not found")
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
raw_material_name = ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}"
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
before = {field: getattr(ingredient, field) for field in updates}
|
||||
for field, value in updates.items():
|
||||
setattr(ingredient, field, value)
|
||||
deltas = diff_fields(before, updates, {"quantity_kg": f"{raw_material_name} quantity", "notes": f"{raw_material_name} notes"})
|
||||
if deltas:
|
||||
record_change(
|
||||
db,
|
||||
session=session,
|
||||
entity_type=ENTITY_MIX,
|
||||
entity_id=mix_id,
|
||||
action="ingredient_updated",
|
||||
summary=f"Updated {raw_material_name}",
|
||||
changes=deltas,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
|
||||
@@ -393,7 +511,18 @@ def delete_editor_mix_ingredient(
|
||||
)
|
||||
if ingredient is None:
|
||||
raise HTTPException(status_code=404, detail="Ingredient not found")
|
||||
raw_material_name = ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}"
|
||||
removed_kg = ingredient.quantity_kg
|
||||
db.delete(ingredient)
|
||||
record_change(
|
||||
db,
|
||||
session=session,
|
||||
entity_type=ENTITY_MIX,
|
||||
entity_id=mix_id,
|
||||
action="ingredient_removed",
|
||||
summary=f"Removed {raw_material_name}",
|
||||
changes=[{"field": raw_material_name, "label": raw_material_name, "before": _format_kg(removed_kg), "after": None}],
|
||||
)
|
||||
db.commit()
|
||||
|
||||
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
|
||||
@@ -437,6 +566,9 @@ def replace_editor_mix_formula(
|
||||
if mix is None:
|
||||
raise HTTPException(status_code=404, detail="Mix not found")
|
||||
|
||||
# Snapshot the formula as it stands so we can diff it against the saved one.
|
||||
before_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
|
||||
|
||||
raw_ids = [row.raw_material_id for row in payload.rows]
|
||||
if len(set(raw_ids)) != len(raw_ids):
|
||||
raise HTTPException(status_code=400, detail="Each raw material can only appear once in a mix")
|
||||
@@ -483,9 +615,35 @@ def replace_editor_mix_formula(
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.flush()
|
||||
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
|
||||
return resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
|
||||
after_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
|
||||
deltas = _formula_deltas(before_formula.ingredients, after_formula.ingredients)
|
||||
if deltas:
|
||||
record_change(
|
||||
db,
|
||||
session=session,
|
||||
entity_type=ENTITY_MIX,
|
||||
entity_id=mix_id,
|
||||
action="formula_updated",
|
||||
summary=f"Updated formula ({len(deltas)} ingredient {'change' if len(deltas) == 1 else 'changes'})",
|
||||
changes=deltas,
|
||||
)
|
||||
db.commit()
|
||||
return after_formula
|
||||
|
||||
|
||||
@router.get("/mixes/{mix_id}/history", response_model=list[EditorChangeEventRead])
|
||||
def get_editor_mix_history(
|
||||
mix_id: int,
|
||||
session: AuthSession = Depends(_require_editor_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_id = session.tenant_id or ""
|
||||
if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == tenant_id)) is None:
|
||||
raise HTTPException(status_code=404, detail="Mix not found")
|
||||
events = list_changes(db, tenant_id=tenant_id, entity_type=ENTITY_MIX, entity_id=mix_id)
|
||||
return [_serialize_change_event(event) for event in events]
|
||||
|
||||
|
||||
@router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead)
|
||||
@@ -662,6 +820,15 @@ def create_editor_ingredient(
|
||||
)
|
||||
db.add(material)
|
||||
try:
|
||||
db.flush()
|
||||
record_change(
|
||||
db,
|
||||
session=session,
|
||||
entity_type=ENTITY_INGREDIENT,
|
||||
entity_id=material.id,
|
||||
action="created",
|
||||
summary=f"Created ingredient “{material.name}”",
|
||||
)
|
||||
db.commit()
|
||||
except IntegrityError as exc:
|
||||
db.rollback()
|
||||
@@ -692,8 +859,32 @@ def update_editor_ingredient(
|
||||
updates["supplier"] = (updates["supplier"] or "").strip() or None
|
||||
if "unit_of_measure" in updates and updates["unit_of_measure"] is not None:
|
||||
updates["unit_of_measure"] = updates["unit_of_measure"].strip()
|
||||
before = {field: getattr(material, field) for field in updates}
|
||||
for field, value in updates.items():
|
||||
setattr(material, field, value)
|
||||
deltas = diff_fields(
|
||||
before,
|
||||
updates,
|
||||
{
|
||||
"name": "Name",
|
||||
"supplier": "Supplier",
|
||||
"unit_of_measure": "Unit of measure",
|
||||
"kg_per_unit": "Kg per unit",
|
||||
"status": "Status",
|
||||
"rounding_decimals": "Rounding (dp)",
|
||||
"notes": "Notes",
|
||||
},
|
||||
)
|
||||
if deltas:
|
||||
record_change(
|
||||
db,
|
||||
session=session,
|
||||
entity_type=ENTITY_INGREDIENT,
|
||||
entity_id=material.id,
|
||||
action="updated",
|
||||
summary=f"Updated {', '.join(delta['label'] for delta in deltas)}",
|
||||
changes=deltas,
|
||||
)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as exc:
|
||||
@@ -702,3 +893,16 @@ def update_editor_ingredient(
|
||||
db.refresh(material)
|
||||
usage = _ingredient_usage_counts(db, tenant_id)
|
||||
return _serialize_ingredient(material, usage.get(material.id, 0))
|
||||
|
||||
|
||||
@router.get("/ingredients/{ingredient_id}/history", response_model=list[EditorChangeEventRead])
|
||||
def get_editor_ingredient_history(
|
||||
ingredient_id: int,
|
||||
session: AuthSession = Depends(_require_editor_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_id = session.tenant_id or ""
|
||||
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == ingredient_id, RawMaterial.tenant_id == tenant_id)) is None:
|
||||
raise HTTPException(status_code=404, detail="Ingredient not found")
|
||||
events = list_changes(db, tenant_id=tenant_id, entity_type=ENTITY_INGREDIENT, entity_id=ingredient_id)
|
||||
return [_serialize_change_event(event) for event in events]
|
||||
|
||||
Reference in New Issue
Block a user