ui: live timers, Berlin timestamps, ZIP failure reports, drop kill-switch/Fehler tab
* remove the kill-switch: auto-apply toggle is the single on/off; manual
'Bewerben' button now only gated by apply reachability; circuit breaker
stays but only gates auto-apply (manual bypasses, so a user can retry)
* Berlin-timezone date filter (de_dt) formats timestamps as DD.MM.YYYY HH:MM
everywhere; storage stays UTC
* Wohnungen: live 'entdeckt vor X' on every flat + 'nächste Aktualisierung in Xs'
countdown in the header, driven by /static/app.js; HTMX polls body every 30s
* drop the Fehler tab entirely; failed applications now carry a
'Fehler-Report herunterladen (ZIP)' link -> /bewerbungen/{id}/report.zip
bundles application.json, flat.json, profile_snapshot.json, forensics.json,
step_log.txt, page.html, console/errors/network JSONs, and decoded
screenshots/*.jpg for AI-assisted debugging
* trim the 'sensibel' blurb from the Profil tab
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c630b500ef
commit
332d9eea19
13 changed files with 429 additions and 343 deletions
268
web/app.py
268
web/app.py
|
|
@ -1,27 +1,37 @@
|
|||
"""
|
||||
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)
|
||||
Four tabs:
|
||||
- / → Wohnungen (all flats, per-user match highlighting, filter block, auto-apply switch)
|
||||
- /bewerbungen → Bewerbungen (history + forensics; failed apps expose a ZIP report download)
|
||||
- /logs → Logs (user-scoped audit log)
|
||||
- /einstellungen/<section> → Einstellungen: profile, filter, notifications, account, admin users
|
||||
|
||||
All state-changing POSTs require CSRF. Internal endpoints require INTERNAL_API_KEY.
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import hmac
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
import zipfile
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Depends, FastAPI, Form, Header, HTTPException, Request, Response, status
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
BERLIN_TZ = ZoneInfo("Europe/Berlin")
|
||||
except Exception:
|
||||
BERLIN_TZ = timezone.utc
|
||||
|
||||
import db
|
||||
import notifications
|
||||
import retention
|
||||
|
|
@ -40,7 +50,12 @@ from auth import (
|
|||
verify_login,
|
||||
)
|
||||
from matching import flat_matches_filter, row_to_dict
|
||||
from settings import APPLY_FAILURE_THRESHOLD, INTERNAL_API_KEY, PUBLIC_URL
|
||||
from settings import (
|
||||
ALERT_SCRAPE_INTERVAL_SECONDS,
|
||||
APPLY_FAILURE_THRESHOLD,
|
||||
INTERNAL_API_KEY,
|
||||
PUBLIC_URL,
|
||||
)
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
|
|
@ -55,11 +70,9 @@ apply_client = ApplyClient()
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App
|
||||
# App + Jinja
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
db.init_db()
|
||||
|
|
@ -74,6 +87,40 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
|
|||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
def _parse_iso(s: str | None) -> datetime | None:
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
# Python handles +00:00 but not trailing Z. Storage always uses +00:00.
|
||||
return datetime.fromisoformat(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _de_dt(s: str | None) -> str:
|
||||
"""UTC ISO → 'DD.MM.YYYY HH:MM' in Europe/Berlin."""
|
||||
dt = _parse_iso(s)
|
||||
if dt is None:
|
||||
return ""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(BERLIN_TZ).strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
|
||||
def _iso_utc(s: str | None) -> str:
|
||||
"""Pass-through of a UTC ISO string (for data-attr in JS)."""
|
||||
dt = _parse_iso(s)
|
||||
if dt is None:
|
||||
return ""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
templates.env.filters["de_dt"] = _de_dt
|
||||
templates.env.filters["iso_utc"] = _iso_utc
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_headers(request: Request, call_next):
|
||||
resp: Response = await call_next(request)
|
||||
|
|
@ -117,17 +164,32 @@ def base_context(request: Request, user, active_tab: str) -> dict:
|
|||
}
|
||||
|
||||
|
||||
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 prefs["apply_circuit_open"]:
|
||||
return False, "circuit breaker offen (zu viele fehlgeschlagene Bewerbungen)"
|
||||
def _manual_apply_allowed() -> tuple[bool, str]:
|
||||
"""Manual 'Bewerben' button is only blocked if the apply service is down."""
|
||||
if not apply_client.health():
|
||||
return False, "apply-service nicht erreichbar"
|
||||
return True, ""
|
||||
|
||||
|
||||
def _auto_apply_allowed(prefs) -> bool:
|
||||
"""Auto-apply trigger gate: user must have enabled it and the circuit must be closed."""
|
||||
if not prefs["auto_apply_enabled"]:
|
||||
return False
|
||||
if prefs["apply_circuit_open"]:
|
||||
return False
|
||||
return apply_client.health()
|
||||
|
||||
|
||||
def _next_scrape_utc() -> str:
|
||||
hb = db.get_state("last_alert_heartbeat")
|
||||
dt = _parse_iso(hb)
|
||||
if dt is None:
|
||||
return ""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return (dt + timedelta(seconds=ALERT_SCRAPE_INTERVAL_SECONDS)).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
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)
|
||||
|
|
@ -153,7 +215,6 @@ def _run_apply_background(user_id: int, flat_id: str, url: str, triggered_by: st
|
|||
db.finish_application(app_id, success=success, message=message,
|
||||
provider=provider, forensics=forensics)
|
||||
|
||||
# Circuit breaker (per user)
|
||||
if success:
|
||||
db.update_preferences(user_id, {"apply_recent_failures": 0})
|
||||
else:
|
||||
|
|
@ -165,13 +226,11 @@ def _run_apply_background(user_id: int, flat_id: str, url: str, triggered_by: st
|
|||
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,
|
||||
|
|
@ -246,17 +305,6 @@ def logout(request: Request):
|
|||
# Tab: Wohnungen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def tab_wohnungen(request: Request):
|
||||
u = current_user(request)
|
||||
if not u:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
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)
|
||||
|
|
@ -266,14 +314,9 @@ def _wohnungen_context(user) -> dict:
|
|||
|
||||
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"]},
|
||||
|
|
@ -281,22 +324,40 @@ def _wohnungen_context(user) -> dict:
|
|||
"last": last,
|
||||
})
|
||||
|
||||
allowed, reason = _get_apply_gate(uid)
|
||||
allowed, reason = _manual_apply_allowed()
|
||||
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,
|
||||
"apply_reachable": apply_client.health(),
|
||||
"last_alert_heartbeat": db.get_state("last_alert_heartbeat") or "",
|
||||
"next_scrape_utc": _next_scrape_utc(),
|
||||
"scrape_interval_seconds": ALERT_SCRAPE_INTERVAL_SECONDS,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def tab_wohnungen(request: Request):
|
||||
u = current_user(request)
|
||||
if not u:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
ctx = base_context(request, u, "wohnungen")
|
||||
ctx.update(_wohnungen_context(u))
|
||||
return templates.TemplateResponse("wohnungen.html", ctx)
|
||||
|
||||
|
||||
@app.get("/partials/wohnungen", response_class=HTMLResponse)
|
||||
def partial_wohnungen(request: Request, user=Depends(require_user)):
|
||||
ctx = base_context(request, user, "wohnungen")
|
||||
ctx.update(_wohnungen_context(user))
|
||||
return templates.TemplateResponse("_wohnungen_body.html", ctx)
|
||||
|
||||
|
||||
@app.post("/actions/filters")
|
||||
async def action_save_filters(
|
||||
request: Request,
|
||||
|
|
@ -342,21 +403,6 @@ async def action_auto_apply(
|
|||
return RedirectResponse("/", status_code=303)
|
||||
|
||||
|
||||
@app.post("/actions/kill-switch")
|
||||
async def action_kill_switch(
|
||||
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"], {"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)
|
||||
|
||||
|
||||
@app.post("/actions/reset-circuit")
|
||||
async def action_reset_circuit(
|
||||
request: Request,
|
||||
|
|
@ -377,7 +423,7 @@ async def action_apply(
|
|||
user=Depends(require_user),
|
||||
):
|
||||
require_csrf(user["id"], csrf)
|
||||
allowed, reason = _get_apply_gate(user["id"])
|
||||
allowed, reason = _manual_apply_allowed()
|
||||
if not allowed:
|
||||
raise HTTPException(409, f"apply disabled: {reason}")
|
||||
flat = db.get_flat(flat_id)
|
||||
|
|
@ -418,6 +464,79 @@ def bewerbung_detail(request: Request, app_id: int):
|
|||
return templates.TemplateResponse("bewerbung_detail.html", ctx)
|
||||
|
||||
|
||||
@app.get("/bewerbungen/{app_id}/report.zip")
|
||||
def bewerbung_zip(request: Request, app_id: int):
|
||||
u = current_user(request)
|
||||
if not u:
|
||||
raise HTTPException(401)
|
||||
a = db.get_application(app_id)
|
||||
if not a or (a["user_id"] != u["id"] and not u["is_admin"]):
|
||||
raise HTTPException(404)
|
||||
|
||||
flat = db.get_flat(a["flat_id"])
|
||||
forensics = json.loads(a["forensics_json"]) if a["forensics_json"] else {}
|
||||
profile = json.loads(a["profile_snapshot_json"]) if a["profile_snapshot_json"] else {}
|
||||
app_meta = {k: a[k] for k in a.keys() if k not in ("forensics_json", "profile_snapshot_json")}
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr(
|
||||
"README.txt",
|
||||
f"lazyflat application report\n"
|
||||
f"application_id={a['id']}\n"
|
||||
f"flat_id={a['flat_id']}\n"
|
||||
f"provider={a['provider']}\n"
|
||||
f"success={a['success']}\n"
|
||||
f"started_at_utc={a['started_at']}\n"
|
||||
f"finished_at_utc={a['finished_at']}\n"
|
||||
f"submit_forms_used={bool(a['submit_forms_used'])}\n"
|
||||
f"\n"
|
||||
f"Contents:\n"
|
||||
f" application.json DB row + metadata\n"
|
||||
f" flat.json Flat details at discovery time\n"
|
||||
f" profile_snapshot.json Profile used for this attempt\n"
|
||||
f" forensics.json Full captured forensics\n"
|
||||
f" step_log.txt Human-readable step log\n"
|
||||
f" page.html Final page HTML (if captured)\n"
|
||||
f" console.json Browser console entries\n"
|
||||
f" errors.json Browser pageerror events\n"
|
||||
f" network.json Network requests + partial responses\n"
|
||||
f" screenshots/*.jpg Screenshots at key moments\n"
|
||||
)
|
||||
zf.writestr("application.json", json.dumps(app_meta, indent=2, default=str))
|
||||
zf.writestr("flat.json", json.dumps(dict(flat) if flat else {}, indent=2, default=str))
|
||||
zf.writestr("profile_snapshot.json", json.dumps(profile, indent=2, default=str))
|
||||
zf.writestr("forensics.json", json.dumps(forensics, indent=2, default=str))
|
||||
|
||||
step_lines = []
|
||||
for s in forensics.get("steps", []):
|
||||
step_lines.append(f"[{s.get('ts', 0):7.2f}s] {s.get('step', '?'):<24} {s.get('status', ''):<5} {s.get('detail', '')}")
|
||||
zf.writestr("step_log.txt", "\n".join(step_lines))
|
||||
|
||||
if forensics.get("final_html"):
|
||||
zf.writestr("page.html", forensics["final_html"])
|
||||
zf.writestr("console.json", json.dumps(forensics.get("console", []), indent=2))
|
||||
zf.writestr("errors.json", json.dumps(forensics.get("errors", []), indent=2))
|
||||
zf.writestr("network.json", json.dumps(forensics.get("network", []), indent=2))
|
||||
|
||||
for idx, s in enumerate(forensics.get("screenshots", []), start=1):
|
||||
b64 = s.get("b64_jpeg", "")
|
||||
if b64:
|
||||
try:
|
||||
data = base64.b64decode(b64)
|
||||
label = (s.get("label") or f"step{idx}").replace("/", "_").replace(" ", "_")
|
||||
zf.writestr(f"screenshots/{idx:02d}_{label}.jpg", data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
buf.seek(0)
|
||||
filename = f"lazyflat-report-{a['id']}.zip"
|
||||
return StreamingResponse(
|
||||
buf, media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tab: Logs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -432,41 +551,6 @@ def tab_logs(request: Request):
|
|||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -595,8 +679,6 @@ async def action_submit_forms(
|
|||
return RedirectResponse("/einstellungen/profil", status_code=303)
|
||||
|
||||
|
||||
# --- Admin: Benutzer ---------------------------------------------------------
|
||||
|
||||
@app.post("/actions/users/create")
|
||||
async def action_users_create(
|
||||
request: Request,
|
||||
|
|
@ -654,7 +736,6 @@ async def internal_submit_flat(
|
|||
if not is_new:
|
||||
return {"status": "duplicate"}
|
||||
|
||||
# per-user matching + auto-apply + notifications
|
||||
for u in db.list_users():
|
||||
if u["disabled"]:
|
||||
continue
|
||||
|
|
@ -668,7 +749,7 @@ async def internal_submit_flat(
|
|||
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"]:
|
||||
if _auto_apply_allowed(prefs):
|
||||
_kick_apply(u["id"], str(payload["id"]), payload["link"], "auto")
|
||||
db.log_audit("system", "auto_apply_kick",
|
||||
f"user={u['username']} flat={payload['id']}",
|
||||
|
|
@ -689,7 +770,6 @@ 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"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue