# Preisdatenbank Koblenz – Backend **Stand:** v1.9 / Schema v4+ (2026-06-10) **Umgebung:** LXC 106 auf Proxmox `pve` **IP:** `192.168.2.x` (FRITZ!Box DHCP-Reservierung) **Pfad auf LXC:** `/opt/preisdb/` --- ## Stack | Komponente | Details | |---|---| | Python | 3.11 | | Web-Framework | FastAPI + Uvicorn, Port 8765 | | Datenbank | SQLite (WAL-Modus), `/opt/preisdb/data/preisdb.sqlite` | | Prozessverwaltung | systemd (`preisdb-api.service`, `preisdb-import.timer`) | | Mail | Postfix auf LXC 106, Gmail-Relay, `/usr/sbin/sendmail` | --- ## Dateien ``` /opt/preisdb/ ├── app/ │ ├── main.py # FastAPI Backend (v1.9) │ ├── importer.py # Einzel-Import-Logik (v1.4) │ ├── run_import.py # Import-Einstiegspunkt für systemd (v1.5) │ ├── config.py # DB_PATH, API-Keys, Mail-Config │ └── index.html # Web-Frontend (v2.1) └── data/ └── preisdb.sqlite ``` Lokale Kopie: `C:\MoniBase\offen\preisdb\` **Achtung:** `config.py` enthält API-Keys und wird nicht in GitHub eingecheckt. --- ## Datenbank-Schema (v4+) ```sql Haendler (id, mg_retailer_id, name, unique_name, aktiv) Marke (id, name) Kategorie (id, mg_category_id, name, ist_food, lebensmittelgruppe_id) Lebensmittelgruppe (id, name, symbol, sortierung) Produkt (id, mg_product_id, name, marke_id, kategorie_id) Angebot (id, produkt_id, haendler_id, beschreibung, preis, alter_preis, referenz_preis, einheit, volumen, gueltig_von, gueltig_bis, requires_loyalty, importiert_am) Preishistorie (id, produkt_id, haendler_id, preis, referenz_preis, gueltig_von, gueltig_bis, importiert_am) Suchbegriff (id, begriff, aktiv, letzter_import, letzte_treffer) SparfuchsGruppe (id, name) SparfuchsKategorie (id, gruppe_id, kategorie_id) ``` **Schlüsselregeln:** - `Kategorie.ist_food = 1` → wird in Suche angezeigt; `= 0` → Non-Food, gefiltert - `Kategorie.lebensmittelgruppe_id` → FK auf `Lebensmittelgruppe` - `Haendler.aktiv = 1` → wird beim Import berücksichtigt - `Angebot.referenz_preis` = Normpreis (€/Einheit) — Basis für Preisvergleich --- ## FastAPI-Endpunkte ### Öffentlich | Methode | Endpunkt | Beschreibung | |---|---|---| | GET | `/status` | Letzter Import, Anzahl Angebote/Händler/Produkte | | GET | `/suche` | Preissuche (`q`, `kategorie_id`, `lebensmittelgruppe_id`) | | GET | `/kategorien` | Alle Kategorien alphabetisch | | GET | `/lebensmittelgruppen` | Alle Lebensmittelgruppen nach Sortierung | | GET | `/produkt/{id}/verlauf` | Preisverlauf eines Produkts | | GET | `/sparfuchs/gruppen` | Alle Sparfuchs-Gruppen mit Kategorien | | GET | `/sparfuchs/{gruppe_id}` | TOP 3 pro Kategorie in einer Gruppe | | GET | `/sparfuchs/lebensmittelgruppe/{lg_id}` | TOP 3 pro Kategorie einer LG | | POST | `/sparfuchs/gruppen` | Neue Gruppe anlegen | | PUT | `/sparfuchs/gruppen/{id}` | Gruppe umbenennen / Kategorien ändern | | DELETE | `/sparfuchs/gruppen/{id}` | Gruppe löschen | ### Admin | Methode | Endpunkt | Beschreibung | |---|---|---| | GET | `/admin/suchbegriffe` | Alle Suchbegriffe mit Statistik | | POST | `/admin/suchbegriffe` | Neuen Begriff anlegen | | PUT | `/admin/suchbegriffe/{id}` | Begriff aktivieren/deaktivieren/umbenennen | | DELETE | `/admin/suchbegriffe/{id}` | Begriff löschen | | GET | `/admin/kategorien` | Alle Kategorien mit Produktanzahl + Gruppen-Info | | PUT | `/admin/kategorien/{id}/food` | Food/Non-Food setzen (`ist_food=0/1`) | | PUT | `/admin/kategorien/{id}/gruppe` | Lebensmittelgruppe zuordnen | | POST | `/admin/lebensmittelgruppen` | Neue Lebensmittelgruppe | | PUT | `/admin/lebensmittelgruppen/{id}` | LG umbenennen/Symbol/Sortierung | | DELETE | `/admin/lebensmittelgruppen/{id}` | LG löschen (Kategorien → NULL) | | GET | `/admin/uebersicht` | LG → Kategorie → Produkte (für Druck) | | GET | `/admin/haendler` | Alle Händler mit Angebote-Anzahl + aktiv-Flag | | PUT | `/admin/haendler/{id}/aktiv` | Händler aktivieren/deaktivieren | CORS: `allow_origins=["*"]` API-Docs: http://192.168.2.x:8765/docs --- ## Importer ### Datenquellen Alle Konfiguration kommen aus der DB — nichts mehr hardcoded: | Quelle | Funktion | |---|---| | Aktive Suchbegriffe | `SELECT FROM Suchbegriff WHERE aktiv = 1` | | Non-Food-Kategorien | `SELECT FROM Kategorie WHERE ist_food = 0` | | Aktive Händler | `SELECT mg_retailer_id FROM Haendler WHERE aktiv = 1` | ### marktguru REST-API ``` GET https://api.marktguru.de/api/v1/offers/search ?q={suchbegriff}&as=web&limit=100&offset=0&zipCode=56068 Header: x-clientkey, x-apikey (aus config.py, nicht in GitHub) ``` Die `retailerId`-Filterung funktioniert nicht serverseitig — clientseitig gefiltert via `aktive_haendler`. ### Upsert-Logik (`importer.py`) - `upsert_haendler()` → `ON CONFLICT DO UPDATE SET name=...` - `upsert_kategorie()` → gibt `(id, ist_food, is_new)` zurück - `upsert_produkt()` → gibt `(id, is_new)` zurück - Angebot: Delta-Erkennung auf Preis/Gültigkeit → bei Änderung → Eintrag in `Preishistorie` - Neue Kategorien + Produkte werden gesammelt und am Ende gemeldet ### run_import.py (v1.5) Einstiegspunkt für systemd. Ablauf: 1. Alle Daten aus DB laden (Suchbegriffe, Non-Food, Händler) 2. Für jeden aktiven Suchbegriff: `import_suchbegriff_detail()` aufrufen 3. `Suchbegriff.letzter_import` und `letzte_treffer` aktualisieren 4. Neue Kategorien und Produkte nach Import ausgeben (stdout + Mail) 5. Import-Mail via `/usr/sbin/sendmail` senden --- ## systemd ``` preisdb-api.service # FastAPI-Backend, autostart preisdb-import.timer # täglich 02:00 UTC, persistent preisdb-import.service # einmalig aufgerufen durch Timer ``` **Service neustarten:** ```bash pct exec 106 -- systemctl restart preisdb-api ``` **Import manuell auslösen:** ```bash pct exec 106 -- python3 /opt/preisdb/app/run_import.py ``` --- ## Deployment (lokaler Rechner → LXC) ```powershell # Datei auf Proxmox-Host kopieren scp -i "C:\Users\BENUTZER\.ssh\id_ed25519_proxmox" main.py [email protected]:/tmp/ # Von Proxmox-Host in LXC schieben ssh -i "C:\Users\BENUTZER\.ssh\id_ed25519_proxmox" [email protected] ` "pct push 106 /tmp/main.py /opt/preisdb/app/main.py" # Service neustarten ssh -i "C:\Users\BENUTZER\.ssh\id_ed25519_proxmox" [email protected] ` "pct exec 106 -- systemctl restart preisdb-api" ``` --- ## Sicherheit - `config.py` mit API-Keys und Mail-Config liegt nur auf LXC — nicht in GitHub - Gmail-Credentials in `/etc/postfix/sasl_passwd` auf LXC 106 — nicht im Python-Code - kein Passwort im Python-Code, Mail nur via `/usr/sbin/sendmail`