feat: Admin-Seite mit Passwortaenderung und Regel-Neuanwendung
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
116
finance/app/services/admin.py
Normal file
116
finance/app/services/admin.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Admin-Funktionen: Passwortaenderung (GUI+Grafana gemeinsam) und
|
||||
Regel-Neuanwendung (Ausbaustufe 4 Task 2).
|
||||
|
||||
KRITISCH (siehe Global Constraints des Plans): die .env wird im API-Container
|
||||
als Einzeldatei-Bind-Mount eingehaengt. Ein solcher Mount folgt dem Inode -
|
||||
die Datei MUSS in-place ueberschrieben werden (open r+/truncate unter flock),
|
||||
NIEMALS ueber Temp-Datei+rename (das erzeugt einen neuen Inode und würde vom
|
||||
Container nicht mehr gesehen).
|
||||
"""
|
||||
import fcntl
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from base64 import b64encode
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import current_password_hash, hash_password, verify_password
|
||||
from app.config import get_settings
|
||||
from app.models.tables import Transaction
|
||||
from app.services.categorize import apply_rules
|
||||
|
||||
|
||||
def _sync_grafana_password(old: str, new: str, base_url: str) -> None:
|
||||
"""PUT /api/admin/users/1/password gegen Grafana, Basic-Auth mit dem
|
||||
ALTEN Passwort (admin:old) - Grafana authentifiziert den Request noch mit
|
||||
dem bisherigen Passwort, aendert es aber auf `new`. Wird IMMER vor dem
|
||||
Schreiben der .env aufgerufen: schlaegt Grafana fehl, bleibt die Datei
|
||||
unangetastet (kein inkonsistenter Zwischenzustand GUI-Hash != Grafana)."""
|
||||
url = f"{base_url.rstrip('/')}/api/admin/users/1/password"
|
||||
auth_header = "Basic " + b64encode(f"admin:{old}".encode()).decode()
|
||||
body = json.dumps({"password": new}).encode()
|
||||
req = urllib.request.Request(
|
||||
url, data=body, method="PUT",
|
||||
headers={"Authorization": auth_header, "Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
if resp.status >= 400:
|
||||
raise RuntimeError(
|
||||
f"Grafana-Passwortänderung fehlgeschlagen (HTTP {resp.status})."
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise RuntimeError(
|
||||
f"Grafana-Passwortänderung fehlgeschlagen (HTTP {exc.code})."
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(
|
||||
f"Grafana-Passwortänderung fehlgeschlagen: {exc.reason}"
|
||||
) from exc
|
||||
except (TimeoutError, OSError) as exc:
|
||||
# Ein Hang in der Lesephase (nach erfolgreichem Verbindungsaufbau)
|
||||
# kann ein rohes TimeoutError/socket.timeout werfen, das urlopen NICHT
|
||||
# in ein URLError verpackt (URLError selbst ist zwar ein OSError, aber
|
||||
# dieser Pfad faengt Faelle ab, die es nicht bis dorthin schaffen).
|
||||
# Ohne diesen Fang wuerde die Exception bis in den Router durchschlagen
|
||||
# und dort als 500 statt als deutsche 400-Fehlermeldung enden.
|
||||
raise RuntimeError(
|
||||
f"Grafana-Passwortänderung fehlgeschlagen: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _rewrite_env_file(env_file: Path, new_password: str, new_hash: str) -> None:
|
||||
"""Ersetzt FB_PASSWORD und FB_GUI_PASSWORD_HASH IN-PLACE (open r+ unter
|
||||
flock, truncate) - der Inode der Datei bleibt unveraendert, siehe
|
||||
Modul-Docstring."""
|
||||
with open(env_file, "r+", encoding="utf-8") as f:
|
||||
fcntl.flock(f, fcntl.LOCK_EX)
|
||||
try:
|
||||
lines = f.read().splitlines()
|
||||
out = []
|
||||
for line in lines:
|
||||
if line.startswith("FB_PASSWORD="):
|
||||
out.append(f"FB_PASSWORD='{new_password}'")
|
||||
elif line.startswith("FB_GUI_PASSWORD_HASH="):
|
||||
out.append(f"FB_GUI_PASSWORD_HASH='{new_hash}'")
|
||||
else:
|
||||
out.append(line)
|
||||
f.seek(0)
|
||||
f.write("\n".join(out) + "\n")
|
||||
f.truncate()
|
||||
finally:
|
||||
fcntl.flock(f, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def change_password(old: str, new: str) -> None:
|
||||
"""Aendert das gemeinsame GUI-/Grafana-Passwort. Reihenfolge ist bindend:
|
||||
1) altes Passwort gegen den aktuellen Hash verifizieren,
|
||||
2) Grafana AKTUALISIEREN (bricht bei Fehler ab, ohne die .env
|
||||
anzufassen),
|
||||
3) erst danach die .env in-place neu schreiben.
|
||||
"""
|
||||
if not verify_password(old, current_password_hash()):
|
||||
raise ValueError("Das alte Passwort ist falsch.")
|
||||
|
||||
settings = get_settings()
|
||||
_sync_grafana_password(old, new, settings.grafana_url)
|
||||
|
||||
new_hash = hash_password(new)
|
||||
_rewrite_env_file(settings.env_file, new, new_hash)
|
||||
|
||||
|
||||
def apply_rules_retroactively(session: Session) -> int:
|
||||
"""Wendet die aktuellen Kategorie-Regeln rueckwirkend auf alle
|
||||
bestaetigten, noch unkategorisierten Buchungen an und committet."""
|
||||
txs = session.execute(
|
||||
select(Transaction).where(
|
||||
Transaction.status == "confirmed",
|
||||
Transaction.category_id.is_(None),
|
||||
)
|
||||
).scalars().all()
|
||||
hits = apply_rules(session, txs)
|
||||
session.commit()
|
||||
return hits
|
||||
Reference in New Issue
Block a user