"""Anbindung an eine selbst gehostete Mealie-Instanz (Rezepte). Vorbild ist ``services/oidc.py``, nicht ``off.py``. Der Unterschied ist Absicht: Bei Open Food Facts ist „kein Treffer" der Normalfall und wird still zu ``None``. Hier ist „Mealie antwortet nicht" ein Fehler, den der Nutzer sehen muss — sonst stünde eine leere Rezeptliste da, und niemand wüsste, ob es keine Rezepte gibt oder der Server weg ist. Zugangsdaten: Der Admin tippt Adresse, Benutzername und Passwort **einmal**. Vorrania meldet sich damit an, lässt sich von Mealie ein Dauertoken ausstellen und verwirft das Passwort sofort. Gespeichert wird nur das Token — widerrufbar in Mealie unter Profil ▸ API-Tokens. """ from __future__ import annotations import time import httpx from sqlalchemy.orm import Session from ..config import get_settings from ..models import IntegrationSecret, Setting # Schlüssel in den Einstellungen bzw. in der Geheimnis-Tabelle. BASE_URL_KEY = "mealie_base_url" SECRET_SERVICE = "mealie" # Name des Tokens, wie er in Mealie erscheint – damit erkennbar ist, wer da # zugreift, und man es gezielt widerrufen kann. TOKEN_NAME = "Vorrania" CACHE_SEKUNDEN = 3600.0 # erkannte Pfadvariante REZEPTE_CACHE_SEKUNDEN = 60.0 # kurz: ein neues Rezept soll bald auftauchen # Pfadvariante je Adresse. Mealie 2.x legt die Haushalts-Routen unter # /api/households/, 1.x unter /api/groups/. Die Rezeptpfade sind in beiden # gleich; der Unterschied zählt erst für die Einkaufslisten. _variante_cache: dict[str, tuple[float, str]] = {} _rezepte_cache: dict[str, tuple[float, dict]] = {} # Fuer Tests austauschbar – ohne das muesste jeder Test einen Server starten. _transport: httpx.BaseTransport | None = None class MealieFehler(Exception): """Mealie hat nicht geantwortet oder abgelehnt. ``kennung`` ist die kurze Kennung für die Oberfläche, ``args[0]`` der Klartext fürs Protokoll – wie ``OidcFehler``. **Das Passwort steht in keinem der beiden.** """ def __init__(self, kennung: str, text: str) -> None: super().__init__(text) self.kennung = kennung def cache_leeren() -> None: """Nur für Tests und zum erzwungenen Neuladen.""" _variante_cache.clear() _rezepte_cache.clear() def setze_transport(transport: httpx.BaseTransport | None) -> None: """Nur für Tests: HTTP-Antworten vorgeben, ohne einen Server zu starten.""" global _transport _transport = transport def _client() -> httpx.Client: return httpx.Client( timeout=get_settings().mealie_timeout_seconds, headers={"User-Agent": "Vorrania-Selfhosted/1.0"}, follow_redirects=True, transport=_transport, ) # -------------------------------------------------------------------------- # Konfiguration # -------------------------------------------------------------------------- def basis_adresse(db: Session) -> str: row = db.get(Setting, BASE_URL_KEY) return (row.value if row else "").strip().rstrip("/") def token(db: Session) -> str: row = db.get(IntegrationSecret, SECRET_SERVICE) return row.secret if row else "" def eingerichtet(db: Session) -> bool: return bool(basis_adresse(db)) and bool(token(db)) def _zugang(db: Session) -> tuple[str, str]: basis, tok = basis_adresse(db), token(db) if not basis or not tok: raise MealieFehler( "nicht_eingerichtet", "Für Mealie sind noch keine Zugangsdaten hinterlegt.", ) return basis, tok def speichere_token(db: Session, wert: str) -> None: row = db.get(IntegrationSecret, SECRET_SERVICE) if row is None: db.add(IntegrationSecret(service=SECRET_SERVICE, secret=wert)) else: row.secret = wert db.commit() cache_leeren() def loesche_zugang(db: Session) -> None: row = db.get(IntegrationSecret, SECRET_SERVICE) if row is not None: db.delete(row) adresse = db.get(Setting, BASE_URL_KEY) if adresse is not None: db.delete(adresse) db.commit() cache_leeren() # -------------------------------------------------------------------------- # Einrichten: Passwort einmal, danach nur noch das Dauertoken # -------------------------------------------------------------------------- def hole_dauertoken(base_url: str, username: str, password: str) -> str: """Einmal anmelden, Dauertoken ausstellen lassen, zurückgeben. 1. ``POST /api/auth/token`` mit Benutzername und Passwort als Formularfelder liefert ein kurzlebiges Sitzungstoken. 2. Damit ``POST /api/users/api-tokens`` → ein Dauertoken (rund fünf Jahre), das genau einmal im Klartext in der Antwort steht. Das Passwort verlässt diese Funktion nicht: Es steht in keinem Rückgabewert, in keiner Fehlermeldung und in keiner Protokollzeile. """ basis = base_url.strip().rstrip("/") if not basis: raise MealieFehler("nicht_eingerichtet", "Bitte die Adresse von Mealie angeben.") with _client() as client: try: antwort = client.post( f"{basis}/api/auth/token", data={"username": username, "password": password}, ) except httpx.HTTPError as fehler: raise MealieFehler( "nicht_erreichbar", f"Mealie unter {basis} nicht erreichbar: {fehler}" ) from fehler if antwort.status_code in (401, 403): raise MealieFehler( "anmeldung_fehlgeschlagen", "Benutzername oder Passwort stimmen bei Mealie nicht.", ) if antwort.status_code >= 400: raise MealieFehler( "abgelehnt", f"Mealie hat die Anmeldung abgelehnt (HTTP {antwort.status_code}).", ) try: sitzung = antwort.json().get("access_token") except ValueError: sitzung = None if not sitzung: raise MealieFehler( "abgelehnt", "Mealie hat kein Sitzungstoken zurückgegeben." ) try: erstellt = client.post( f"{basis}/api/users/api-tokens", json={"name": TOKEN_NAME, "integration_id": "vorrania"}, headers={"Authorization": f"Bearer {sitzung}"}, ) except httpx.HTTPError as fehler: raise MealieFehler( "nicht_erreichbar", f"Mealie nicht erreichbar: {fehler}" ) from fehler if erstellt.status_code >= 400: raise MealieFehler( "kein_dauertoken", f"Mealie wollte kein Dauertoken ausstellen (HTTP {erstellt.status_code}).", ) try: dauertoken = erstellt.json().get("token") except ValueError: dauertoken = None if not dauertoken: raise MealieFehler( "kein_dauertoken", "Mealie hat kein Dauertoken zurückgegeben." ) return dauertoken # -------------------------------------------------------------------------- # Abfragen # -------------------------------------------------------------------------- def _hole(db: Session, pfad: str) -> dict: basis, tok = _zugang(db) with _client() as client: try: antwort = client.get( f"{basis}{pfad}", headers={"Authorization": f"Bearer {tok}"} ) except httpx.HTTPError as fehler: raise MealieFehler( "nicht_erreichbar", f"Mealie unter {basis} nicht erreichbar: {fehler}" ) from fehler if antwort.status_code in (401, 403): raise MealieFehler( "token_fehlt", "Mealie weist das hinterlegte Token zurück. Bitte die Verbindung erneuern.", ) if antwort.status_code == 404: raise MealieFehler("nicht_gefunden", f"Mealie kennt {pfad} nicht.") if antwort.status_code >= 400: raise MealieFehler( "abgelehnt", f"Mealie antwortete mit HTTP {antwort.status_code} auf {pfad}." ) try: return antwort.json() except ValueError as fehler: raise MealieFehler("abgelehnt", "Mealie antwortete nicht mit JSON.") from fehler def pfadvariante(db: Session) -> str: """„households" (Mealie 2.x) oder „groups" (1.x), gepuffert je Adresse. Die Rezeptpfade sind in beiden Fassungen gleich; der Unterschied zählt erst für die Einkaufslisten. Geprüft wird mit einer Anfrage: 404 heißt „falscher Pfad", alles andere „richtiger". Bewusst nicht über die Versionsnummer — die melden Installationen uneinheitlich. """ basis = basis_adresse(db) gepuffert = _variante_cache.get(basis) if gepuffert and gepuffert[0] > time.monotonic(): return gepuffert[1] variante = "households" try: _hole(db, "/api/households/shopping/lists?perPage=1") except MealieFehler as fehler: if fehler.kennung == "nicht_gefunden": variante = "groups" elif fehler.kennung != "abgelehnt": raise _variante_cache[basis] = (time.monotonic() + CACHE_SEKUNDEN, variante) return variante def version(db: Session) -> str | None: """Versionsangabe der Instanz, nur zur Anzeige. Fehlt sie, ist das kein Fehler.""" try: return str(_hole(db, "/api/app/about").get("version") or "") or None except MealieFehler: return None def rezepte(db: Session, *, seite: int = 1, pro_seite: int = 50, suche: str | None = None) -> dict: """Eine Seite der Rezeptliste. Kurz gepuffert, damit Blättern nicht hakt.""" pfad = f"/api/recipes?page={seite}&perPage={pro_seite}" if suche: pfad += f"&search={httpx.QueryParams({'s': suche})['s']}" schluessel = f"{basis_adresse(db)}|{pfad}" gepuffert = _rezepte_cache.get(schluessel) if gepuffert and gepuffert[0] > time.monotonic(): return gepuffert[1] daten = _hole(db, pfad) _rezepte_cache[schluessel] = (time.monotonic() + REZEPTE_CACHE_SEKUNDEN, daten) return daten def rezept(db: Session, slug: str) -> dict: """Ein einzelnes Rezept samt Zutaten. Bewusst **nicht** gepuffert – es wird gezielt geöffnet und soll dann aktuell sein.""" return _hole(db, f"/api/recipes/{slug}")