Files
foodlinkk-command-center/tools-api/app/packaging_routes.py
T
2026-06-09 10:41:13 +00:00

307 lines
12 KiB
Python

"""Packaging generation API routes — persisted in PostgreSQL."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any, Literal
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import Response
from pydantic import BaseModel, Field
from app.db import execute, fetch_all, fetch_one
from app.packaging.export import svg_to_pdf_bytes, svg_to_png_bytes
from app.packaging.generator import FOODLINKK_BRAND, PACKAGING_TYPES, generate_packaging
router = APIRouter(prefix="/packaging", tags=["packaging"])
class PackagingSpec(BaseModel):
type: Literal["folding_box", "wrap", "round_label", "sleeve", "pouch", "tray"] = "folding_box"
width_mm: float = Field(default=120, gt=0, le=4000)
height_mm: float = Field(default=80, gt=0, le=4000)
depth_mm: float = Field(default=40, ge=0, le=4000)
bleed_mm: float = Field(default=3, ge=0, le=20)
elements: dict[str, bool] = Field(default_factory=dict)
brand: dict[str, str] = Field(default_factory=dict)
text: dict[str, Any] = Field(default_factory=dict)
barcode_value: str | None = Field(default=None, max_length=64)
qr_value: str | None = Field(default=None, max_length=512)
design_name: str | None = Field(default=None, max_length=120)
project_id: int | None = None
project_name: str | None = None
client_id: int | None = None
created_by: str = "ceo"
class PackagingUpdate(BaseModel):
spec: PackagingSpec
class CopyToProjectBody(BaseModel):
target_project_id: int = Field(..., gt=0)
created_by: str = "ceo"
class PackagingEmailBody(BaseModel):
to: list[str] = Field(..., min_length=1)
subject: str = Field(..., min_length=1, max_length=255)
body: str = ""
cc: list[str] = Field(default_factory=list)
attach_formats: list[str] = Field(default_factory=lambda: ["pdf"])
client_id: int | None = None
def _normalize_spec(spec_data: dict[str, Any]) -> dict[str, Any]:
if not spec_data.get("brand"):
spec_data["brand"] = dict(FOODLINKK_BRAND)
if not spec_data.get("elements"):
spec_data["elements"] = {
"barcode": True,
"logo_area": True,
"fold_lines": True,
"cut_lines": True,
"nutrition_panel": False,
"ingredients": False,
"halal_badge": False,
"window": False,
"qr_code": False,
"glue_tabs": False,
"bleed": True,
"dimensions": True,
}
return spec_data
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
out = dict(row)
spec = out.get("spec") or {}
if isinstance(spec, str):
try:
spec = json.loads(spec)
except Exception:
spec = {}
out["spec"] = spec
for key in ("created_at",):
if out.get(key) is not None and hasattr(out[key], "isoformat"):
out[key] = out[key].isoformat()
return out
def _get_project(project_id: str) -> dict[str, Any] | None:
row = fetch_one(
"""
SELECT po.*, cp.name AS project_name, cp.nas_path, c.name AS client_name, c.id AS client_id
FROM packaging_outputs po
LEFT JOIN cockpit_projects cp ON cp.id = po.project_id
LEFT JOIN clients c ON c.id = cp.client_id
WHERE po.id = %s
""",
(project_id,),
)
return _serialize_row(dict(row)) if row else None
def _link_asset(cockpit_project_id: int, packaging_id: str, spec_data: dict[str, Any], created_by: str, file_path: str | None = None) -> None:
title = spec_data.get("design_name") or spec_data.get("text", {}).get("product_name") or f"Packaging {spec_data.get('type')}"
existing = fetch_one(
"SELECT id FROM project_assets WHERE project_id = %s AND asset_type = 'packaging' AND ref_id = %s LIMIT 1",
(cockpit_project_id, packaging_id),
)
payload = json.dumps({"packaging_id": packaging_id, "spec": spec_data})
if existing:
execute(
"""
UPDATE project_assets SET title = %s, payload = %s::jsonb, file_path = COALESCE(%s, file_path)
WHERE id = %s
""",
(title, payload, file_path, existing["id"]),
)
else:
fetch_one(
"""
INSERT INTO project_assets (project_id, asset_type, ref_id, title, file_path, payload, created_by, source_agent)
VALUES (%s, 'packaging', %s, %s, %s, %s::jsonb, %s, 'packaging')
RETURNING id
""",
(cockpit_project_id, packaging_id, title, file_path, payload, created_by),
)
execute("UPDATE cockpit_projects SET updated_at = NOW() WHERE id = %s", (cockpit_project_id,))
def _resolve_cockpit_project(spec: PackagingSpec) -> int | None:
cockpit_project_id = spec.project_id
if spec.project_name and not cockpit_project_id:
row = fetch_one(
"""
INSERT INTO cockpit_projects (name, client_id, description, created_by, project_type, updated_at)
VALUES (%s, %s, %s, %s, 'packaging', NOW())
RETURNING id
""",
(spec.project_name.strip(), spec.client_id, "Packaging project", spec.created_by),
)
cockpit_project_id = int(row["id"]) if row else None
return cockpit_project_id
def _persist_packaging(spec: PackagingSpec, packaging_id: str | None = None) -> dict[str, Any]:
spec_data = _normalize_spec(spec.model_dump(exclude={"project_id", "project_name", "client_id", "created_by"}))
svg = generate_packaging(spec_data)
pid = packaging_id or uuid4().hex
cockpit_project_id = _resolve_cockpit_project(spec)
existing = _get_project(pid) if packaging_id else None
if existing:
fetch_one(
"""
UPDATE packaging_outputs SET project_id = %s, spec = %s::jsonb, svg = %s, created_by = %s
WHERE id = %s RETURNING id
""",
(cockpit_project_id, json.dumps(spec_data), svg, spec.created_by, pid),
)
else:
fetch_one(
"""
INSERT INTO packaging_outputs (id, project_id, spec, svg, created_by, created_at)
VALUES (%s, %s, %s::jsonb, %s, %s, NOW())
RETURNING id
""",
(pid, cockpit_project_id, json.dumps(spec_data), svg, spec.created_by),
)
if cockpit_project_id:
_link_asset(cockpit_project_id, pid, spec_data, spec.created_by)
return {
"id": pid,
"cockpit_project_id": cockpit_project_id,
"created_at": datetime.now(timezone.utc).isoformat(),
"svg": svg,
"spec": spec_data,
}
@router.get("/types")
def packaging_types() -> dict[str, Any]:
labels = {
"folding_box": "Folding box (sluitdoos)",
"wrap": "Wrap / banderole",
"round_label": "Rond label",
"sleeve": "Sleeve / huls",
"pouch": "Pouch / zak",
"tray": "Tray / schaal",
}
return {"items": [{"id": t, "label": labels.get(t, t)} for t in PACKAGING_TYPES]}
@router.post("/preview")
def packaging_preview(spec: PackagingSpec) -> dict[str, Any]:
spec_data = _normalize_spec(spec.model_dump(exclude={"project_id", "project_name", "client_id", "created_by"}))
return {"svg": generate_packaging(spec_data), "spec": spec_data}
@router.post("/generate")
def packaging_generate(spec: PackagingSpec) -> dict[str, Any]:
return _persist_packaging(spec)
@router.get("/projects")
def packaging_projects(limit: int = Query(default=30, ge=1, le=200)) -> dict[str, Any]:
rows = fetch_all(
"""
SELECT po.id, po.project_id AS cockpit_project_id, po.spec, po.created_by, po.created_at,
cp.name AS project_name, cp.nas_path, c.name AS client_name
FROM packaging_outputs po
LEFT JOIN cockpit_projects cp ON cp.id = po.project_id
LEFT JOIN clients c ON c.id = cp.client_id
ORDER BY po.created_at DESC
LIMIT %s
""",
(limit,),
)
return {"items": [_serialize_row(dict(r)) for r in rows], "count": len(rows)}
@router.get("/download/{project_id}")
def packaging_download(project_id: str, format: str = Query(default="svg", pattern="^(svg|png|pdf)$")):
project = _get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="Packaging project not found")
svg = project["svg"]
slug = (project.get("spec") or {}).get("design_name") or project_id[:8]
safe_slug = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in slug.lower())[:40]
filename = f"foodlinkk-{safe_slug}.{format}"
if format == "svg":
return Response(
content=svg.encode("utf-8"),
media_type="image/svg+xml",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
if format == "png":
return Response(
content=svg_to_png_bytes(svg),
media_type="image/png",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
return Response(
content=svg_to_pdf_bytes(svg),
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/{packaging_id}")
def packaging_get(packaging_id: str) -> dict[str, Any]:
row = _get_project(packaging_id)
if not row:
raise HTTPException(status_code=404, detail="Packaging design not found")
return {"item": row}
@router.patch("/{packaging_id}")
def packaging_update(packaging_id: str, body: PackagingUpdate) -> dict[str, Any]:
if not _get_project(packaging_id):
raise HTTPException(status_code=404, detail="Packaging design not found")
existing = _get_project(packaging_id) or {}
spec = body.spec
if not spec.project_id and existing.get("project_id"):
spec.project_id = int(existing["project_id"])
result = _persist_packaging(spec, packaging_id=packaging_id)
return result
@router.post("/{packaging_id}/duplicate")
def packaging_duplicate(packaging_id: str, created_by: str = Query(default="ceo")) -> dict[str, Any]:
row = _get_project(packaging_id)
if not row:
raise HTTPException(status_code=404, detail="Packaging design not found")
spec = dict(row.get("spec") or {})
spec["design_name"] = (spec.get("design_name") or spec.get("text", {}).get("product_name") or "Design") + " (kopie)"
payload = PackagingSpec(**{**spec, "project_id": int(row["project_id"]) if row.get("project_id") else None, "created_by": created_by})
return _persist_packaging(payload)
@router.post("/{packaging_id}/copy-to-project")
def packaging_copy_to_project(packaging_id: str, body: CopyToProjectBody) -> dict[str, Any]:
row = _get_project(packaging_id)
if not row:
raise HTTPException(status_code=404, detail="Packaging design not found")
target = fetch_one("SELECT id, name FROM cockpit_projects WHERE id = %s", (body.target_project_id,))
if not target:
raise HTTPException(status_code=404, detail="Target project not found")
spec = dict(row.get("spec") or {})
spec["design_name"] = (spec.get("design_name") or "Design") + f" → {target['name']}"
payload = PackagingSpec(**{**spec, "project_id": body.target_project_id, "created_by": body.created_by})
return _persist_packaging(payload)
@router.delete("/{packaging_id}")
def packaging_delete(packaging_id: str) -> dict[str, Any]:
if not _get_project(packaging_id):
raise HTTPException(status_code=404, detail="Packaging design not found")
execute("DELETE FROM project_assets WHERE asset_type = 'packaging' AND ref_id = %s", (packaging_id,))
execute("DELETE FROM packaging_outputs WHERE id = %s", (packaging_id,))
return {"ok": True, "id": packaging_id}