# 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`