SysOps: deploy-all — 2026-06-09 10:41 UTC

This commit is contained in:
sysops
2026-06-09 10:41:13 +00:00
parent 69fe67cc0e
commit 21ea3a2c81
82 changed files with 8906 additions and 981 deletions
+252 -26
View File
@@ -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}