SysOps: deploy-all — 2026-06-09 10:41 UTC
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
"""Packaging generation API routes."""
|
||||
"""Packaging generation API routes — persisted in PostgreSQL."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -9,58 +10,229 @@ 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, generate_packaging
|
||||
from app.packaging.generator import FOODLINKK_BRAND, PACKAGING_TYPES, generate_packaging
|
||||
|
||||
router = APIRouter(prefix="/packaging", tags=["packaging"])
|
||||
|
||||
_PROJECTS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
class PackagingSpec(BaseModel):
|
||||
type: Literal["folding_box", "wrap", "round_label"]
|
||||
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]:
|
||||
spec_data = spec.model_dump()
|
||||
if not spec_data["brand"]:
|
||||
spec_data["brand"] = dict(FOODLINKK_BRAND)
|
||||
svg = generate_packaging(spec_data)
|
||||
project_id = uuid4().hex
|
||||
now = datetime.utcnow().isoformat() + "Z"
|
||||
_PROJECTS[project_id] = {
|
||||
"id": project_id,
|
||||
"created_at": now,
|
||||
"spec": spec_data,
|
||||
"svg": svg,
|
||||
}
|
||||
return {"id": project_id, "created_at": now, "svg": svg, "spec": spec_data}
|
||||
return _persist_packaging(spec)
|
||||
|
||||
|
||||
@router.get("/projects")
|
||||
def packaging_projects(limit: int = Query(default=30, ge=1, le=200)) -> dict[str, Any]:
|
||||
items = sorted(_PROJECTS.values(), key=lambda x: x["created_at"], reverse=True)[:limit]
|
||||
return {
|
||||
"items": [{"id": p["id"], "created_at": p["created_at"], "spec": p["spec"]} for p in items],
|
||||
"count": len(items),
|
||||
}
|
||||
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 = _PROJECTS.get(project_id)
|
||||
project = _get_project(project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Packaging project not found")
|
||||
|
||||
svg = project["svg"]
|
||||
filename = f"foodlinkk-packaging-{project_id[:8]}.{format}"
|
||||
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"),
|
||||
@@ -78,3 +250,57 @@ def packaging_download(project_id: str, format: str = Query(default="svg", patte
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user