Initial commit: aplicatie de cantarire si etichetare

Aplicatie Flask + SQLite pentru productie alimentara: cantarirea
componentelor unui produs finit si tiparirea etichetei prin QZ Tray.

- produse finite (retete) formate din componente cantarite
- designer vizual de etichete (mm/px, zona sigura, campuri variabile,
  cod QR cu continut din mai multe bucati)
- legare produs finit -> sablon de eticheta
- navigare pe doua taburi: Cantarire / Setari
- administrare: componente, produse finite, sabloane, campuri, istoric
- backup, resetare baza de date, checklist de pregatire, /healthz
- CLI de administrare si serviciu systemd pentru productie
- vendor/syncpulse-scale-bridge (GPL-3.0-or-later) pentru citirea cantarului
This commit is contained in:
2026-10-18 23:38:45 +03:00
commit bff6d70b17
107 changed files with 21428 additions and 0 deletions
+281
View File
@@ -0,0 +1,281 @@
"""Designer vizual: validare, salvare si randare pe eticheta."""
from __future__ import annotations
import json
import pytest
from cantarire import designer
def _designer_template(client, name="Designer test") -> int:
response = client.post("/sabloane/designer/nou", data={"name": name})
assert response.status_code == 302
with client.application.app_context():
from cantarire.repositories import list_templates
return [t for t in list_templates() if t["name"] == name][0]["id"]
def test_default_design_has_expected_blocks():
design = designer.default_design(100, 60)
types = [block["type"] for block in design["blocks"]]
assert "field" in types and "items" in types and "qr" in types
@pytest.mark.parametrize("width,height", [(100, 60), (80, 50), (60, 40), (150, 100)])
def test_default_design_stays_inside_safe_margin(width, height):
"""Elementele implicite trebuie sa fie in interiorul zonei sigure."""
margin = designer.SAFE_MARGIN_MM
design = designer.default_design(width, height)
for block in design["blocks"]:
assert block["x"] >= margin - 0.001, block
assert block["y"] >= margin - 0.001, block
assert block["x"] + block["w"] <= width - margin + 0.001, block
assert block["y"] + block["h"] <= height - margin + 0.001, block
def test_validate_design_clamps_out_of_bounds():
raw = {
"width_mm": 100,
"height_mm": 60,
"blocks": [
{"type": "text", "x": 200, "y": -5, "w": 500, "h": 999, "text": "X"},
{"type": "nope"},
{"type": "field", "field": "does.not.exist"},
],
}
design = designer.validate_design(raw, 100, 60)
assert len(design["blocks"]) == 2 # tipul necunoscut este ignorat
block = design["blocks"][0]
assert block["x"] <= 100 and block["y"] >= 0
assert block["w"] <= 100 and block["h"] <= 60
assert design["blocks"][1]["field"] == "recipe.name"
def test_validate_design_rejects_bad_json():
with pytest.raises(designer.ValidationError):
designer.validate_design("{not json", 100, 60)
def test_render_design_escapes_text_and_builds_fields():
design = designer.validate_design({
"width_mm": 100, "height_mm": 60,
"blocks": [
{"type": "text", "x": 1, "y": 1, "w": 50, "h": 5, "text": "<script>x</script>"},
{"type": "field", "x": 1, "y": 7, "w": 50, "h": 8,
"field": "recipe.name", "prefix": "", "suffix": ""},
{"type": "items", "x": 1, "y": 16, "w": 50, "h": 20},
{"type": "qr", "x": 60, "y": 16, "w": 20, "h": 20, "field": "package.qr"},
],
}, 100, 60)
html = designer.render_design(design, designer.demo_fields(), designer.demo_items())
assert "&lt;script&gt;" in html and "<script>" not in html
assert "Fasole cu carne" in html
assert "250 g" in html and "Carne" in html
assert "<svg" in html # QR inline
# -- continutul codului QR ---------------------------------------------------
def _qr_block(**extra):
block = {"type": "qr", "x": 2, "y": 2, "w": 20, "h": 20}
block.update(extra)
return block
def _qr(**extra):
design = designer.validate_design(
{"width_mm": 100, "height_mm": 60, "blocks": [_qr_block(**extra)]}, 100, 60)
return design["blocks"][0]
def test_qr_parts_can_combine_fields_and_literal_text():
block = _qr(separator="dash", parts=[
{"field": "recipe.code"},
{"text": "LOT"},
{"field": "package.lot"},
])
assert block["separator"] == "dash"
payload = designer.qr_payload(block, designer.demo_fields())
assert payload == "PF-001 - LOT - LOT-2610-A"
@pytest.mark.parametrize("separator,expected", [
("pipe", "PF-001 | LOT-2610-A"),
("dash", "PF-001 - LOT-2610-A"),
("slash", "PF-001/LOT-2610-A"),
("space", "PF-001 LOT-2610-A"),
("newline", "PF-001\nLOT-2610-A"),
("none", "PF-001LOT-2610-A"),
])
def test_qr_separator_variants(separator, expected):
block = _qr(separator=separator, parts=[
{"field": "recipe.code"}, {"field": "package.lot"},
])
assert designer.qr_payload(block, designer.demo_fields()) == expected
def test_qr_unknown_separator_falls_back_to_pipe():
block = _qr(separator="paranteze", parts=[{"field": "recipe.code"}])
assert block["separator"] == "pipe"
def test_qr_empty_field_parts_are_skipped():
block = _qr(separator="pipe", parts=[
{"field": "recipe.code"},
{"field": "package.lot"},
{"field": "company.address"},
])
fields = dict(designer.demo_fields())
fields["package.lot"] = ""
assert designer.qr_payload(block, fields) == "PF-001 | Str. Exemplu 1"
def test_qr_parts_reject_unknown_fields_and_empty_text():
block = _qr(parts=[
{"field": "does.not.exist"},
{"text": ""},
{"field": "package.lot"},
])
assert block["parts"] == [{"field": "package.lot"}]
def test_qr_legacy_field_becomes_a_single_part():
block = _qr(field="package.lot")
assert block["parts"] == [{"field": "package.lot"}]
assert designer.qr_payload(block, designer.demo_fields()) == "LOT-2610-A"
def test_qr_legacy_full_content_still_works():
block = _qr(field="package.qr")
assert block["parts"] == [{"field": "package.qr"}]
payload = designer.qr_payload(block, designer.demo_fields())
assert payload.startswith("PF-001 | LOT-2610-A | 450 g")
def test_qr_parts_are_limited():
parts = [{"field": "package.lot"} for _ in range(designer.MAX_QR_PARTS + 8)]
block = _qr(parts=parts)
assert len(block["parts"]) == designer.MAX_QR_PARTS
def test_qr_accepts_custom_fields():
block = _qr(parts=[{"field": "custom.contact"}])
assert block["parts"] == [{"field": "custom.contact"}]
assert designer.qr_payload(block, {"custom.contact": "Tel. 0700"}) == "Tel. 0700"
def test_qr_without_parts_uses_full_content():
block = {"type": "qr", "x": 1, "y": 1, "w": 20, "h": 20, "parts": []}
assert designer.qr_payload(block, designer.demo_fields()).startswith("PF-001 |")
def test_qr_payload_is_html_escaped_in_render():
design = designer.validate_design({
"width_mm": 100, "height_mm": 60,
"blocks": [_qr_block(parts=[{"text": "</svg><script>x</script>"}])],
}, 100, 60)
html = designer.render_design(design, designer.demo_fields(), designer.demo_items())
assert "<script>x" not in html
assert "<svg" in html
def test_qr_svg_differs_for_different_payloads():
a = designer.qr_svg("PF-001 | LOT-A")
b = designer.qr_svg("PF-002 | LOT-B")
assert a != b
assert "<svg" in a and "<svg" in b
def test_designer_page_loads(client):
template_id = _designer_template(client)
page = client.get(f"/sabloane/{template_id}/designer")
assert page.status_code == 200
html = page.get_data(as_text=True)
assert "designer" in html
assert "package.total_weight" in html # catalog de campuri
assert "canvas-blocks" in html
def test_designer_save_persists_and_renders(client):
template_id = _designer_template(client)
payload = {
"design": {
"version": 1, "width_mm": 80, "height_mm": 50,
"blocks": [
{"id": "b1", "type": "field", "x": 2, "y": 2, "w": 60, "h": 12,
"field": "recipe.name", "style": {"size": 6, "bold": True}},
{"id": "b2", "type": "field", "x": 2, "y": 16, "w": 60, "h": 12,
"field": "package.total_weight", "suffix": " g"},
],
}
}
response = client.post(f"/sabloane/{template_id}/designer", json=payload)
assert response.status_code == 200, response.get_json()
assert response.get_json()["ok"] is True
with client.application.app_context():
from cantarire.repositories import get_template
stored = get_template(template_id)
assert stored["design_json"]
assert stored["width_mm"] == 80 and stored["height_mm"] == 50
saved = json.loads(stored["design_json"])
assert len(saved["blocks"]) == 2
def test_designer_save_rejects_invalid_payload(client):
template_id = _designer_template(client)
response = client.post(f"/sabloane/{template_id}/designer", json={"design": "nope"})
assert response.status_code == 400
assert response.get_json()["ok"] is False
def test_designer_qr_endpoint(client):
response = client.get("/sabloane/designer/qr.svg?data=PF-001|LOT-1")
assert response.status_code == 200
assert response.mimetype == "image/svg+xml"
assert b"<svg" in response.data
def test_label_uses_design_when_present(client, recipe):
with client.application.app_context():
from cantarire.repositories import get_default_template, save_template_design
template = get_default_template()
design = designer.default_design(template["width_mm"], template["height_mm"])
save_template_design(template["id"], designer.dump_design(design))
package_id = client.post("/api/packages", json={"recipe_id": recipe["id"]}).get_json()["package"]["id"]
client.post(f"/api/packages/{package_id}/items", json={"weight": "250"})
done = client.post(f"/api/packages/{package_id}/items", json={"weight": "200"}).get_json()
label = done["label"]
assert "design-label" in label["html"]
assert "Fasole cu carne" in label["html"]
assert "450" in label["html"]
assert ".d-block" in label["css"]
page = client.get(label["print_url"] + "?auto=0")
assert page.status_code == 200
assert "design-label" in page.get_data(as_text=True)
def test_reset_design_restores_default(client):
template_id = _designer_template(client)
client.post(f"/sabloane/{template_id}/designer", json={
"design": {"width_mm": 100, "height_mm": 60,
"blocks": [{"type": "text", "x": 1, "y": 1, "w": 20, "h": 4, "text": "X"}]}
})
reset = client.post(f"/sabloane/{template_id}/designer/reset")
assert reset.status_code == 302
with client.application.app_context():
from cantarire import designer
from cantarire.repositories import get_template
stored = get_template(template_id)
design = designer.load_design(stored["design_json"], 100, 60)
assert len(design["blocks"]) == len(designer.default_design(100, 60)["blocks"])
assert stored["design_json"] != "" # sablonul nu rămâne fara eticheta