307 lines
12 KiB
Python
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}
|