Mealie anbinden: Passwort einmal, danach nur noch ein Dauertoken
Unter Einstellungen gibt es jetzt eine Karte "Mealie (Rezepte)": Adresse, Benutzername, Passwort. Vorrania meldet sich damit EINMAL an (POST /api/auth/token), laesst sich von Mealie ueber POST /api/users/api-tokens ein Dauertoken ausstellen und verwirft das Passwort sofort - es steht in keiner Zeile der Datenbank, in keiner Antwort und in keinem Fehlertext. Wer sein Passwort gar nicht erst eintippen will, haekelt "Ich gebe lieber selbst ein Token ein" an. Das Token liegt NICHT in den Einstellungen: GET /settings liefert jedem angemeldeten Nutzer alle Zeilen im Klartext. Eine Ausschlussliste dort waere beim naechsten Geheimnis vergessen, deshalb eine eigene Tabelle ohne jeden Leseweg - setzen, loeschen, und die Auskunft "ist gesetzt". Was keinen Ausgang hat, kann nicht durchrutschen. Ein Test prueft genau das. Der Dienst folgt services/oidc.py, nicht off.py: 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. Die Kennungen trennen auch, was verschieden ist - ein falsches Passwort (400) ist etwas anderes als ein weggetretener Server (502). Mealie 1.x legt die Haushalts-Routen unter /api/groups/, 2.x unter /api/households/. Erkannt wird das zur Laufzeit mit einer Anfrage und dann eine Stunde gepuffert; ueber die Versionsnummer waere es unzuverlaessig, die melden Installationen uneinheitlich. Rezeptliste und -abgleich kommen als Naechstes; dies ist der Teil, mit dem sich der Server ueberhaupt erst verbinden laesst. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
37ab1ba47d
commit
7d8a54a3fc
@@ -39,6 +39,9 @@ class Settings(BaseSettings):
|
||||
# Kategorie etwas sagen. Im Betrieb entscheidet die Einstellung
|
||||
# "open_days"; dieser Wert gilt nur, solange dort nichts gesetzt ist.
|
||||
open_days_default: int = 3
|
||||
# Mealie: Adresse und Token stehen in der Datenbank (Admin traegt sie in der
|
||||
# Oberflaeche ein), hier nur die Geduld fuer eine Antwort.
|
||||
mealie_timeout_seconds: float = 10.0
|
||||
|
||||
# CORS (comma separated); "*" allows all
|
||||
cors_origins: str = "*"
|
||||
|
||||
@@ -19,6 +19,7 @@ from .routers import (
|
||||
loans,
|
||||
locations,
|
||||
maintenance,
|
||||
mealie,
|
||||
measures,
|
||||
oidc,
|
||||
package_types,
|
||||
@@ -325,6 +326,7 @@ app.include_router(branding.router)
|
||||
app.include_router(categories.router)
|
||||
app.include_router(maintenance.router)
|
||||
app.include_router(measures.router)
|
||||
app.include_router(mealie.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(shops.router)
|
||||
app.include_router(field_definitions.router)
|
||||
|
||||
@@ -790,6 +790,31 @@ class Setting(Base):
|
||||
value: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
|
||||
class IntegrationSecret(Base):
|
||||
"""Zugangsgeheimnis für einen externen Dienst (heute: das Mealie-Dauertoken).
|
||||
|
||||
Bewusst **nicht** in :class:`Setting`: ``GET /settings`` liefert jedem
|
||||
angemeldeten Nutzer alle Zeilen im Klartext. Eine Ausschlussliste dort wäre
|
||||
beim nächsten Geheimnis vergessen. Diese Tabelle hat deshalb überhaupt
|
||||
keinen Leseweg nach außen — setzen, löschen, und eine Auskunft „ist
|
||||
gesetzt". Was keinen Ausgang hat, kann nicht durchrutschen.
|
||||
|
||||
Anders als bei :class:`ApiToken` muss der Klartext erhalten bleiben: Wir
|
||||
legen ihn einem fremden Dienst vor, ein Hash nützte dort nichts. Er liegt
|
||||
damit unverschlüsselt in der Datenbank — eine Verschlüsselung wäre Theater,
|
||||
solange der Schlüssel daneben läge. Der Schutz ist: kein Leseweg, nur Admin
|
||||
beim Setzen, nie in einer Antwort.
|
||||
"""
|
||||
|
||||
__tablename__ = "integration_secrets"
|
||||
|
||||
service: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
secret: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_now, onupdate=_now
|
||||
)
|
||||
|
||||
|
||||
class Shop(Base):
|
||||
"""Bezugsquelle / Geschäft, aus dem ein Gegenstand stammt ("gekauft bei").
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Mealie einrichten und prüfen.
|
||||
|
||||
Die Zugangsdaten nimmt dieser Router entgegen, gibt sie aber nie zurück. Die
|
||||
Oberfläche erfährt nur „eingerichtet ja/nein" — dasselbe Muster wie bei
|
||||
``GET /auth/oidc/providers``, wo Client-Secrets ebenfalls nie in der Antwort
|
||||
stehen.
|
||||
|
||||
Bewusst eigene Routen mit JSON-Rumpf statt ``PUT /settings/{key}?value=``:
|
||||
Query-Parameter landen in Zugriffsprotokollen, und ein Passwort hat dort
|
||||
nichts zu suchen.
|
||||
"""
|
||||
|
||||
# ACHTUNG: Kein ``from __future__ import annotations`` – siehe package_types.py.
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import Setting, User
|
||||
from ..schemas import (
|
||||
MealieAvailable,
|
||||
MealieConfigIn,
|
||||
MealieStatus,
|
||||
MealieTokenIn,
|
||||
)
|
||||
from ..services import mealie
|
||||
|
||||
router = APIRouter(prefix="/mealie", tags=["mealie"])
|
||||
|
||||
|
||||
def _setze_adresse(db: Session, base_url: str) -> str:
|
||||
sauber = base_url.strip().rstrip("/")
|
||||
row = db.get(Setting, mealie.BASE_URL_KEY)
|
||||
if row is None:
|
||||
db.add(Setting(key=mealie.BASE_URL_KEY, value=sauber))
|
||||
else:
|
||||
row.value = sauber
|
||||
db.commit()
|
||||
return sauber
|
||||
|
||||
|
||||
def _status(db: Session, pruefen: bool) -> MealieStatus:
|
||||
adresse = mealie.basis_adresse(db)
|
||||
hat_token = bool(mealie.token(db))
|
||||
status_out = MealieStatus(
|
||||
configured=bool(adresse) and hat_token,
|
||||
base_url=adresse,
|
||||
token_set=hat_token,
|
||||
)
|
||||
if not pruefen or not status_out.configured:
|
||||
return status_out
|
||||
try:
|
||||
status_out.version = mealie.version(db)
|
||||
# Eine echte Abfrage – „erreichbar" soll heissen, dass auch das Token gilt.
|
||||
mealie.rezepte(db, pro_seite=1)
|
||||
status_out.reachable = True
|
||||
except mealie.MealieFehler as fehler:
|
||||
status_out.reachable = False
|
||||
status_out.error = str(fehler)
|
||||
return status_out
|
||||
|
||||
|
||||
@router.get("/status", response_model=MealieStatus)
|
||||
def mealie_status(
|
||||
pruefen: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> MealieStatus:
|
||||
"""Was eingerichtet ist – ohne das Token. ``pruefen=true`` fragt Mealie an."""
|
||||
return _status(db, pruefen)
|
||||
|
||||
|
||||
@router.get("/available", response_model=MealieAvailable)
|
||||
def mealie_available(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> MealieAvailable:
|
||||
"""Ob die Rezept-Seite etwas anzeigen kann.
|
||||
|
||||
Für alle angemeldeten Benutzer, damit die Oberfläche den Menüpunkt nur dann
|
||||
zeigt. Anders als bei den Anmeldeanbietern **mit** Anmeldung: Es gibt keinen
|
||||
Grund, Unbekannten zu verraten, dass hier eine Mealie-Instanz hängt.
|
||||
"""
|
||||
return MealieAvailable(available=mealie.eingerichtet(db))
|
||||
|
||||
|
||||
@router.put("/config", response_model=MealieStatus)
|
||||
def mealie_konfigurieren(
|
||||
payload: MealieConfigIn,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> MealieStatus:
|
||||
"""Mit Benutzername und Passwort anmelden und ein Dauertoken holen.
|
||||
|
||||
Das Passwort wird nur für diese eine Anmeldung benutzt und danach verworfen
|
||||
– es steht in keiner Zeile der Datenbank und in keiner Antwort.
|
||||
"""
|
||||
try:
|
||||
dauertoken = mealie.hole_dauertoken(
|
||||
payload.base_url, payload.username, payload.password
|
||||
)
|
||||
except mealie.MealieFehler as fehler:
|
||||
raise HTTPException(_http_code(fehler), str(fehler)) from fehler
|
||||
_setze_adresse(db, payload.base_url)
|
||||
mealie.speichere_token(db, dauertoken)
|
||||
return _status(db, pruefen=True)
|
||||
|
||||
|
||||
@router.put("/token", response_model=MealieStatus)
|
||||
def mealie_token_setzen(
|
||||
payload: MealieTokenIn,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> MealieStatus:
|
||||
"""Ein in Mealie selbst erzeugtes Dauertoken einsetzen.
|
||||
|
||||
Für alle, die ihr Passwort nicht eintippen wollen: In Mealie unter Profil ▸
|
||||
API-Tokens eines anlegen und hier einfügen.
|
||||
"""
|
||||
_setze_adresse(db, payload.base_url)
|
||||
mealie.speichere_token(db, payload.token.strip())
|
||||
return _status(db, pruefen=True)
|
||||
|
||||
|
||||
@router.delete("/config", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def mealie_trennen(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> None:
|
||||
"""Verbindung lösen: Adresse und Token hier entfernen.
|
||||
|
||||
Das Token selbst bleibt in Mealie bestehen – wer es dort endgültig los sein
|
||||
will, löscht es unter Profil ▸ API-Tokens.
|
||||
"""
|
||||
mealie.loesche_zugang(db)
|
||||
|
||||
|
||||
def _http_code(fehler: mealie.MealieFehler) -> int:
|
||||
"""Fehlerkennung auf einen Statuscode abbilden.
|
||||
|
||||
Getrennt, weil „Mealie ist weg" (502) etwas anderes ist als „dein Passwort
|
||||
stimmt nicht" (400) – und die Oberfläche beides verschieden erklären soll.
|
||||
"""
|
||||
if fehler.kennung in ("anmeldung_fehlgeschlagen",):
|
||||
return status.HTTP_400_BAD_REQUEST
|
||||
if fehler.kennung == "nicht_eingerichtet":
|
||||
return status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
return status.HTTP_502_BAD_GATEWAY
|
||||
@@ -694,6 +694,40 @@ class LotConsumeOut(BaseModel):
|
||||
opened: bool = False
|
||||
|
||||
|
||||
# ---- Mealie ----
|
||||
class MealieConfigIn(BaseModel):
|
||||
"""Zugangsdaten für die einmalige Anmeldung bei Mealie.
|
||||
|
||||
Das Passwort wird **nicht** gespeichert: Vorrania meldet sich damit einmal
|
||||
an, lässt sich ein Dauertoken ausstellen und vergisst es.
|
||||
"""
|
||||
base_url: str = Field(min_length=1, max_length=255)
|
||||
username: str = Field(min_length=1, max_length=120)
|
||||
password: str = Field(min_length=1)
|
||||
|
||||
|
||||
class MealieTokenIn(BaseModel):
|
||||
"""Ein in Mealie selbst erzeugtes Dauertoken einsetzen (Alternative zum Passwort)."""
|
||||
base_url: str = Field(min_length=1, max_length=255)
|
||||
token: str = Field(min_length=1)
|
||||
|
||||
|
||||
class MealieStatus(BaseModel):
|
||||
"""Auskunft über die Verbindung – **nie** das Token selbst."""
|
||||
configured: bool = False
|
||||
base_url: str = ""
|
||||
token_set: bool = False
|
||||
# Erst bei einer echten Abfrage gefüllt; None heisst „nicht nachgesehen".
|
||||
reachable: bool | None = None
|
||||
version: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class MealieAvailable(BaseModel):
|
||||
"""Ob die Rezept-Seite überhaupt etwas anzeigen kann (für alle Benutzer)."""
|
||||
available: bool = False
|
||||
|
||||
|
||||
# ---- Hausmaße ----
|
||||
class MeasureIn(BaseModel):
|
||||
"""Name eines Hausmaßes. Die Menge steht an Kategorie bzw. Artikel."""
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
"""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}")
|
||||
Reference in New Issue
Block a user