"""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}