multi-user: users, per-user profiles/filters/notifications, tab UI, apply forensics

* DB: users + user_profiles/filters/notifications/preferences; applications gets
  user_id + forensics_json + profile_snapshot_json; new errors table
  with 14d retention; schema versioning via MIGRATIONS list
* auth: password hashes in DB (argon2); env vars seed first admin; per-user
  sessions; CSRF bound to user id
* apply: personal info/WBS moved out of env into the request body; providers
  take an ApplyContext with Profile + submit_forms; full Playwright recorder
  (step log, console, page errors, network, screenshots, final HTML)
* web: five top-level tabs (Wohnungen/Bewerbungen/Logs/Fehler/Einstellungen);
  settings sub-tabs profil/filter/benachrichtigungen/account/benutzer;
  per-user matching, auto-apply and notifications (UI/Telegram/SMTP); red
  auto-apply switch on Wohnungen tab; forensics detail view for bewerbungen
  and fehler; retention background thread

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Moritz 2026-04-21 10:52:41 +02:00
parent e663386a19
commit c630b500ef
36 changed files with 2763 additions and 1113 deletions

View file

@ -1,48 +1,71 @@
"""
lazyflat web app.
Five tabs:
- / Wohnungen (all flats, per-user match highlighting, filter block, auto-apply switch)
- /bewerbungen Bewerbungen (user's application history, forensics drill-in)
- /logs Logs (user-scoped audit log)
- /fehler Fehler (user-scoped error records + admin-global)
- /einstellungen/<section> Einstellungen: profile, filter, notifications, account, admin users
All state-changing POSTs require CSRF. Internal endpoints require INTERNAL_API_KEY.
"""
import asyncio
import hmac
import json
import logging
import sqlite3
import threading
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import Depends, FastAPI, Form, HTTPException, Header, Request, Response, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi import Depends, FastAPI, Form, Header, HTTPException, Request, Response, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import db
from apply_client import ApplyClient
import notifications
import retention
from apply_client import ApplyClient, _row_to_profile
from auth import (
bootstrap_admin,
clear_session_cookie,
current_user,
hash_password,
issue_csrf_token,
issue_session_cookie,
rate_limit_login,
require_admin,
require_csrf,
require_user,
verify_password,
)
from settings import (
APPLY_FAILURE_THRESHOLD,
AUTH_USERNAME,
FILTER_MAX_MORNING_COMMUTE,
FILTER_MAX_RENT,
FILTER_ROOMS,
INTERNAL_API_KEY,
verify_login,
)
from matching import flat_matches_filter, row_to_dict
from settings import APPLY_FAILURE_THRESHOLD, INTERNAL_API_KEY, PUBLIC_URL
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-5s %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
logging.getLogger("urllib3").setLevel(logging.WARNING)
logger = logging.getLogger("web")
apply_client = ApplyClient()
_apply_lock = threading.Lock()
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(_app: FastAPI):
db.init_db()
logger.info("web service started")
bootstrap_admin()
retention.start()
logger.info("web service ready")
yield
@ -51,31 +74,26 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# -----------------------------------------------------------------------------
# Security headers middleware
# -----------------------------------------------------------------------------
@app.middleware("http")
async def security_headers(request: Request, call_next):
response: Response = await call_next(request)
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault("Permissions-Policy", "geolocation=(), camera=(), microphone=()")
response.headers.setdefault(
resp: Response = await call_next(request)
resp.headers.setdefault("X-Frame-Options", "DENY")
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
resp.headers.setdefault("Permissions-Policy", "geolocation=(), camera=(), microphone=()")
resp.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; "
"script-src 'self' https://cdn.tailwindcss.com https://unpkg.com; "
"style-src 'self' https://cdn.tailwindcss.com 'unsafe-inline'; "
"img-src 'self' data:; "
"connect-src 'self'; "
"frame-ancestors 'none';"
"img-src 'self' data:; connect-src 'self'; frame-ancestors 'none';"
)
return response
return resp
# -----------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
# ---------------------------------------------------------------------------
def client_ip(request: Request) -> str:
xff = request.headers.get("x-forwarded-for")
@ -86,55 +104,95 @@ def client_ip(request: Request) -> str:
def require_internal(x_internal_api_key: str | None = Header(default=None)) -> None:
if not x_internal_api_key or not hmac.compare_digest(x_internal_api_key, INTERNAL_API_KEY):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid internal key")
raise HTTPException(status_code=401, detail="invalid internal key")
def matches_criteria(payload: dict) -> bool:
rooms = payload.get("rooms") or 0.0
rent = payload.get("total_rent") or 0.0
commute = (payload.get("connectivity") or {}).get("morning_time") or 0.0
if FILTER_ROOMS and rooms not in FILTER_ROOMS:
return False
if rent > FILTER_MAX_RENT:
return False
if commute > FILTER_MAX_MORNING_COMMUTE:
return False
return True
def base_context(request: Request, user, active_tab: str) -> dict:
return {
"request": request,
"user": user,
"csrf": issue_csrf_token(user["id"]),
"active_tab": active_tab,
"is_admin": bool(user["is_admin"]),
}
def apply_allowed() -> tuple[bool, str]:
if db.get_state("kill_switch") == "1":
def _get_apply_gate(user_id: int) -> tuple[bool, str]:
prefs = db.get_preferences(user_id)
if prefs["kill_switch"]:
return False, "kill switch aktiv"
if db.get_state("apply_circuit_open") == "1":
return False, "circuit breaker offen (zu viele Fehler)"
if prefs["apply_circuit_open"]:
return False, "circuit breaker offen (zu viele fehlgeschlagene Bewerbungen)"
if not apply_client.health():
return False, "apply-service nicht erreichbar"
return True, ""
def run_apply(flat_id: str, url: str, triggered_by: str) -> None:
"""Run synchronously inside a worker thread via asyncio.to_thread."""
app_id = db.start_application(flat_id, url, triggered_by)
try:
result = apply_client.apply(url)
except Exception as e:
result = {"success": False, "message": f"client error: {e}"}
def _run_apply_background(user_id: int, flat_id: str, url: str, triggered_by: str) -> None:
prefs = db.get_preferences(user_id)
profile_row = db.get_profile(user_id)
profile = _row_to_profile(profile_row)
submit_forms = bool(prefs["submit_forms"])
app_id = db.start_application(
user_id=user_id, flat_id=flat_id, url=url,
triggered_by=triggered_by, submit_forms=submit_forms,
profile_snapshot=profile,
)
logger.info("apply.start user=%s flat=%s application=%s submit=%s",
user_id, flat_id, app_id, submit_forms)
result = apply_client.apply(url=url, profile=profile,
submit_forms=submit_forms, application_id=app_id)
success = bool(result.get("success"))
db.finish_application(app_id, success, result.get("message", ""))
message = result.get("message", "")
provider = result.get("provider", "")
forensics = result.get("forensics") or {}
# Circuit breaker accounting
db.finish_application(app_id, success=success, message=message,
provider=provider, forensics=forensics)
# Circuit breaker (per user)
if success:
db.set_state("apply_recent_failures", "0")
db.update_preferences(user_id, {"apply_recent_failures": 0})
else:
fails = int(db.get_state("apply_recent_failures") or "0") + 1
db.set_state("apply_recent_failures", str(fails))
if fails >= APPLY_FAILURE_THRESHOLD:
db.set_state("apply_circuit_open", "1")
db.log_audit("system", "circuit_open", f"{fails} consecutive failures")
failures = int(prefs["apply_recent_failures"] or 0) + 1
updates = {"apply_recent_failures": failures}
if failures >= APPLY_FAILURE_THRESHOLD:
updates["apply_circuit_open"] = 1
db.log_error(source="apply", kind="circuit_open", user_id=user_id,
summary=f"{failures} aufeinanderfolgende Fehler",
application_id=app_id)
db.update_preferences(user_id, updates)
# record forensic error row
db.log_error(source="apply", kind="apply_failure", user_id=user_id,
summary=message or "Bewerbung fehlgeschlagen",
application_id=app_id,
context={"provider": provider, "url": url})
# Notify user
flat = db.get_flat(flat_id)
flat_dict = {"address": flat["address"] if flat else "", "link": url,
"rooms": flat["rooms"] if flat else None,
"total_rent": flat["total_rent"] if flat else None}
if success:
notifications.on_apply_ok(user_id, flat_dict, message)
else:
notifications.on_apply_fail(user_id, flat_dict, message)
db.log_audit("system", "apply_finished", f"app={app_id} success={success}", user_id=user_id)
# -----------------------------------------------------------------------------
def _kick_apply(user_id: int, flat_id: str, url: str, triggered_by: str) -> None:
asyncio.create_task(asyncio.to_thread(
_run_apply_background, user_id, flat_id, url, triggered_by,
))
# ---------------------------------------------------------------------------
# Public routes
# -----------------------------------------------------------------------------
# ---------------------------------------------------------------------------
@app.get("/health")
def health():
@ -153,104 +211,134 @@ def login_submit(request: Request, username: str = Form(...), password: str = Fo
ip = client_ip(request)
if not rate_limit_login(ip):
db.log_audit(username or "?", "login_rate_limited", ip=ip)
db.log_error(source="web", kind="rate_limit", summary=f"login throttled for {ip}",
context={"username": username or ""})
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "Zu viele Versuche. Bitte später erneut versuchen."},
{"request": request, "error": "Zu viele Versuche. Bitte später erneut."},
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
)
if not verify_password(username, password):
user = verify_login(username, password)
if not user:
db.log_audit(username or "?", "login_failed", ip=ip)
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "Login fehlgeschlagen."},
status_code=status.HTTP_401_UNAUTHORIZED,
)
response = RedirectResponse("/", status_code=303)
issue_session_cookie(response, username)
db.log_audit(username, "login_success", ip=ip)
issue_session_cookie(response, user["id"])
db.log_audit(user["username"], "login_success", user_id=user["id"], ip=ip)
return response
@app.post("/logout")
def logout(request: Request):
user = current_user(request) or "?"
u = current_user(request)
response = RedirectResponse("/login", status_code=303)
clear_session_cookie(response)
db.log_audit(user, "logout", ip=client_ip(request))
if u:
db.log_audit(u["username"], "logout", user_id=u["id"], ip=client_ip(request))
return response
# -----------------------------------------------------------------------------
# Authenticated dashboard
# -----------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Tab: Wohnungen
# ---------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
def dashboard(request: Request):
user = current_user(request)
if not user:
def tab_wohnungen(request: Request):
u = current_user(request)
if not u:
return RedirectResponse("/login", status_code=303)
allowed, reason = apply_allowed()
ctx = {
"request": request,
"user": user,
"csrf": issue_csrf_token(user),
"mode": db.get_state("mode") or "manual",
"kill_switch": db.get_state("kill_switch") == "1",
"circuit_open": db.get_state("apply_circuit_open") == "1",
"apply_failures": int(db.get_state("apply_recent_failures") or "0"),
"last_alert_heartbeat": db.get_state("last_alert_heartbeat") or "",
"apply_reachable": apply_client.health(),
ctx = base_context(request, u, "wohnungen")
ctx.update(_wohnungen_context(u))
return templates.TemplateResponse("wohnungen.html", ctx)
def _wohnungen_context(user) -> dict:
uid = user["id"]
filters_row = db.get_filters(uid)
prefs = db.get_preferences(uid)
filters = row_to_dict(filters_row)
flats = db.recent_flats(100)
flats_view = []
for f in flats:
try:
payload = json.loads(f["payload_json"])
except Exception:
payload = {}
last = db.last_application_for_flat(uid, f["id"])
flats_view.append({
"row": f,
"payload": payload,
"matched": flat_matches_filter({
"rooms": f["rooms"], "total_rent": f["total_rent"], "size": f["size"],
"wbs": f["wbs"], "connectivity": {"morning_time": f["connectivity_morning_time"]},
}, filters),
"last": last,
})
allowed, reason = _get_apply_gate(uid)
return {
"flats": flats_view,
"filters": filters,
"auto_apply_enabled": bool(prefs["auto_apply_enabled"]),
"submit_forms": bool(prefs["submit_forms"]),
"kill_switch": bool(prefs["kill_switch"]),
"circuit_open": bool(prefs["apply_circuit_open"]),
"apply_failures": int(prefs["apply_recent_failures"] or 0),
"apply_allowed": allowed,
"apply_block_reason": reason,
"flats": db.recent_flats(50),
"applications": db.recent_applications(20),
"audit": db.recent_audit(15),
}
return templates.TemplateResponse("dashboard.html", ctx)
@app.get("/partials/dashboard", response_class=HTMLResponse)
def dashboard_partial(request: Request, user: str = Depends(require_user)):
"""HTMX partial refresh — avoids leaking data to unauthenticated clients."""
allowed, reason = apply_allowed()
ctx = {
"request": request,
"user": user,
"csrf": issue_csrf_token(user),
"mode": db.get_state("mode") or "manual",
"kill_switch": db.get_state("kill_switch") == "1",
"circuit_open": db.get_state("apply_circuit_open") == "1",
"apply_failures": int(db.get_state("apply_recent_failures") or "0"),
"last_alert_heartbeat": db.get_state("last_alert_heartbeat") or "",
"apply_reachable": apply_client.health(),
"apply_allowed": allowed,
"apply_block_reason": reason,
"flats": db.recent_flats(50),
"applications": db.recent_applications(20),
"audit": db.recent_audit(15),
"last_alert_heartbeat": db.get_state("last_alert_heartbeat") or "",
}
return templates.TemplateResponse("_dashboard_body.html", ctx)
# -----------------------------------------------------------------------------
# State-changing actions (require auth + CSRF)
# -----------------------------------------------------------------------------
@app.post("/actions/mode")
async def action_mode(
@app.post("/actions/filters")
async def action_save_filters(
request: Request,
mode: str = Form(...),
csrf: str = Form(...),
user: str = Depends(require_user),
rooms_min: str = Form(""),
rooms_max: str = Form(""),
max_rent: str = Form(""),
min_size: str = Form(""),
max_morning_commute: str = Form(""),
wbs_required: str = Form(""),
user=Depends(require_user),
):
require_csrf(request, csrf)
if mode not in ("manual", "auto"):
raise HTTPException(400, "invalid mode")
db.set_state("mode", mode)
db.log_audit(user, "set_mode", mode, client_ip(request))
require_csrf(user["id"], csrf)
def _f(v):
v = (v or "").strip().replace(",", ".")
return float(v) if v else None
db.update_filters(user["id"], {
"rooms_min": _f(rooms_min),
"rooms_max": _f(rooms_max),
"max_rent": _f(max_rent),
"min_size": _f(min_size),
"max_morning_commute": _f(max_morning_commute),
"wbs_required": (wbs_required or "").strip(),
})
db.log_audit(user["username"], "filters.updated", user_id=user["id"], ip=client_ip(request))
return RedirectResponse("/", status_code=303)
@app.post("/actions/auto-apply")
async def action_auto_apply(
request: Request,
value: str = Form(...),
csrf: str = Form(...),
user=Depends(require_user),
):
require_csrf(user["id"], csrf)
new = 1 if value == "on" else 0
db.update_preferences(user["id"], {"auto_apply_enabled": new})
db.log_audit(user["username"], "auto_apply", "on" if new else "off",
user_id=user["id"], ip=client_ip(request))
return RedirectResponse("/", status_code=303)
@ -259,12 +347,13 @@ async def action_kill_switch(
request: Request,
value: str = Form(...),
csrf: str = Form(...),
user: str = Depends(require_user),
user=Depends(require_user),
):
require_csrf(request, csrf)
new = "1" if value == "on" else "0"
db.set_state("kill_switch", new)
db.log_audit(user, "set_kill_switch", new, client_ip(request))
require_csrf(user["id"], csrf)
new = 1 if value == "on" else 0
db.update_preferences(user["id"], {"kill_switch": new})
db.log_audit(user["username"], "kill_switch", "on" if new else "off",
user_id=user["id"], ip=client_ip(request))
return RedirectResponse("/", status_code=303)
@ -272,12 +361,11 @@ async def action_kill_switch(
async def action_reset_circuit(
request: Request,
csrf: str = Form(...),
user: str = Depends(require_user),
user=Depends(require_user),
):
require_csrf(request, csrf)
db.set_state("apply_circuit_open", "0")
db.set_state("apply_recent_failures", "0")
db.log_audit(user, "reset_circuit", "", client_ip(request))
require_csrf(user["id"], csrf)
db.update_preferences(user["id"], {"apply_circuit_open": 0, "apply_recent_failures": 0})
db.log_audit(user["username"], "reset_circuit", user_id=user["id"], ip=client_ip(request))
return RedirectResponse("/", status_code=303)
@ -286,26 +374,273 @@ async def action_apply(
request: Request,
flat_id: str = Form(...),
csrf: str = Form(...),
user: str = Depends(require_user),
user=Depends(require_user),
):
require_csrf(request, csrf)
allowed, reason = apply_allowed()
require_csrf(user["id"], csrf)
allowed, reason = _get_apply_gate(user["id"])
if not allowed:
raise HTTPException(409, f"apply disabled: {reason}")
flat = db.get_flat(flat_id)
if not flat:
raise HTTPException(404, "flat not found")
db.log_audit(user, "trigger_apply", f"flat_id={flat_id}", client_ip(request))
# Run apply in background so the UI returns fast
asyncio.create_task(asyncio.to_thread(run_apply, flat_id, flat["link"], "user"))
db.log_audit(user["username"], "trigger_apply", f"flat_id={flat_id}",
user_id=user["id"], ip=client_ip(request))
_kick_apply(user["id"], flat_id, flat["link"], "user")
return RedirectResponse("/", status_code=303)
# -----------------------------------------------------------------------------
# Internal endpoints (called by alert/apply services)
# -----------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Tab: Bewerbungen
# ---------------------------------------------------------------------------
@app.get("/bewerbungen", response_class=HTMLResponse)
def tab_bewerbungen(request: Request):
u = current_user(request)
if not u:
return RedirectResponse("/login", status_code=303)
ctx = base_context(request, u, "bewerbungen")
ctx["applications"] = db.recent_applications(u["id"], limit=100)
return templates.TemplateResponse("bewerbungen.html", ctx)
@app.get("/bewerbungen/{app_id}", response_class=HTMLResponse)
def bewerbung_detail(request: Request, app_id: int):
u = current_user(request)
if not u:
return RedirectResponse("/login", status_code=303)
a = db.get_application(app_id)
if not a or (a["user_id"] != u["id"] and not u["is_admin"]):
raise HTTPException(404, "not found")
forensics = json.loads(a["forensics_json"]) if a["forensics_json"] else None
profile = json.loads(a["profile_snapshot_json"]) if a["profile_snapshot_json"] else {}
ctx = base_context(request, u, "bewerbungen")
ctx.update({"application": a, "forensics": forensics, "profile_snapshot": profile})
return templates.TemplateResponse("bewerbung_detail.html", ctx)
# ---------------------------------------------------------------------------
# Tab: Logs
# ---------------------------------------------------------------------------
@app.get("/logs", response_class=HTMLResponse)
def tab_logs(request: Request):
u = current_user(request)
if not u:
return RedirectResponse("/login", status_code=303)
ctx = base_context(request, u, "logs")
ctx["events"] = db.recent_audit(u["id"], limit=200)
return templates.TemplateResponse("logs.html", ctx)
# ---------------------------------------------------------------------------
# Tab: Fehler
# ---------------------------------------------------------------------------
@app.get("/fehler", response_class=HTMLResponse)
def tab_fehler(request: Request):
u = current_user(request)
if not u:
return RedirectResponse("/login", status_code=303)
ctx = base_context(request, u, "fehler")
ctx["errors"] = db.recent_errors(u["id"], limit=200, include_global=bool(u["is_admin"]))
return templates.TemplateResponse("fehler.html", ctx)
@app.get("/fehler/{err_id}", response_class=HTMLResponse)
def fehler_detail(request: Request, err_id: int):
u = current_user(request)
if not u:
return RedirectResponse("/login", status_code=303)
e = db.get_error(err_id)
if not e or (e["user_id"] is not None and e["user_id"] != u["id"] and not u["is_admin"]):
raise HTTPException(404, "not found")
app_row = db.get_application(e["application_id"]) if e["application_id"] else None
forensics = None
if app_row and app_row["forensics_json"]:
try:
forensics = json.loads(app_row["forensics_json"])
except Exception:
forensics = None
context = json.loads(e["context_json"]) if e["context_json"] else None
ctx = base_context(request, u, "fehler")
ctx.update({"error": e, "application": app_row, "forensics": forensics, "context": context})
return templates.TemplateResponse("fehler_detail.html", ctx)
# ---------------------------------------------------------------------------
# Tab: Einstellungen (sub-tabs)
# ---------------------------------------------------------------------------
VALID_SECTIONS = ("profil", "filter", "benachrichtigungen", "account", "benutzer")
@app.get("/einstellungen", response_class=HTMLResponse)
def tab_settings_root(request: Request):
return RedirectResponse("/einstellungen/profil", status_code=303)
@app.get("/einstellungen/{section}", response_class=HTMLResponse)
def tab_settings(request: Request, section: str):
u = current_user(request)
if not u:
return RedirectResponse("/login", status_code=303)
if section not in VALID_SECTIONS:
raise HTTPException(404)
if section == "benutzer" and not u["is_admin"]:
raise HTTPException(403)
ctx = base_context(request, u, "einstellungen")
ctx["section"] = section
if section == "profil":
ctx["profile"] = db.get_profile(u["id"])
elif section == "filter":
ctx["filters"] = row_to_dict(db.get_filters(u["id"]))
elif section == "benachrichtigungen":
ctx["notifications"] = db.get_notifications(u["id"])
elif section == "account":
pass
elif section == "benutzer":
ctx["users"] = db.list_users()
return templates.TemplateResponse("einstellungen.html", ctx)
@app.post("/actions/profile")
async def action_profile(request: Request, user=Depends(require_user)):
form = await request.form()
require_csrf(user["id"], form.get("csrf", ""))
def _b(name): return form.get(name, "").lower() in ("true", "on", "yes", "1")
def _i(name):
try: return int(form.get(name) or 0)
except ValueError: return 0
db.update_profile(user["id"], {
"salutation": form.get("salutation", ""),
"firstname": form.get("firstname", ""),
"lastname": form.get("lastname", ""),
"email": form.get("email", ""),
"telephone": form.get("telephone", ""),
"street": form.get("street", ""),
"house_number": form.get("house_number", ""),
"postcode": form.get("postcode", ""),
"city": form.get("city", ""),
"is_possessing_wbs": 1 if _b("is_possessing_wbs") else 0,
"wbs_type": form.get("wbs_type", "0"),
"wbs_valid_till": form.get("wbs_valid_till", "1970-01-01"),
"wbs_rooms": _i("wbs_rooms"),
"wbs_adults": _i("wbs_adults"),
"wbs_children": _i("wbs_children"),
"is_prio_wbs": 1 if _b("is_prio_wbs") else 0,
"immomio_email": form.get("immomio_email", ""),
"immomio_password": form.get("immomio_password", ""),
})
db.log_audit(user["username"], "profile.updated", user_id=user["id"], ip=client_ip(request))
return RedirectResponse("/einstellungen/profil", status_code=303)
@app.post("/actions/notifications")
async def action_notifications(request: Request, user=Depends(require_user)):
form = await request.form()
require_csrf(user["id"], form.get("csrf", ""))
def _b(n): return 1 if form.get(n, "").lower() in ("on", "true", "1", "yes") else 0
db.update_notifications(user["id"], {
"channel": form.get("channel", "ui"),
"telegram_bot_token": form.get("telegram_bot_token", ""),
"telegram_chat_id": form.get("telegram_chat_id", ""),
"email_address": form.get("email_address", ""),
"notify_on_match": _b("notify_on_match"),
"notify_on_apply_success": _b("notify_on_apply_success"),
"notify_on_apply_fail": _b("notify_on_apply_fail"),
})
db.log_audit(user["username"], "notifications.updated", user_id=user["id"], ip=client_ip(request))
return RedirectResponse("/einstellungen/benachrichtigungen", status_code=303)
@app.post("/actions/account/password")
async def action_password(
request: Request,
old_password: str = Form(""),
new_password: str = Form(""),
new_password_repeat: str = Form(""),
csrf: str = Form(...),
user=Depends(require_user),
):
require_csrf(user["id"], csrf)
if not new_password or new_password != new_password_repeat:
return RedirectResponse("/einstellungen/account?err=mismatch", status_code=303)
if len(new_password) < 10:
return RedirectResponse("/einstellungen/account?err=tooshort", status_code=303)
row = db.get_user_by_username(user["username"])
from auth import verify_hash
if not row or not verify_hash(row["password_hash"], old_password):
return RedirectResponse("/einstellungen/account?err=wrongold", status_code=303)
db.set_user_password(user["id"], hash_password(new_password))
db.log_audit(user["username"], "password.changed", user_id=user["id"], ip=client_ip(request))
return RedirectResponse("/einstellungen/account?ok=1", status_code=303)
@app.post("/actions/submit-forms")
async def action_submit_forms(
request: Request,
value: str = Form(...),
csrf: str = Form(...),
user=Depends(require_user),
):
require_csrf(user["id"], csrf)
new = 1 if value == "on" else 0
db.update_preferences(user["id"], {"submit_forms": new})
db.log_audit(user["username"], "submit_forms", "on" if new else "off",
user_id=user["id"], ip=client_ip(request))
return RedirectResponse("/einstellungen/profil", status_code=303)
# --- Admin: Benutzer ---------------------------------------------------------
@app.post("/actions/users/create")
async def action_users_create(
request: Request,
username: str = Form(...),
password: str = Form(...),
is_admin: str = Form(""),
csrf: str = Form(...),
admin=Depends(require_admin),
):
require_csrf(admin["id"], csrf)
username = (username or "").strip()
if not username or len(password) < 10:
raise HTTPException(400, "username required, password >= 10 chars")
try:
uid = db.create_user(username, hash_password(password),
is_admin=(is_admin.lower() in ("on", "true", "yes", "1")))
except sqlite3.IntegrityError:
return RedirectResponse("/einstellungen/benutzer?err=exists", status_code=303)
db.log_audit(admin["username"], "user.created", f"new_user={username} id={uid}",
user_id=admin["id"], ip=client_ip(request))
return RedirectResponse("/einstellungen/benutzer?ok=1", status_code=303)
@app.post("/actions/users/disable")
async def action_users_disable(
request: Request,
target_id: int = Form(...),
value: str = Form(...),
csrf: str = Form(...),
admin=Depends(require_admin),
):
require_csrf(admin["id"], csrf)
if target_id == admin["id"]:
raise HTTPException(400, "refusing to disable self")
db.set_user_disabled(target_id, value == "on")
db.log_audit(admin["username"], "user.toggle_disable",
f"target={target_id} disabled={value=='on'}",
user_id=admin["id"], ip=client_ip(request))
return RedirectResponse("/einstellungen/benutzer", status_code=303)
# ---------------------------------------------------------------------------
# Internal endpoints
# ---------------------------------------------------------------------------
@app.post("/internal/flats")
async def internal_submit_flat(
@ -314,28 +649,51 @@ async def internal_submit_flat(
):
if not payload.get("id") or not payload.get("link"):
raise HTTPException(400, "id and link required")
matched = matches_criteria(payload)
is_new = db.upsert_flat(payload, matched)
is_new = db.upsert_flat(payload)
if not is_new:
return {"status": "duplicate"}
if matched:
db.log_audit("alert", "flat_matched", f"id={payload['id']} rent={payload.get('total_rent')}")
if db.get_state("mode") == "auto":
allowed, reason = apply_allowed()
if allowed:
db.log_audit("system", "auto_apply", f"flat_id={payload['id']}")
asyncio.create_task(asyncio.to_thread(run_apply, str(payload["id"]), payload["link"], "auto"))
else:
db.log_audit("system", "auto_apply_blocked", reason)
return {"status": "ok", "matched": matched}
# per-user matching + auto-apply + notifications
for u in db.list_users():
if u["disabled"]:
continue
filters = row_to_dict(db.get_filters(u["id"]))
if not flat_matches_filter(payload, filters):
continue
db.log_audit("alert", "flat_matched",
f"user={u['username']} flat={payload['id']}",
user_id=u["id"])
notifications.on_match(u["id"], payload)
prefs = db.get_preferences(u["id"])
if prefs["auto_apply_enabled"] and not prefs["kill_switch"] and not prefs["apply_circuit_open"]:
_kick_apply(u["id"], str(payload["id"]), payload["link"], "auto")
db.log_audit("system", "auto_apply_kick",
f"user={u['username']} flat={payload['id']}",
user_id=u["id"])
return {"status": "ok"}
@app.post("/internal/heartbeat")
async def internal_heartbeat(
payload: dict,
_guard: None = Depends(require_internal),
):
async def internal_heartbeat(payload: dict, _g: None = Depends(require_internal)):
service = payload.get("service", "unknown")
db.set_state(f"last_{service}_heartbeat", db.now_iso())
return {"status": "ok"}
@app.post("/internal/error")
async def internal_report_error(
payload: dict,
_g: None = Depends(require_internal),
):
"""Alert/other services can push errors here."""
db.log_error(
source=payload.get("source", "unknown"),
kind=payload.get("kind", "error"),
summary=payload.get("summary", ""),
context=payload.get("context"),
)
return {"status": "ok"}