mitai-jinkendo/backend/data_layer/food_visibility.py
Lars e452d9a8fe
All checks were successful
Deploy Development / deploy (push) Successful in 1m7s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 23s
fix: zentrale Lebensmittel fuer Admins aenderbar
Persoenliche Liste bleibt beim Owner; zentrale Eintraege oeffnen den Admin-Editor. Admin-Katalog trennt beide Listen, persoenliche bleiben sichtbar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 13:01:41 +02:00

122 lines
3.7 KiB
Python

"""Who may see and map a catalog food.
Scopes:
- personal — ``manual_user`` with ``owner_profile_id`` (only that profile)
- global — ``manual_admin`` (all users; no owner)
- bls — official BLS (all users; no owner)
Name-mappings: a *user* mapping may target any food visible to that user.
A *global* mapping (``profile_id IS NULL``) may only target global/BLS foods,
never another user's private entry.
"""
from __future__ import annotations
from typing import Any
VIS_PERSONAL = "personal"
VIS_GLOBAL = "global"
VIS_BLS = "bls"
SQL_VISIBLE_TO_PROFILE = "(owner_profile_id IS NULL OR owner_profile_id = %s)"
SQL_GLOBAL_ONLY = "owner_profile_id IS NULL"
_KIND_VIS = {
"official_bls": VIS_BLS,
"manual_admin": VIS_GLOBAL,
"manual_user": VIS_PERSONAL,
}
_VIS_RANK = {VIS_PERSONAL: 0, VIS_GLOBAL: 1, VIS_BLS: 2}
def same_id(a, b) -> bool:
return str(a or "").replace("-", "").lower() == str(b or "").replace("-", "").lower()
def visibility_of(food: dict[str, Any] | None) -> str:
if not food:
return VIS_BLS
kind = food.get("catalog_kind")
if kind in _KIND_VIS:
return _KIND_VIS[kind]
if food.get("owner_profile_id"):
return VIS_PERSONAL
return VIS_GLOBAL
def visibility_rank(food: dict[str, Any] | None) -> int:
return _VIS_RANK.get(visibility_of(food), 3)
def is_active(food: dict[str, Any] | None) -> bool:
if not food:
return False
if "is_active" not in food:
return True
return bool(food.get("is_active"))
def is_visible_to(food: dict[str, Any] | None, profile_id: str | None) -> bool:
if not is_active(food):
return False
owner = food.get("owner_profile_id")
if owner is None:
return True
return profile_id is not None and same_id(owner, profile_id)
def is_global_mapping_target(food: dict[str, Any] | None) -> bool:
return is_active(food) and food.get("owner_profile_id") is None
def load_catalog_food(cur, food_id: str) -> dict[str, Any] | None:
cur.execute(
"""
SELECT id, name_de, catalog_kind, owner_profile_id, is_active, bls_code
FROM food_catalog
WHERE id = %s
""",
(food_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def require_visible_food(cur, food_id: str, profile_id: str) -> dict[str, Any]:
food = load_catalog_food(cur, food_id)
if not is_visible_to(food, profile_id):
raise LookupError("Lebensmittel nicht gefunden")
return food
def require_global_mapping_target(cur, food_id: str) -> dict[str, Any]:
food = load_catalog_food(cur, food_id)
if not food or not is_active(food):
raise LookupError("Lebensmittel nicht gefunden")
if not is_global_mapping_target(food):
raise PermissionError("Globales Mapping darf nur auf zentrale oder BLS-Lebensmittel zeigen")
return food
def require_mapping_target(cur, food_id: str, mapping_profile_id: str | None) -> dict[str, Any]:
if mapping_profile_id:
return require_visible_food(cur, food_id, mapping_profile_id)
return require_global_mapping_target(cur, food_id)
def can_edit_food(food: dict[str, Any] | None, profile_id: str | None, *, is_admin: bool = False) -> bool:
"""Owner ändert persönlich; Admin ändert zentral (und im Admin-Katalog auch fremde Manuals)."""
if not is_active(food):
return False
kind = food.get("catalog_kind")
if kind == "manual_user":
return same_id(food.get("owner_profile_id"), profile_id) or bool(is_admin)
if kind == "manual_admin":
return bool(is_admin)
return False
def attach_visibility(row: dict[str, Any]) -> dict[str, Any]:
row["visibility"] = visibility_of(row)
return row