mitai-jinkendo/backend/data_layer/food_attributes.py
Lars d35087ad84
All checks were successful
Deploy Development / deploy (push) Successful in 1m8s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
feat: Eigene Stoffe am Lebensmittel anlegen
EPA-Chips und mg/µg vom Etikett; fehlende Parameter ohne Admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 15:47:49 +02:00

188 lines
5.9 KiB
Python

"""Catalog attribute definitions and numeric values (BLS EAV + extensions)."""
from __future__ import annotations
import re
from typing import Any
_ATTR_KEY_RE = re.compile(r"[^A-Z0-9]+")
_ALLOWED_UNITS = {"g": "g", "mg": "mg", "µg": "µg", "ug": "µg", "mcg": "µg", "kcal": "kcal", "kj": "kJ"}
MACRO_TO_KEY = {
"kcal": "ENERCC",
"protein_g": "PROT625",
"fat_g": "FAT",
"carbs_g": "CHO",
}
def scale_to_per_100g(value: float, serving_g: float | None) -> float:
if not serving_g or serving_g <= 0 or serving_g == 100:
return float(value)
return float(value) * (100.0 / float(serving_g))
def list_numeric_attributes(
cur, query: str = "", limit: int = 40, keys: list[str] | None = None
) -> list[dict[str, Any]]:
lim = min(max(int(limit or 40), 1), 80)
wanted = [str(k).strip() for k in (keys or []) if str(k).strip()]
if wanted:
cur.execute(
"""
SELECT id, attr_key, name_de, name_en, unit, category, origin
FROM food_attributes
WHERE is_active = true AND data_type = 'num_per_100g'
AND attr_key = ANY(%s)
ORDER BY sort_order, attr_key
""",
(wanted,),
)
return [dict(r) for r in cur.fetchall()]
q = (query or "").strip()
if q:
like = f"%{q}%"
cur.execute(
"""
SELECT id, attr_key, name_de, name_en, unit, category, origin
FROM food_attributes
WHERE is_active = true AND data_type = 'num_per_100g'
AND (
attr_key ILIKE %s OR name_de ILIKE %s
OR COALESCE(name_en, '') ILIKE %s
)
ORDER BY
CASE WHEN attr_key ILIKE %s THEN 0
WHEN name_de ILIKE %s THEN 1
ELSE 2 END,
sort_order, attr_key
LIMIT %s
""",
(like, like, like, q, f"{q}%", lim),
)
else:
cur.execute(
"""
SELECT id, attr_key, name_de, name_en, unit, category, origin
FROM food_attributes
WHERE is_active = true AND data_type = 'num_per_100g'
ORDER BY sort_order, attr_key
LIMIT %s
""",
(lim,),
)
return [dict(r) for r in cur.fetchall()]
def write_numeric_attributes(cur, food_id: str, values: dict[str, Any] | None) -> int:
if not values:
return 0
written = 0
for raw_key, raw_val in values.items():
key = str(raw_key or "").strip()
if not key or raw_val is None or raw_val == "":
continue
try:
num = float(raw_val)
except (TypeError, ValueError):
continue
cur.execute(
"""
SELECT id FROM food_attributes
WHERE attr_key = %s AND is_active = true AND data_type = 'num_per_100g'
""",
(key,),
)
row = cur.fetchone()
if not row:
continue
cur.execute(
"""
INSERT INTO food_attribute_values (food_id, attribute_id, value_num, is_trace)
VALUES (%s, %s, %s, false)
ON CONFLICT (food_id, attribute_id)
DO UPDATE SET value_num = EXCLUDED.value_num, updated_at = NOW()
""",
(food_id, row["id"], num),
)
written += 1
return written
def macros_and_attributes_to_values(
macros: dict[str, Any] | None,
attributes: dict[str, Any] | None,
serving_g: float | None = None,
) -> dict[str, float]:
out: dict[str, float] = {}
for field, key in MACRO_TO_KEY.items():
if not macros or field not in macros or macros[field] is None or macros[field] == "":
continue
try:
out[key] = scale_to_per_100g(float(macros[field]), serving_g)
except (TypeError, ValueError):
continue
for key, raw in (attributes or {}).items():
if raw is None or raw == "":
continue
try:
out[str(key)] = scale_to_per_100g(float(raw), serving_g)
except (TypeError, ValueError):
continue
return out
def extra_attributes_for_food(cur, food_id: str) -> dict[str, float]:
cur.execute(
"""
SELECT a.attr_key, v.value_num
FROM food_attribute_values v
JOIN food_attributes a ON a.id = v.attribute_id
WHERE v.food_id = %s AND a.data_type = 'num_per_100g'
AND v.value_num IS NOT NULL AND v.is_trace = false
AND NOT (a.attr_key = ANY(%s))
ORDER BY a.sort_order, a.attr_key
""",
(food_id, list(MACRO_TO_KEY.values())),
)
return {r["attr_key"]: float(r["value_num"]) for r in cur.fetchall()}
def normalize_attr_key(raw: str | None) -> str:
key = _ATTR_KEY_RE.sub("_", (raw or "").strip().upper()).strip("_")
return key[:64]
def normalize_attr_unit(raw: str | None) -> str:
return _ALLOWED_UNITS.get((raw or "g").strip().lower(), "g")
def create_extension_attribute(cur, name_de: str, unit: str = "g", attr_key: str | None = None) -> dict[str, Any]:
name = (name_de or "").strip()
if not name:
raise ValueError("Name fehlt")
key = normalize_attr_key(attr_key or name)
if not key:
raise ValueError("attr_key fehlt")
unit_n = normalize_attr_unit(unit)
cur.execute(
"""
SELECT id, attr_key, name_de, name_en, unit, category, origin
FROM food_attributes
WHERE attr_key = %s AND is_active = true
""",
(key,),
)
existing = cur.fetchone()
if existing:
return dict(existing)
cur.execute(
"""
INSERT INTO food_attributes
(attr_key, name_de, name_en, unit, category, data_type, origin, sort_order)
VALUES (%s, %s, %s, %s, 'extension', 'num_per_100g', 'extension', 9100)
RETURNING id, attr_key, name_de, name_en, unit, category, origin
""",
(key, name, None, unit_n),
)
return dict(cur.fetchone())