diff --git a/web/app.py b/web/app.py index 62f1d9a..67dc7da 100644 --- a/web/app.py +++ b/web/app.py @@ -8,28 +8,55 @@ Tabs: - /admin/
→ Admin-only: protokoll | benutzer All state-changing POSTs require CSRF. Internal endpoints require INTERNAL_API_KEY. - -This module is intentionally thin: lifespan, middleware, template filters, and -`include_router` calls. All business logic lives in `common.py` and -`routes/*.py`. """ +import asyncio +import base64 +import hmac +import io +import json import logging +import mimetypes +import sqlite3 +import zipfile from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone +from typing import Any -from fastapi import FastAPI, Request, Response +from fastapi import Depends, FastAPI, Form, Header, HTTPException, Request, Response, status +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 enrichment +import notifications import retention -from auth import bootstrap_admin -from common import _de_dt, _iso_utc, logger, templates -from routes.admin import router as admin_router -from routes.auth import router as auth_router -from routes.bewerbungen import router as bewerbungen_router -from routes.einstellungen import router as einstellungen_router -from routes.internal import router as internal_router -from routes.wohnungen import router as wohnungen_router +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_login, +) +from matching import flat_matches_filter, row_to_dict +from settings import ( + APPLY_FAILURE_THRESHOLD, + INTERNAL_API_KEY, + PUBLIC_URL, +) logging.basicConfig( @@ -38,6 +65,20 @@ logging.basicConfig( datefmt="%H:%M:%S", ) logging.getLogger("urllib3").setLevel(logging.WARNING) +logger = logging.getLogger("web") + +apply_client = ApplyClient() + +# Strong refs for fire-and-forget tasks. asyncio.create_task only weakly +# references tasks from the event loop — the GC could drop them mid-flight. +_bg_tasks: set[asyncio.Task] = set() + + +def _spawn(coro) -> asyncio.Task: + t = asyncio.create_task(coro) + _bg_tasks.add(t) + t.add_done_callback(_bg_tasks.discard) + return t # --------------------------------------------------------------------------- @@ -56,10 +97,39 @@ async def lifespan(_app: FastAPI): app = FastAPI(lifespan=lifespan, title="wohnungsdidi", docs_url=None, redoc_url=None, openapi_url=None) 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") -# Template filters are registered on the shared Jinja environment (owned by -# common.templates) so every router that renders a template sees them. templates.env.filters["de_dt"] = _de_dt templates.env.filters["iso_utc"] = _iso_utc templates.env.filters["flat_slug"] = lambda s: enrichment.flat_slug(str(s or "")) @@ -85,15 +155,1130 @@ async def security_headers(request: Request, call_next): return resp +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def client_ip(request: Request) -> str: + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +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=401, detail="invalid internal key") + + +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 _is_htmx(request: Request) -> bool: + return request.headers.get("hx-request", "").lower() == "true" + + +def _wohnungen_partial_or_redirect(request: Request, user): + """If called via HTMX, render the body partial; otherwise redirect to /.""" + if _is_htmx(request): + ctx = base_context(request, user, "wohnungen") + ctx.update(_wohnungen_context(user)) + return templates.TemplateResponse("_wohnungen_body.html", ctx) + return RedirectResponse("/", status_code=303) + + +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 _last_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.astimezone(timezone.utc).isoformat(timespec="seconds") + + +FILTER_KEYS = ("rooms_min", "rooms_max", "max_rent", "min_size", + "wbs_required", "max_age_hours") + + +def _has_filters(f) -> bool: + if not f: + return False + for k in FILTER_KEYS: + v = f[k] if hasattr(f, "keys") else None + if v not in (None, "", 0, 0.0): + return True + return False + + +def _alert_status(notifications_row) -> tuple[str, str]: + """Return (label, chip_kind) for the user's alarm (notification) setup. + + 'aktiv' only if a real push channel is configured with credentials. + 'ui' is not a real alarm — the dashboard already shows matches when you + happen to be looking. + """ + if not notifications_row: + return "nicht eingerichtet", "warn" + ch = (notifications_row["channel"] or "ui").strip() + if ch == "telegram": + if notifications_row["telegram_bot_token"] and notifications_row["telegram_chat_id"]: + return "aktiv (Telegram)", "ok" + return "unvollständig", "warn" + return "nicht eingerichtet", "warn" + + +def _filter_summary(f) -> str: + if not _has_filters(f): + return "—" + parts = [] + rmin, rmax = f["rooms_min"], f["rooms_max"] + if rmin or rmax: + def fmt(x): + return "–" if x is None else ("%g" % x) + parts.append(f"{fmt(rmin)}–{fmt(rmax)} Zi") + if f["max_rent"]: + parts.append(f"≤ {int(f['max_rent'])} €") + if f["min_size"]: + parts.append(f"≥ {int(f['min_size'])} m²") + if f["wbs_required"] == "yes": + parts.append("WBS") + elif f["wbs_required"] == "no": + parts.append("ohne WBS") + if f["max_age_hours"]: + parts.append(f"≤ {int(f['max_age_hours'])} h alt") + return " · ".join(parts) + + +def _has_running_application(user_id: int) -> bool: + return db.has_running_application(user_id) + + +def _finish_apply_background(app_id: int, user_id: int, flat_id: str, url: str, + profile: dict, submit_forms: bool) -> None: + """Called on a worker thread AFTER the application row already exists. + The HTMX response has already shipped the running state to the user.""" + logger.info("apply.running 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")) + message = result.get("message", "") + provider = result.get("provider", "") + forensics = result.get("forensics") or {} + + db.finish_application(app_id, success=success, message=message, + provider=provider, forensics=forensics) + + prefs = db.get_preferences(user_id) + if success: + db.update_preferences(user_id, {"apply_recent_failures": 0}) + else: + 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) + 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}) + + 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: + """Insert the application row synchronously so the immediate HTMX response + already renders the row's "läuft…" state. The long-running Playwright call + is then offloaded to a background thread.""" + 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, + ) + + _spawn(asyncio.to_thread( + _finish_apply_background, app_id, user_id, flat_id, url, profile, submit_forms, + )) + + +# --------------------------------------------------------------------------- +# Public routes +# --------------------------------------------------------------------------- + @app.get("/health") def health(): return {"status": "ok"} -# Routers are registered without prefixes — paths stay absolute. -app.include_router(auth_router) -app.include_router(wohnungen_router) -app.include_router(bewerbungen_router) -app.include_router(einstellungen_router) -app.include_router(admin_router) -app.include_router(internal_router) +@app.get("/login", response_class=HTMLResponse) +def login_form(request: Request, error: str | None = None): + if current_user(request): + return RedirectResponse("/", status_code=303) + return templates.TemplateResponse("login.html", {"request": request, "error": error}) + + +@app.post("/login") +def login_submit(request: Request, username: str = Form(...), password: str = Form(...)): + 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."}, + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + ) + 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, user["id"]) + db.log_audit(user["username"], "login_success", user_id=user["id"], ip=ip) + return response + + +@app.post("/logout") +def logout(request: Request): + u = current_user(request) + response = RedirectResponse("/login", status_code=303) + clear_session_cookie(response) + if u: + db.log_audit(u["username"], "logout", user_id=u["id"], ip=client_ip(request)) + return response + + +# --------------------------------------------------------------------------- +# Tab: Wohnungen +# --------------------------------------------------------------------------- + +def _wohnungen_context(user) -> dict: + uid = user["id"] + filters_row = db.get_filters(uid) + notif_row = db.get_notifications(uid) + prefs = db.get_preferences(uid) + filters = row_to_dict(filters_row) + flats = db.recent_flats(100) + + rejected = db.rejected_flat_ids(uid) + max_age_hours = filters_row["max_age_hours"] if filters_row else None + age_cutoff = None + if max_age_hours: + age_cutoff = datetime.now(timezone.utc) - timedelta(hours=int(max_age_hours)) + # One query for this user's latest application per flat, instead of a + # per-flat query inside the loop. + latest_apps = db.latest_applications_by_flat(uid) + flats_view = [] + for f in flats: + if f["id"] in rejected: + continue + if age_cutoff is not None: + disc = _parse_iso(f["discovered_at"]) + if disc is None: + continue + if disc.tzinfo is None: + disc = disc.replace(tzinfo=timezone.utc) + if disc < age_cutoff: + continue + if not flat_matches_filter({ + "rooms": f["rooms"], "total_rent": f["total_rent"], "size": f["size"], + "wbs": f["wbs"], + }, filters): + continue + flats_view.append({"row": f, "last": latest_apps.get(f["id"])}) + + rejected_view = db.rejected_flats(uid) + enrichment_counts = db.enrichment_counts() + + partner = db.get_partner_user(uid) + partner_info = None + if partner: + partner_profile = db.get_profile(partner["id"]) + initial = ((partner_profile["firstname"] if partner_profile else "") + or partner["username"] or "?")[:1].upper() + display_name = (partner_profile["firstname"] + if partner_profile and partner_profile["firstname"] + else partner["username"]) + actions = db.partner_flat_actions(partner["id"]) + partner_info = { + "initial": initial, + "name": display_name, + "applied_flat_ids": actions["applied"], + "rejected_flat_ids": actions["rejected"], + } + + allowed, reason = _manual_apply_allowed() + alert_label, alert_chip = _alert_status(notif_row) + has_running = _has_running_application(uid) + map_points = [] + for item in flats_view: + f = item["row"] + if f["lat"] is None or f["lng"] is None: + continue + last = item["last"] + is_running = bool(last and last["finished_at"] is None) + already_applied = bool(last and last["success"] == 1) + if is_running: + status = {"label": "läuft…", "chip": "warn"} + elif already_applied: + status = {"label": "beworben", "chip": "ok"} + elif last and last["success"] == 0: + status = {"label": "fehlgeschlagen", "chip": "bad"} + else: + status = None + map_points.append({ + "id": f["id"], + "lat": f["lat"], "lng": f["lng"], + "address": f["address"] or f["link"], + "link": f["link"], + "rent": f["total_rent"], + "rooms": f["rooms"], + "size": f["size"], + "status": status, + "can_apply": allowed and not already_applied, + "is_running": is_running, + }) + return { + "flats": flats_view, + "rejected_flats": rejected_view, + "enrichment_counts": enrichment_counts, + "partner": partner_info, + "map_points": map_points, + "has_filters": _has_filters(filters_row), + "alert_label": alert_label, + "alert_chip": alert_chip, + "filter_summary": _filter_summary(filters_row), + "auto_apply_enabled": bool(prefs["auto_apply_enabled"]), + "submit_forms": bool(prefs["submit_forms"]), + "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_scrape_utc": _last_scrape_utc(), + "has_running_apply": has_running, + "poll_interval": 3 if has_running else 30, + } + + +@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.get("/partials/wohnung/{flat_id:path}", response_class=HTMLResponse) +def partial_wohnung_detail(request: Request, flat_id: str, user=Depends(require_user)): + flat = db.get_flat(flat_id) + if not flat: + raise HTTPException(404) + slug = enrichment.flat_slug(flat_id) + image_urls = [ + f"/flat-images/{slug}/{i}" + for i in range(1, int(flat["image_count"] or 0) + 1) + ] + ctx = { + "request": request, + "flat": flat, + "enrichment_status": flat["enrichment_status"], + "image_urls": image_urls, + } + return templates.TemplateResponse("_wohnung_detail.html", ctx) + + +@app.get("/flat-images/{slug}/{index}") +def flat_image(slug: str, index: int): + """Serve a downloaded flat image by slug + 1-based index. + + `slug` is derived from enrichment.flat_slug(flat_id) and is filesystem-safe + (hex), so it can be composed into a path without sanitisation concerns.""" + if not slug.isalnum() or not 1 <= index <= 99: + raise HTTPException(404) + d = enrichment.IMAGES_DIR / slug + if not d.exists(): + raise HTTPException(404) + # Files are named NN.; try the usual extensions. + prefix = f"{index:02d}." + for f in d.iterdir(): + if f.name.startswith(prefix): + media = mimetypes.guess_type(f.name)[0] or "image/jpeg" + return Response(content=f.read_bytes(), media_type=media, + headers={"Cache-Control": "public, max-age=3600"}) + raise HTTPException(404) + + +@app.post("/actions/filters") +async def action_save_filters( + request: Request, + csrf: str = Form(...), + rooms_min: str = Form(""), + rooms_max: str = Form(""), + max_rent: str = Form(""), + min_size: str = Form(""), + wbs_required: str = Form(""), + max_age_hours: str = Form(""), + user=Depends(require_user), +): + require_csrf(user["id"], csrf) + + def _f(v): + v = (v or "").strip().replace(",", ".") + return float(v) if v else None + + def _i(v): + v = (v or "").strip() + try: + return int(v) if v else None + except ValueError: + return 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), + "wbs_required": (wbs_required or "").strip(), + "max_age_hours": _i(max_age_hours), + }) + 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(default="off"), + 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 _wohnungen_partial_or_redirect(request, user) + + +@app.post("/actions/reset-circuit") +async def action_reset_circuit( + request: Request, + csrf: str = Form(...), + user=Depends(require_user), +): + 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 _wohnungen_partial_or_redirect(request, user) + + +@app.post("/actions/apply") +async def action_apply( + request: Request, + flat_id: str = Form(...), + csrf: str = Form(...), + user=Depends(require_user), +): + require_csrf(user["id"], csrf) + allowed, reason = _manual_apply_allowed() + 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") + last = db.last_application_for_flat(user["id"], flat_id) + if last and last["finished_at"] is None: + # Another apply is already running for this user+flat; don't queue a second. + return _wohnungen_partial_or_redirect(request, user) + if last and last["success"] == 1: + # Already successfully applied — no point in re-running. + return _wohnungen_partial_or_redirect(request, 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 _wohnungen_partial_or_redirect(request, user) + + +@app.post("/actions/reject") +async def action_reject( + request: Request, + flat_id: str = Form(...), + csrf: str = Form(...), + user=Depends(require_user), +): + require_csrf(user["id"], csrf) + db.reject_flat(user["id"], flat_id) + db.log_audit(user["username"], "flat.rejected", f"flat_id={flat_id}", + user_id=user["id"], ip=client_ip(request)) + return _wohnungen_partial_or_redirect(request, user) + + +@app.post("/actions/unreject") +async def action_unreject( + request: Request, + flat_id: str = Form(...), + csrf: str = Form(...), + user=Depends(require_user), +): + require_csrf(user["id"], csrf) + db.unreject_flat(user["id"], flat_id) + db.log_audit(user["username"], "flat.unrejected", f"flat_id={flat_id}", + user_id=user["id"], ip=client_ip(request)) + return _wohnungen_partial_or_redirect(request, user) + + +# --------------------------------------------------------------------------- +# 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}/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"wohnungsdidi 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" snapshots/NN_*.jpg Screenshot at each step (NN = order)\n" + f" snapshots/NN_*.html Page HTML at each step\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): + label = (s.get("label") or f"step{idx}").replace("/", "_").replace(" ", "_") + b64 = s.get("b64_jpeg", "") + if b64: + try: + data = base64.b64decode(b64) + zf.writestr(f"snapshots/{idx:02d}_{label}.jpg", data) + except Exception: + pass + html = s.get("html") or "" + if html: + zf.writestr(f"snapshots/{idx:02d}_{label}.html", html) + + buf.seek(0) + filename = f"wohnungsdidi-report-{a['id']}.zip" + return StreamingResponse( + buf, media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +# --------------------------------------------------------------------------- +# Tab: Logs +# --------------------------------------------------------------------------- + +def _parse_date_range(from_str: str | None, to_str: str | None) -> tuple[str | None, str | None]: + """Parse 'YYYY-MM-DD' local-Berlin date inputs into UTC ISO bounds. + Bounds are inclusive start-of-day and start-of-next-day.""" + def _to_utc_iso(s: str, end_of_day: bool) -> str | None: + try: + d = datetime.strptime(s, "%Y-%m-%d").replace(tzinfo=BERLIN_TZ) + except ValueError: + return None + if end_of_day: + d = d + timedelta(days=1) + return d.astimezone(timezone.utc).isoformat(timespec="seconds") + + start = _to_utc_iso(from_str, False) if from_str else None + end = _to_utc_iso(to_str, True) if to_str else None + return start, end + + +def _collect_events(start_iso: str | None, end_iso: str | None, + limit: int = 500) -> list[dict]: + users = {row["id"]: row["username"] for row in db.list_users()} + events: list[dict] = [] + for a in db.audit_in_range(start_iso, end_iso, limit=limit): + events.append({ + "kind": "audit", "ts": a["timestamp"], "source": "web", + "actor": a["actor"], "action": a["action"], + "details": a["details"] or "", + "user": users.get(a["user_id"], ""), + "ip": a["ip"] or "", + }) + for e in db.errors_in_range(start_iso, end_iso, limit=limit): + events.append({ + "kind": "error", "ts": e["timestamp"], "source": e["source"], + "actor": e["source"], "action": e["kind"], + "details": e["summary"] or "", + "user": users.get(e["user_id"], "") if e["user_id"] else "", + "ip": "", + }) + events.sort(key=lambda x: x["ts"], reverse=True) + return events + + +@app.get("/logs") +def tab_logs_legacy(): + # Old top-level Protokoll tab was merged into /admin/protokoll. + return RedirectResponse("/admin/protokoll", status_code=301) + + +ADMIN_SECTIONS = ("protokoll", "benutzer", "geheimnisse") + + +def _mask_secret(value: str) -> str: + if not value: + return "" + if len(value) <= 10: + return "•" * len(value) + return value[:6] + "…" + value[-4:] + + +@app.get("/admin", response_class=HTMLResponse) +def tab_admin_root(request: Request): + return RedirectResponse("/admin/protokoll", status_code=303) + + +@app.get("/admin/{section}", response_class=HTMLResponse) +def tab_admin(request: Request, section: str): + u = current_user(request) + if not u: + return RedirectResponse("/login", status_code=303) + if not u["is_admin"]: + raise HTTPException(403, "admin only") + if section not in ADMIN_SECTIONS: + raise HTTPException(404) + + ctx = base_context(request, u, "admin") + ctx["section"] = section + + if section == "protokoll": + q = request.query_params + from_str = q.get("from") or "" + to_str = q.get("to") or "" + start_iso, end_iso = _parse_date_range(from_str or None, to_str or None) + ctx.update({ + "events": _collect_events(start_iso, end_iso, limit=500), + "from_str": from_str, "to_str": to_str, + }) + elif section == "benutzer": + ctx["users"] = db.list_users() + elif section == "geheimnisse": + secrets = db.all_secrets() + ctx["secrets_masked"] = {k: _mask_secret(secrets.get(k, "")) for k in db.SECRET_KEYS} + ctx["secret_flash"] = request.query_params.get("ok") + return templates.TemplateResponse("admin.html", ctx) + + +@app.get("/logs/export.csv") +def tab_logs_export(request: Request): + u = current_user(request) + if not u: + raise HTTPException(401) + if not u["is_admin"]: + raise HTTPException(403) + + import csv as _csv + q = request.query_params + start_iso, end_iso = _parse_date_range(q.get("from") or None, q.get("to") or None) + events = _collect_events(start_iso, end_iso, limit=5000) + + buf = io.StringIO() + w = _csv.writer(buf, delimiter=",", quoting=_csv.QUOTE_MINIMAL) + w.writerow(["timestamp_utc", "timestamp_berlin", "kind", "source", "actor", "action", "user", "details", "ip"]) + for e in events: + w.writerow([ + e["ts"], + _de_dt(e["ts"]), + e["kind"], + e["source"], + e["actor"], + e["action"], + e["user"], + e["details"], + e["ip"], + ]) + body = buf.getvalue().encode("utf-8") + filename = "wohnungsdidi-protokoll" + if q.get("from"): filename += f"-{q['from']}" + if q.get("to"): filename += f"-bis-{q['to']}" + filename += ".csv" + return Response( + content=body, media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +# --------------------------------------------------------------------------- +# Tab: Einstellungen (sub-tabs) +# --------------------------------------------------------------------------- + +VALID_SECTIONS = ("profil", "filter", "benachrichtigungen", "partner", "account") + + +@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) + # Benutzer verwaltung lives under /admin/benutzer since the admin tab rework. + if section == "benutzer": + return RedirectResponse("/admin/benutzer", status_code=301) + if section not in VALID_SECTIONS: + raise HTTPException(404) + + 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 == "partner": + ctx["partner"] = db.get_partner_user(u["id"]) + ctx["partner_profile"] = db.get_profile(ctx["partner"]["id"]) if ctx["partner"] else None + ctx["incoming_requests"] = db.partnership_incoming(u["id"]) + ctx["outgoing_requests"] = db.partnership_outgoing(u["id"]) + ctx["partner_flash"] = request.query_params.get("flash") or "" + 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 + + # Field names are intentionally opaque ("contact_addr", "immomio_login", + # "immomio_secret") to keep password managers — specifically Bitwarden — + # from recognising the form as a login/identity form and autofilling. + db.update_profile(user["id"], { + "salutation": form.get("salutation", ""), + "firstname": form.get("firstname", ""), + "lastname": form.get("lastname", ""), + "email": form.get("contact_addr", ""), + "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_login", ""), + "immomio_password": form.get("immomio_secret", ""), + }) + 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/partner/request") +async def action_partner_request( + request: Request, + partner_username: str = Form(...), + csrf: str = Form(...), + user=Depends(require_user), +): + require_csrf(user["id"], csrf) + target = db.get_user_by_username((partner_username or "").strip()) + if not target or target["id"] == user["id"]: + return RedirectResponse("/einstellungen/partner?flash=nouser", status_code=303) + req_id = db.partnership_request(user["id"], target["id"]) + if req_id is None: + return RedirectResponse("/einstellungen/partner?flash=exists", status_code=303) + db.log_audit(user["username"], "partner.requested", + f"target={target['username']}", user_id=user["id"], ip=client_ip(request)) + return RedirectResponse("/einstellungen/partner?flash=sent", status_code=303) + + +@app.post("/actions/partner/accept") +async def action_partner_accept( + request: Request, + request_id: int = Form(...), + csrf: str = Form(...), + user=Depends(require_user), +): + require_csrf(user["id"], csrf) + if not db.partnership_accept(request_id, user["id"]): + return RedirectResponse("/einstellungen/partner?flash=accept_failed", status_code=303) + db.log_audit(user["username"], "partner.accepted", + f"request={request_id}", user_id=user["id"], ip=client_ip(request)) + return RedirectResponse("/einstellungen/partner?flash=accepted", status_code=303) + + +@app.post("/actions/partner/decline") +async def action_partner_decline( + request: Request, + request_id: int = Form(...), + csrf: str = Form(...), + user=Depends(require_user), +): + require_csrf(user["id"], csrf) + db.partnership_decline(request_id, user["id"]) + db.log_audit(user["username"], "partner.declined", + f"request={request_id}", user_id=user["id"], ip=client_ip(request)) + return RedirectResponse("/einstellungen/partner?flash=declined", status_code=303) + + +@app.post("/actions/partner/unlink") +async def action_partner_unlink( + request: Request, + csrf: str = Form(...), + user=Depends(require_user), +): + require_csrf(user["id"], csrf) + db.partnership_unlink(user["id"]) + db.log_audit(user["username"], "partner.unlinked", user_id=user["id"], ip=client_ip(request)) + return RedirectResponse("/einstellungen/partner?flash=unlinked", 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 + channel = form.get("channel", "ui") + if channel not in ("ui", "telegram"): + channel = "ui" + db.update_notifications(user["id"], { + "channel": channel, + "telegram_bot_token": form.get("telegram_bot_token", ""), + "telegram_chat_id": form.get("telegram_chat_id", ""), + "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(default="off"), + 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)) + if _is_htmx(request): + return _wohnungen_partial_or_redirect(request, user) + return RedirectResponse(request.headers.get("referer", "/einstellungen/profil"), status_code=303) + + +@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("/admin/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("/admin/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("/admin/benutzer", status_code=303) + + +@app.post("/actions/secrets") +async def action_secrets(request: Request, admin=Depends(require_admin)): + form = await request.form() + require_csrf(admin["id"], form.get("csrf", "")) + changed = [] + for key in db.SECRET_KEYS: + raw = (form.get(key) or "").strip() + if not raw: + continue + db.set_secret(key, raw) + changed.append(key) + db.log_audit(admin["username"], "secrets.updated", + ",".join(changed) or "no-op", + user_id=admin["id"], ip=client_ip(request)) + return RedirectResponse("/admin/geheimnisse?ok=1", status_code=303) + + +@app.post("/actions/enrich-all") +async def action_enrich_all( + request: Request, + csrf: str = Form(...), + admin=Depends(require_admin), +): + require_csrf(admin["id"], csrf) + queued = enrichment.kick_backfill() + db.log_audit(admin["username"], "enrichment.backfill", + f"queued={queued}", user_id=admin["id"], ip=client_ip(request)) + return _wohnungen_partial_or_redirect(request, admin) + + +@app.post("/actions/enrich-flat") +async def action_enrich_flat( + request: Request, + flat_id: str = Form(...), + csrf: str = Form(...), + admin=Depends(require_admin), +): + require_csrf(admin["id"], csrf) + db.set_flat_enrichment(flat_id, "pending") + enrichment.kick(flat_id) + db.log_audit(admin["username"], "enrichment.retry", + f"flat={flat_id}", user_id=admin["id"], ip=client_ip(request)) + return _wohnungen_partial_or_redirect(request, admin) + + +@app.post("/actions/users/delete") +async def action_users_delete( + request: Request, + target_id: int = Form(...), + csrf: str = Form(...), + admin=Depends(require_admin), +): + require_csrf(admin["id"], csrf) + if target_id == admin["id"]: + raise HTTPException(400, "refusing to delete self") + target = db.get_user(target_id) + if not target: + return RedirectResponse("/admin/benutzer", status_code=303) + db.delete_user(target_id) + db.log_audit(admin["username"], "user.deleted", + f"target={target_id} username={target['username']}", + user_id=admin["id"], ip=client_ip(request)) + return RedirectResponse("/admin/benutzer?deleted=1", status_code=303) + + +# --------------------------------------------------------------------------- +# Internal endpoints +# --------------------------------------------------------------------------- + +@app.post("/internal/flats") +async def internal_submit_flat( + payload: dict, + _guard: None = Depends(require_internal), +): + if not payload.get("id") or not payload.get("link"): + raise HTTPException(400, "id and link required") + + is_new = db.upsert_flat(payload) + if not is_new: + return {"status": "duplicate"} + + # Kick LLM enrichment + image download for this fresh flat. + enrichment.kick(str(payload["id"])) + + 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 _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']}", + user_id=u["id"]) + + return {"status": "ok"} + + +@app.post("/internal/heartbeat") +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), +): + db.log_error( + source=payload.get("source", "unknown"), + kind=payload.get("kind", "error"), + summary=payload.get("summary", ""), + context=payload.get("context"), + ) + return {"status": "ok"} + + +@app.get("/internal/secrets") +async def internal_secrets(_g: None = Depends(require_internal)): + """Give sibling services (alert) the current runtime creds that the admin + may have edited via the UI, so no redeploy is needed when rotating.""" + return db.all_secrets() diff --git a/web/common.py b/web/common.py deleted file mode 100644 index f67f968..0000000 --- a/web/common.py +++ /dev/null @@ -1,279 +0,0 @@ -"""Shared helpers and singletons used by the route modules. - -Extracted from the old monolithic app.py. Signatures and behaviour are kept -byte-for-byte identical with the original helpers so the routers can import -from here without any behavioural change. -""" -import asyncio -import hmac -import logging -from datetime import datetime, timedelta, timezone -from typing import Any - -from fastapi import Depends, HTTPException, Header, Request -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 -from apply_client import ApplyClient, _row_to_profile -from auth import issue_csrf_token -from settings import APPLY_FAILURE_THRESHOLD, INTERNAL_API_KEY - - -logger = logging.getLogger("web") - -# Jinja is instantiated here so every router uses the same environment and -# the filters registered in app.py (de_dt, iso_utc, flat_slug) are visible -# to every template rendered via this instance. -templates = Jinja2Templates(directory="templates") - -apply_client = ApplyClient() - -# Strong refs for fire-and-forget tasks. asyncio.create_task only weakly -# references tasks from the event loop — the GC could drop them mid-flight. -_bg_tasks: set[asyncio.Task] = set() - - -def _spawn(coro) -> asyncio.Task: - t = asyncio.create_task(coro) - _bg_tasks.add(t) - t.add_done_callback(_bg_tasks.discard) - return t - - -# --------------------------------------------------------------------------- -# Time helpers -# --------------------------------------------------------------------------- - -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") - - -def _last_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.astimezone(timezone.utc).isoformat(timespec="seconds") - - -# --------------------------------------------------------------------------- -# Request helpers -# --------------------------------------------------------------------------- - -def client_ip(request: Request) -> str: - xff = request.headers.get("x-forwarded-for") - if xff: - return xff.split(",")[0].strip() - return request.client.host if request.client else "unknown" - - -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=401, detail="invalid internal key") - - -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 _is_htmx(request: Request) -> bool: - return request.headers.get("hx-request", "").lower() == "true" - - -# --------------------------------------------------------------------------- -# Filter helpers -# --------------------------------------------------------------------------- - -FILTER_KEYS = ("rooms_min", "rooms_max", "max_rent", "min_size", - "wbs_required", "max_age_hours") - - -def _has_filters(f) -> bool: - if not f: - return False - for k in FILTER_KEYS: - v = f[k] if hasattr(f, "keys") else None - if v not in (None, "", 0, 0.0): - return True - return False - - -def _alert_status(notifications_row) -> tuple[str, str]: - """Return (label, chip_kind) for the user's alarm (notification) setup. - - 'aktiv' only if a real push channel is configured with credentials. - 'ui' is not a real alarm — the dashboard already shows matches when you - happen to be looking. - """ - if not notifications_row: - return "nicht eingerichtet", "warn" - ch = (notifications_row["channel"] or "ui").strip() - if ch == "telegram": - if notifications_row["telegram_bot_token"] and notifications_row["telegram_chat_id"]: - return "aktiv (Telegram)", "ok" - return "unvollständig", "warn" - return "nicht eingerichtet", "warn" - - -def _filter_summary(f) -> str: - if not _has_filters(f): - return "—" - parts = [] - rmin, rmax = f["rooms_min"], f["rooms_max"] - if rmin or rmax: - def fmt(x): - return "–" if x is None else ("%g" % x) - parts.append(f"{fmt(rmin)}–{fmt(rmax)} Zi") - if f["max_rent"]: - parts.append(f"≤ {int(f['max_rent'])} €") - if f["min_size"]: - parts.append(f"≥ {int(f['min_size'])} m²") - if f["wbs_required"] == "yes": - parts.append("WBS") - elif f["wbs_required"] == "no": - parts.append("ohne WBS") - if f["max_age_hours"]: - parts.append(f"≤ {int(f['max_age_hours'])} h alt") - return " · ".join(parts) - - -# --------------------------------------------------------------------------- -# Apply gates and orchestration -# --------------------------------------------------------------------------- - -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 _has_running_application(user_id: int) -> bool: - return db.has_running_application(user_id) - - -def _finish_apply_background(app_id: int, user_id: int, flat_id: str, url: str, - profile: dict, submit_forms: bool) -> None: - """Called on a worker thread AFTER the application row already exists. - The HTMX response has already shipped the running state to the user.""" - logger.info("apply.running 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")) - message = result.get("message", "") - provider = result.get("provider", "") - forensics = result.get("forensics") or {} - - db.finish_application(app_id, success=success, message=message, - provider=provider, forensics=forensics) - - prefs = db.get_preferences(user_id) - if success: - db.update_preferences(user_id, {"apply_recent_failures": 0}) - else: - 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) - 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}) - - 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: - """Insert the application row synchronously so the immediate HTMX response - already renders the row's "läuft…" state. The long-running Playwright call - is then offloaded to a background thread.""" - 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, - ) - - _spawn(asyncio.to_thread( - _finish_apply_background, app_id, user_id, flat_id, url, profile, submit_forms, - )) - - -# --------------------------------------------------------------------------- -# Misc -# --------------------------------------------------------------------------- - -def _mask_secret(value: str) -> str: - if not value: - return "" - if len(value) <= 10: - return "•" * len(value) - return value[:6] + "…" + value[-4:] diff --git a/web/routes/__init__.py b/web/routes/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/web/routes/admin.py b/web/routes/admin.py deleted file mode 100644 index 62cbeab..0000000 --- a/web/routes/admin.py +++ /dev/null @@ -1,224 +0,0 @@ -"""Admin-only routes: protocol log viewer, user management, secrets, -CSV export, and the /logs legacy redirect.""" -import io -import sqlite3 -from datetime import datetime, timedelta, timezone - -from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response -from fastapi.responses import HTMLResponse, RedirectResponse - -import db -from auth import current_user, hash_password, require_admin, require_csrf -from common import ( - BERLIN_TZ, - _de_dt, - _mask_secret, - base_context, - client_ip, - templates, -) - - -router = APIRouter() - -ADMIN_SECTIONS = ("protokoll", "benutzer", "geheimnisse") - - -def _parse_date_range(from_str: str | None, to_str: str | None) -> tuple[str | None, str | None]: - """Parse 'YYYY-MM-DD' local-Berlin date inputs into UTC ISO bounds. - Bounds are inclusive start-of-day and start-of-next-day.""" - def _to_utc_iso(s: str, end_of_day: bool) -> str | None: - try: - d = datetime.strptime(s, "%Y-%m-%d").replace(tzinfo=BERLIN_TZ) - except ValueError: - return None - if end_of_day: - d = d + timedelta(days=1) - return d.astimezone(timezone.utc).isoformat(timespec="seconds") - - start = _to_utc_iso(from_str, False) if from_str else None - end = _to_utc_iso(to_str, True) if to_str else None - return start, end - - -def _collect_events(start_iso: str | None, end_iso: str | None, - limit: int = 500) -> list[dict]: - users = {row["id"]: row["username"] for row in db.list_users()} - events: list[dict] = [] - for a in db.audit_in_range(start_iso, end_iso, limit=limit): - events.append({ - "kind": "audit", "ts": a["timestamp"], "source": "web", - "actor": a["actor"], "action": a["action"], - "details": a["details"] or "", - "user": users.get(a["user_id"], ""), - "ip": a["ip"] or "", - }) - for e in db.errors_in_range(start_iso, end_iso, limit=limit): - events.append({ - "kind": "error", "ts": e["timestamp"], "source": e["source"], - "actor": e["source"], "action": e["kind"], - "details": e["summary"] or "", - "user": users.get(e["user_id"], "") if e["user_id"] else "", - "ip": "", - }) - events.sort(key=lambda x: x["ts"], reverse=True) - return events - - -@router.get("/logs") -def tab_logs_legacy(): - # Old top-level Protokoll tab was merged into /admin/protokoll. - return RedirectResponse("/admin/protokoll", status_code=301) - - -@router.get("/admin", response_class=HTMLResponse) -def tab_admin_root(request: Request): - return RedirectResponse("/admin/protokoll", status_code=303) - - -@router.get("/admin/{section}", response_class=HTMLResponse) -def tab_admin(request: Request, section: str): - u = current_user(request) - if not u: - return RedirectResponse("/login", status_code=303) - if not u["is_admin"]: - raise HTTPException(403, "admin only") - if section not in ADMIN_SECTIONS: - raise HTTPException(404) - - ctx = base_context(request, u, "admin") - ctx["section"] = section - - if section == "protokoll": - q = request.query_params - from_str = q.get("from") or "" - to_str = q.get("to") or "" - start_iso, end_iso = _parse_date_range(from_str or None, to_str or None) - ctx.update({ - "events": _collect_events(start_iso, end_iso, limit=500), - "from_str": from_str, "to_str": to_str, - }) - elif section == "benutzer": - ctx["users"] = db.list_users() - elif section == "geheimnisse": - secrets = db.all_secrets() - ctx["secrets_masked"] = {k: _mask_secret(secrets.get(k, "")) for k in db.SECRET_KEYS} - ctx["secret_flash"] = request.query_params.get("ok") - return templates.TemplateResponse("admin.html", ctx) - - -@router.get("/logs/export.csv") -def tab_logs_export(request: Request): - u = current_user(request) - if not u: - raise HTTPException(401) - if not u["is_admin"]: - raise HTTPException(403) - - import csv as _csv - q = request.query_params - start_iso, end_iso = _parse_date_range(q.get("from") or None, q.get("to") or None) - events = _collect_events(start_iso, end_iso, limit=5000) - - buf = io.StringIO() - w = _csv.writer(buf, delimiter=",", quoting=_csv.QUOTE_MINIMAL) - w.writerow(["timestamp_utc", "timestamp_berlin", "kind", "source", "actor", "action", "user", "details", "ip"]) - for e in events: - w.writerow([ - e["ts"], - _de_dt(e["ts"]), - e["kind"], - e["source"], - e["actor"], - e["action"], - e["user"], - e["details"], - e["ip"], - ]) - body = buf.getvalue().encode("utf-8") - filename = "wohnungsdidi-protokoll" - if q.get("from"): filename += f"-{q['from']}" - if q.get("to"): filename += f"-bis-{q['to']}" - filename += ".csv" - return Response( - content=body, media_type="text/csv; charset=utf-8", - headers={"Content-Disposition": f'attachment; filename="{filename}"'}, - ) - - -@router.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("/admin/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("/admin/benutzer?ok=1", status_code=303) - - -@router.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("/admin/benutzer", status_code=303) - - -@router.post("/actions/users/delete") -async def action_users_delete( - request: Request, - target_id: int = Form(...), - csrf: str = Form(...), - admin=Depends(require_admin), -): - require_csrf(admin["id"], csrf) - if target_id == admin["id"]: - raise HTTPException(400, "refusing to delete self") - target = db.get_user(target_id) - if not target: - return RedirectResponse("/admin/benutzer", status_code=303) - db.delete_user(target_id) - db.log_audit(admin["username"], "user.deleted", - f"target={target_id} username={target['username']}", - user_id=admin["id"], ip=client_ip(request)) - return RedirectResponse("/admin/benutzer?deleted=1", status_code=303) - - -@router.post("/actions/secrets") -async def action_secrets(request: Request, admin=Depends(require_admin)): - form = await request.form() - require_csrf(admin["id"], form.get("csrf", "")) - changed = [] - for key in db.SECRET_KEYS: - raw = (form.get(key) or "").strip() - if not raw: - continue - db.set_secret(key, raw) - changed.append(key) - db.log_audit(admin["username"], "secrets.updated", - ",".join(changed) or "no-op", - user_id=admin["id"], ip=client_ip(request)) - return RedirectResponse("/admin/geheimnisse?ok=1", status_code=303) diff --git a/web/routes/auth.py b/web/routes/auth.py deleted file mode 100644 index e530ce9..0000000 --- a/web/routes/auth.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Authentication routes: login page, login submit, logout.""" -from fastapi import APIRouter, Form, Request, status -from fastapi.responses import HTMLResponse, RedirectResponse - -import db -from auth import ( - clear_session_cookie, - current_user, - issue_session_cookie, - rate_limit_login, - verify_login, -) -from common import client_ip, templates - - -router = APIRouter() - - -@router.get("/login", response_class=HTMLResponse) -def login_form(request: Request, error: str | None = None): - if current_user(request): - return RedirectResponse("/", status_code=303) - return templates.TemplateResponse("login.html", {"request": request, "error": error}) - - -@router.post("/login") -def login_submit(request: Request, username: str = Form(...), password: str = Form(...)): - 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."}, - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - ) - 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, user["id"]) - db.log_audit(user["username"], "login_success", user_id=user["id"], ip=ip) - return response - - -@router.post("/logout") -def logout(request: Request): - u = current_user(request) - response = RedirectResponse("/login", status_code=303) - clear_session_cookie(response) - if u: - db.log_audit(u["username"], "logout", user_id=u["id"], ip=client_ip(request)) - return response diff --git a/web/routes/bewerbungen.py b/web/routes/bewerbungen.py deleted file mode 100644 index ad9f1d9..0000000 --- a/web/routes/bewerbungen.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Bewerbungen (application history) tab and forensics ZIP export.""" -import base64 -import io -import json -import zipfile - -from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse - -import db -from auth import current_user -from common import base_context, templates - - -router = APIRouter() - - -@router.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) - - -@router.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"wohnungsdidi 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" snapshots/NN_*.jpg Screenshot at each step (NN = order)\n" - f" snapshots/NN_*.html Page HTML at each step\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): - label = (s.get("label") or f"step{idx}").replace("/", "_").replace(" ", "_") - b64 = s.get("b64_jpeg", "") - if b64: - try: - data = base64.b64decode(b64) - zf.writestr(f"snapshots/{idx:02d}_{label}.jpg", data) - except Exception: - pass - html = s.get("html") or "" - if html: - zf.writestr(f"snapshots/{idx:02d}_{label}.html", html) - - buf.seek(0) - filename = f"wohnungsdidi-report-{a['id']}.zip" - return StreamingResponse( - buf, media_type="application/zip", - headers={"Content-Disposition": f'attachment; filename="{filename}"'}, - ) diff --git a/web/routes/einstellungen.py b/web/routes/einstellungen.py deleted file mode 100644 index 6b5e8d0..0000000 --- a/web/routes/einstellungen.py +++ /dev/null @@ -1,188 +0,0 @@ -"""Einstellungen (settings) tab: profile, filter info, notifications, partner, -account, plus the related action endpoints.""" -from fastapi import APIRouter, Depends, Form, HTTPException, Request -from fastapi.responses import HTMLResponse, RedirectResponse - -import db -from auth import current_user, hash_password, require_csrf, require_user -from common import base_context, client_ip, templates -from matching import row_to_dict - - -router = APIRouter() - -VALID_SECTIONS = ("profil", "filter", "benachrichtigungen", "partner", "account") - - -@router.get("/einstellungen", response_class=HTMLResponse) -def tab_settings_root(request: Request): - return RedirectResponse("/einstellungen/profil", status_code=303) - - -@router.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) - # Benutzer verwaltung lives under /admin/benutzer since the admin tab rework. - if section == "benutzer": - return RedirectResponse("/admin/benutzer", status_code=301) - if section not in VALID_SECTIONS: - raise HTTPException(404) - - 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 == "partner": - ctx["partner"] = db.get_partner_user(u["id"]) - ctx["partner_profile"] = db.get_profile(ctx["partner"]["id"]) if ctx["partner"] else None - ctx["incoming_requests"] = db.partnership_incoming(u["id"]) - ctx["outgoing_requests"] = db.partnership_outgoing(u["id"]) - ctx["partner_flash"] = request.query_params.get("flash") or "" - return templates.TemplateResponse("einstellungen.html", ctx) - - -@router.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 - - # Field names are intentionally opaque ("contact_addr", "immomio_login", - # "immomio_secret") to keep password managers — specifically Bitwarden — - # from recognising the form as a login/identity form and autofilling. - db.update_profile(user["id"], { - "salutation": form.get("salutation", ""), - "firstname": form.get("firstname", ""), - "lastname": form.get("lastname", ""), - "email": form.get("contact_addr", ""), - "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_login", ""), - "immomio_password": form.get("immomio_secret", ""), - }) - db.log_audit(user["username"], "profile.updated", user_id=user["id"], ip=client_ip(request)) - return RedirectResponse("/einstellungen/profil", status_code=303) - - -@router.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 - channel = form.get("channel", "ui") - if channel not in ("ui", "telegram"): - channel = "ui" - db.update_notifications(user["id"], { - "channel": channel, - "telegram_bot_token": form.get("telegram_bot_token", ""), - "telegram_chat_id": form.get("telegram_chat_id", ""), - "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) - - -@router.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) - - -@router.post("/actions/partner/request") -async def action_partner_request( - request: Request, - partner_username: str = Form(...), - csrf: str = Form(...), - user=Depends(require_user), -): - require_csrf(user["id"], csrf) - target = db.get_user_by_username((partner_username or "").strip()) - if not target or target["id"] == user["id"]: - return RedirectResponse("/einstellungen/partner?flash=nouser", status_code=303) - req_id = db.partnership_request(user["id"], target["id"]) - if req_id is None: - return RedirectResponse("/einstellungen/partner?flash=exists", status_code=303) - db.log_audit(user["username"], "partner.requested", - f"target={target['username']}", user_id=user["id"], ip=client_ip(request)) - return RedirectResponse("/einstellungen/partner?flash=sent", status_code=303) - - -@router.post("/actions/partner/accept") -async def action_partner_accept( - request: Request, - request_id: int = Form(...), - csrf: str = Form(...), - user=Depends(require_user), -): - require_csrf(user["id"], csrf) - if not db.partnership_accept(request_id, user["id"]): - return RedirectResponse("/einstellungen/partner?flash=accept_failed", status_code=303) - db.log_audit(user["username"], "partner.accepted", - f"request={request_id}", user_id=user["id"], ip=client_ip(request)) - return RedirectResponse("/einstellungen/partner?flash=accepted", status_code=303) - - -@router.post("/actions/partner/decline") -async def action_partner_decline( - request: Request, - request_id: int = Form(...), - csrf: str = Form(...), - user=Depends(require_user), -): - require_csrf(user["id"], csrf) - db.partnership_decline(request_id, user["id"]) - db.log_audit(user["username"], "partner.declined", - f"request={request_id}", user_id=user["id"], ip=client_ip(request)) - return RedirectResponse("/einstellungen/partner?flash=declined", status_code=303) - - -@router.post("/actions/partner/unlink") -async def action_partner_unlink( - request: Request, - csrf: str = Form(...), - user=Depends(require_user), -): - require_csrf(user["id"], csrf) - db.partnership_unlink(user["id"]) - db.log_audit(user["username"], "partner.unlinked", user_id=user["id"], ip=client_ip(request)) - return RedirectResponse("/einstellungen/partner?flash=unlinked", status_code=303) diff --git a/web/routes/internal.py b/web/routes/internal.py deleted file mode 100644 index 8539ae3..0000000 --- a/web/routes/internal.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Internal service-to-service endpoints. Authenticated via INTERNAL_API_KEY -header; never called by browsers.""" -from fastapi import APIRouter, Depends, HTTPException - -import db -import enrichment -import notifications -from common import _auto_apply_allowed, _kick_apply, require_internal -from matching import flat_matches_filter, row_to_dict - - -router = APIRouter() - - -@router.post("/internal/flats") -async def internal_submit_flat( - payload: dict, - _guard: None = Depends(require_internal), -): - if not payload.get("id") or not payload.get("link"): - raise HTTPException(400, "id and link required") - - is_new = db.upsert_flat(payload) - if not is_new: - return {"status": "duplicate"} - - # Kick LLM enrichment + image download for this fresh flat. - enrichment.kick(str(payload["id"])) - - 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 _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']}", - user_id=u["id"]) - - return {"status": "ok"} - - -@router.post("/internal/heartbeat") -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"} - - -@router.post("/internal/error") -async def internal_report_error( - payload: dict, - _g: None = Depends(require_internal), -): - db.log_error( - source=payload.get("source", "unknown"), - kind=payload.get("kind", "error"), - summary=payload.get("summary", ""), - context=payload.get("context"), - ) - return {"status": "ok"} - - -@router.get("/internal/secrets") -async def internal_secrets(_g: None = Depends(require_internal)): - """Give sibling services (alert) the current runtime creds that the admin - may have edited via the UI, so no redeploy is needed when rotating.""" - return db.all_secrets() diff --git a/web/routes/wohnungen.py b/web/routes/wohnungen.py deleted file mode 100644 index 1820edb..0000000 --- a/web/routes/wohnungen.py +++ /dev/null @@ -1,372 +0,0 @@ -"""Wohnungen (main dashboard) routes, including the HTMX partials and the -per-flat action endpoints. The tab-specific context builder and the -partial-or-redirect helper live here since they're only used by this tab. -""" -import mimetypes -from datetime import datetime, timedelta, timezone - -from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response -from fastapi.responses import HTMLResponse, RedirectResponse - -import db -import enrichment -from auth import current_user, require_admin, require_csrf, require_user -from common import ( - _alert_status, - _auto_apply_allowed, - _filter_summary, - _has_filters, - _has_running_application, - _is_htmx, - _kick_apply, - _last_scrape_utc, - _manual_apply_allowed, - _parse_iso, - apply_client, - base_context, - client_ip, - templates, -) -from matching import flat_matches_filter, row_to_dict - - -router = APIRouter() - - -def _wohnungen_context(user) -> dict: - uid = user["id"] - filters_row = db.get_filters(uid) - notif_row = db.get_notifications(uid) - prefs = db.get_preferences(uid) - filters = row_to_dict(filters_row) - flats = db.recent_flats(100) - - rejected = db.rejected_flat_ids(uid) - max_age_hours = filters_row["max_age_hours"] if filters_row else None - age_cutoff = None - if max_age_hours: - age_cutoff = datetime.now(timezone.utc) - timedelta(hours=int(max_age_hours)) - # One query for this user's latest application per flat, instead of a - # per-flat query inside the loop. - latest_apps = db.latest_applications_by_flat(uid) - flats_view = [] - for f in flats: - if f["id"] in rejected: - continue - if age_cutoff is not None: - disc = _parse_iso(f["discovered_at"]) - if disc is None: - continue - if disc.tzinfo is None: - disc = disc.replace(tzinfo=timezone.utc) - if disc < age_cutoff: - continue - if not flat_matches_filter({ - "rooms": f["rooms"], "total_rent": f["total_rent"], "size": f["size"], - "wbs": f["wbs"], - }, filters): - continue - flats_view.append({"row": f, "last": latest_apps.get(f["id"])}) - - rejected_view = db.rejected_flats(uid) - enrichment_counts = db.enrichment_counts() - - partner = db.get_partner_user(uid) - partner_info = None - if partner: - partner_profile = db.get_profile(partner["id"]) - initial = ((partner_profile["firstname"] if partner_profile else "") - or partner["username"] or "?")[:1].upper() - display_name = (partner_profile["firstname"] - if partner_profile and partner_profile["firstname"] - else partner["username"]) - actions = db.partner_flat_actions(partner["id"]) - partner_info = { - "initial": initial, - "name": display_name, - "applied_flat_ids": actions["applied"], - "rejected_flat_ids": actions["rejected"], - } - - allowed, reason = _manual_apply_allowed() - alert_label, alert_chip = _alert_status(notif_row) - has_running = _has_running_application(uid) - map_points = [] - for item in flats_view: - f = item["row"] - if f["lat"] is None or f["lng"] is None: - continue - last = item["last"] - is_running = bool(last and last["finished_at"] is None) - already_applied = bool(last and last["success"] == 1) - if is_running: - status = {"label": "läuft…", "chip": "warn"} - elif already_applied: - status = {"label": "beworben", "chip": "ok"} - elif last and last["success"] == 0: - status = {"label": "fehlgeschlagen", "chip": "bad"} - else: - status = None - map_points.append({ - "id": f["id"], - "lat": f["lat"], "lng": f["lng"], - "address": f["address"] or f["link"], - "link": f["link"], - "rent": f["total_rent"], - "rooms": f["rooms"], - "size": f["size"], - "status": status, - "can_apply": allowed and not already_applied, - "is_running": is_running, - }) - return { - "flats": flats_view, - "rejected_flats": rejected_view, - "enrichment_counts": enrichment_counts, - "partner": partner_info, - "map_points": map_points, - "has_filters": _has_filters(filters_row), - "alert_label": alert_label, - "alert_chip": alert_chip, - "filter_summary": _filter_summary(filters_row), - "auto_apply_enabled": bool(prefs["auto_apply_enabled"]), - "submit_forms": bool(prefs["submit_forms"]), - "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_scrape_utc": _last_scrape_utc(), - "has_running_apply": has_running, - "poll_interval": 3 if has_running else 30, - } - - -def _wohnungen_partial_or_redirect(request: Request, user): - """If called via HTMX, render the body partial; otherwise redirect to /.""" - if _is_htmx(request): - ctx = base_context(request, user, "wohnungen") - ctx.update(_wohnungen_context(user)) - return templates.TemplateResponse("_wohnungen_body.html", ctx) - return RedirectResponse("/", status_code=303) - - -@router.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) - - -@router.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) - - -@router.get("/partials/wohnung/{flat_id:path}", response_class=HTMLResponse) -def partial_wohnung_detail(request: Request, flat_id: str, user=Depends(require_user)): - flat = db.get_flat(flat_id) - if not flat: - raise HTTPException(404) - slug = enrichment.flat_slug(flat_id) - image_urls = [ - f"/flat-images/{slug}/{i}" - for i in range(1, int(flat["image_count"] or 0) + 1) - ] - ctx = { - "request": request, - "flat": flat, - "enrichment_status": flat["enrichment_status"], - "image_urls": image_urls, - } - return templates.TemplateResponse("_wohnung_detail.html", ctx) - - -@router.get("/flat-images/{slug}/{index}") -def flat_image(slug: str, index: int): - """Serve a downloaded flat image by slug + 1-based index. - - `slug` is derived from enrichment.flat_slug(flat_id) and is filesystem-safe - (hex), so it can be composed into a path without sanitisation concerns.""" - if not slug.isalnum() or not 1 <= index <= 99: - raise HTTPException(404) - d = enrichment.IMAGES_DIR / slug - if not d.exists(): - raise HTTPException(404) - # Files are named NN.; try the usual extensions. - prefix = f"{index:02d}." - for f in d.iterdir(): - if f.name.startswith(prefix): - media = mimetypes.guess_type(f.name)[0] or "image/jpeg" - return Response(content=f.read_bytes(), media_type=media, - headers={"Cache-Control": "public, max-age=3600"}) - raise HTTPException(404) - - -@router.post("/actions/filters") -async def action_save_filters( - request: Request, - csrf: str = Form(...), - rooms_min: str = Form(""), - rooms_max: str = Form(""), - max_rent: str = Form(""), - min_size: str = Form(""), - wbs_required: str = Form(""), - max_age_hours: str = Form(""), - user=Depends(require_user), -): - require_csrf(user["id"], csrf) - - def _f(v): - v = (v or "").strip().replace(",", ".") - return float(v) if v else None - - def _i(v): - v = (v or "").strip() - try: - return int(v) if v else None - except ValueError: - return 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), - "wbs_required": (wbs_required or "").strip(), - "max_age_hours": _i(max_age_hours), - }) - db.log_audit(user["username"], "filters.updated", user_id=user["id"], ip=client_ip(request)) - return RedirectResponse("/", status_code=303) - - -@router.post("/actions/auto-apply") -async def action_auto_apply( - request: Request, - value: str = Form(default="off"), - 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 _wohnungen_partial_or_redirect(request, user) - - -@router.post("/actions/reset-circuit") -async def action_reset_circuit( - request: Request, - csrf: str = Form(...), - user=Depends(require_user), -): - 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 _wohnungen_partial_or_redirect(request, user) - - -@router.post("/actions/apply") -async def action_apply( - request: Request, - flat_id: str = Form(...), - csrf: str = Form(...), - user=Depends(require_user), -): - require_csrf(user["id"], csrf) - allowed, reason = _manual_apply_allowed() - 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") - last = db.last_application_for_flat(user["id"], flat_id) - if last and last["finished_at"] is None: - # Another apply is already running for this user+flat; don't queue a second. - return _wohnungen_partial_or_redirect(request, user) - if last and last["success"] == 1: - # Already successfully applied — no point in re-running. - return _wohnungen_partial_or_redirect(request, 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 _wohnungen_partial_or_redirect(request, user) - - -@router.post("/actions/reject") -async def action_reject( - request: Request, - flat_id: str = Form(...), - csrf: str = Form(...), - user=Depends(require_user), -): - require_csrf(user["id"], csrf) - db.reject_flat(user["id"], flat_id) - db.log_audit(user["username"], "flat.rejected", f"flat_id={flat_id}", - user_id=user["id"], ip=client_ip(request)) - return _wohnungen_partial_or_redirect(request, user) - - -@router.post("/actions/unreject") -async def action_unreject( - request: Request, - flat_id: str = Form(...), - csrf: str = Form(...), - user=Depends(require_user), -): - require_csrf(user["id"], csrf) - db.unreject_flat(user["id"], flat_id) - db.log_audit(user["username"], "flat.unrejected", f"flat_id={flat_id}", - user_id=user["id"], ip=client_ip(request)) - return _wohnungen_partial_or_redirect(request, user) - - -@router.post("/actions/submit-forms") -async def action_submit_forms( - request: Request, - value: str = Form(default="off"), - 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)) - if _is_htmx(request): - return _wohnungen_partial_or_redirect(request, user) - return RedirectResponse(request.headers.get("referer", "/einstellungen/profil"), status_code=303) - - -@router.post("/actions/enrich-all") -async def action_enrich_all( - request: Request, - csrf: str = Form(...), - admin=Depends(require_admin), -): - require_csrf(admin["id"], csrf) - queued = enrichment.kick_backfill() - db.log_audit(admin["username"], "enrichment.backfill", - f"queued={queued}", user_id=admin["id"], ip=client_ip(request)) - return _wohnungen_partial_or_redirect(request, admin) - - -@router.post("/actions/enrich-flat") -async def action_enrich_flat( - request: Request, - flat_id: str = Form(...), - csrf: str = Form(...), - admin=Depends(require_admin), -): - require_csrf(admin["id"], csrf) - db.set_flat_enrichment(flat_id, "pending") - enrichment.kick(flat_id) - db.log_audit(admin["username"], "enrichment.retry", - f"flat={flat_id}", user_id=admin["id"], ip=client_ip(request)) - return _wohnungen_partial_or_redirect(request, admin) diff --git a/web/static/app.css b/web/static/app.css deleted file mode 100644 index 4d1fef6..0000000 --- a/web/static/app.css +++ /dev/null @@ -1,163 +0,0 @@ -/* wohnungsdidi — site styling. - Imported via from base.html. Tailwind utilities (via CDN) handle - layout/spacing; this file owns our design tokens and component classes. */ - -:root { - --bg-from: #e4f0fb; --bg-to: #f7fbfe; - --surface: #ffffff; --border: #d8e6f3; - --text: #10253f; --muted: #667d98; - --primary: #2f8ae0; --primary-hover: #1f74c8; - --danger: #e05a6a; --danger-hover: #c44a59; - --ghost: #eaf2fb; --ghost-hover: #d5e5f4; - --accent: #fbd76b; -} - -html { color-scheme: light; } -body { - background: linear-gradient(180deg, var(--bg-from) 0%, var(--bg-to) 100%); - background-attachment: fixed; - color: var(--text); - font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Inter, sans-serif; -} - -/* Card + separator hooks */ -.card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; - box-shadow: 0 1px 2px rgba(16, 37, 63, 0.04); } -.border-soft { border-color: var(--border) !important; } -.divide-soft > :not([hidden]) ~ :not([hidden]) { border-color: var(--border) !important; } - -/* Buttons */ -.btn { border-radius: 9px; padding: 0.45rem 0.95rem; font-weight: 500; - transition: background 0.15s, box-shadow 0.15s, transform 0.05s; display: inline-block; } -.btn:active { transform: translateY(1px); } -.btn:disabled, .btn[disabled] { opacity: .55; cursor: not-allowed; pointer-events: none; } -.btn-primary { background: var(--primary); color: white; box-shadow: 0 1px 2px rgba(47,138,224,.25); } -.btn-primary:hover { background: var(--primary-hover); } -.btn-danger { background: var(--danger); color: white; } -.btn-danger:hover { background: var(--danger-hover); } -.btn-ghost { background: var(--ghost); color: var(--text); border: 1px solid var(--border); } -.btn-ghost:hover { background: var(--ghost-hover); } - -/* Auto-apply hot button (unused in current UI, kept for future) */ -.btn-hot { background: linear-gradient(135deg, #ff7a85 0%, #e14a56 100%); color: white; - box-shadow: 0 2px 6px rgba(225, 74, 86, 0.35); font-weight: 600; } -.btn-hot:hover { filter: brightness(1.05); } -.btn-hot.off { background: linear-gradient(135deg, #cfd9e6 0%, #99abc2 100%); - box-shadow: 0 1px 2px rgba(16, 37, 63, 0.15); } - -/* Partner-Aktionsbadge — kleiner Kreis oben rechts am Button */ -.btn-with-badge { position: relative; display: inline-block; } -.partner-badge { - position: absolute; top: -6px; right: -6px; - width: 18px; height: 18px; border-radius: 9999px; - background: var(--primary); color: #fff; - font-size: 10px; font-weight: 700; line-height: 1; - display: inline-flex; align-items: center; justify-content: center; - border: 2px solid #fff; - box-shadow: 0 1px 2px rgba(16,37,63,.25); - pointer-events: auto; -} - -/* Inputs */ -.input { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; - padding: 0.55rem 0.8rem; width: 100%; color: var(--text); - transition: border-color .15s, box-shadow .15s; } -.input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47,138,224,.18); } - -/* Chips */ -.chip { padding: .2rem .7rem; border-radius: 999px; font-size: .75rem; font-weight: 500; display: inline-block; } -.chip-ok { background: #e4f6ec; color: #1f8a4a; border: 1px solid #b7e4c7; } -.chip-warn { background: #fff4dd; color: #a36a1f; border: 1px solid #f5d48b; } -.chip-bad { background: #fde6e9; color: #b8404e; border: 1px solid #f5b5bf; } -.chip-info { background: #e3effc; color: #1f5f99; border: 1px solid #b6d4f0; } - -/* Countdown — tabular digits + fixed width so the text doesn't wobble - while the seconds tick down. */ -.countdown { font-variant-numeric: tabular-nums; display: inline-block; - min-width: 4.2em; text-align: left; } -.sep { color: var(--muted); user-select: none; } - -/* iOS-style toggle switch */ -.switch { position: relative; display: inline-block; width: 46px; height: 26px; flex-shrink: 0; } -.switch input { opacity: 0; width: 0; height: 0; position: absolute; } -.switch-visual { position: absolute; cursor: pointer; inset: 0; - background: #cfd9e6; border-radius: 999px; transition: background .2s; } -.switch-visual::before { content: ""; position: absolute; width: 20px; height: 20px; - left: 3px; top: 3px; background: #fff; border-radius: 50%; - box-shadow: 0 1px 3px rgba(16,37,63,0.25); transition: transform .2s; } -.switch input:checked + .switch-visual { background: var(--primary); } -.switch input:checked + .switch-visual::before { transform: translateX(20px); } -.switch.warn input:checked + .switch-visual { background: var(--danger); } -.switch input:focus-visible + .switch-visual { box-shadow: 0 0 0 3px rgba(47,138,224,.25); } - -/* View toggle (Liste / Karte) — segmented pill, CSS-only via :has() */ -.view-toggle { display: inline-flex; border: 1px solid var(--border); border-radius: 999px; - overflow: hidden; background: var(--surface); font-size: 0.85rem; font-weight: 500; } -.view-toggle label { padding: 0.35rem 0.95rem; cursor: pointer; user-select: none; - color: var(--muted); transition: background .15s, color .15s; } -.view-toggle input { position: absolute; opacity: 0; pointer-events: none; width: 0; height: 0; } -.view-toggle label:hover { color: var(--text); background: var(--ghost); } -.view-toggle label:has(input:checked) { background: var(--primary); color: #fff; } -.view-map { display: none; } -body:has(#v_map:checked) .view-list { display: none; } -body:has(#v_map:checked) .view-map { display: block; } -#flats-map { height: 520px; border-radius: 10px; } - -/* Flat detail expand */ -.flat-row { border-top: 1px solid var(--border); } -.flat-row:first-child { border-top: 0; } -.flat-expand-btn { width: 1.75rem; height: 1.75rem; border-radius: 999px; - display: inline-flex; align-items: center; justify-content: center; - border: 1px solid var(--border); background: var(--surface); - color: var(--muted); cursor: pointer; transition: transform .2s, background .15s; } -.flat-expand-btn:hover { background: var(--ghost); color: var(--text); } -.flat-expand-btn.open { transform: rotate(180deg); } -.flat-detail { background: #fafcfe; border-top: 1px solid var(--border); } -.flat-detail:empty { display: none; } - -/* Normalised image gallery — every tile has the same aspect ratio */ -.flat-gallery { display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); - gap: 8px; } -.flat-gallery-tile { aspect-ratio: 4 / 3; overflow: hidden; - border-radius: 8px; border: 1px solid var(--border); - background: #f0f5fa; display: block; } -.flat-gallery-tile img { width: 100%; height: 100%; object-fit: cover; - display: block; transition: transform .3s; } -.flat-gallery-tile:hover img { transform: scale(1.04); } - -/* Leaflet popup — match site visual */ -.leaflet-popup-content-wrapper { border-radius: 12px; box-shadow: 0 6px 20px rgba(16,37,63,.15); } -.leaflet-popup-content { margin: 12px 14px; min-width: 220px; color: var(--text); } -.map-popup-title { font-weight: 600; font-size: 13px; display: inline-block; color: var(--primary); } -.map-popup-title:hover { text-decoration: underline; } -.map-popup-meta { color: var(--muted); font-size: 12px; margin-top: 2px; } -.map-popup-status { margin-top: 8px; } -.map-popup-actions { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; } -.map-popup-actions .btn { padding: 0.35rem 0.7rem; font-size: 12px; } -.map-popup-actions form { margin: 0; } - -/* Brand avatar */ -.brand-dot { - width: 2.5rem; height: 2.5rem; border-radius: 10px; - background: linear-gradient(135deg, #66b7f2 0%, #2f8ae0 60%, #fbd76b 100%); - box-shadow: 0 1px 4px rgba(47, 138, 224, 0.35); - display: inline-flex; align-items: center; justify-content: center; - overflow: hidden; flex-shrink: 0; -} -.brand-dot img { width: 88%; height: 88%; object-fit: contain; display: block; } - -/* Anchors */ -a { color: var(--primary); } -a:hover { text-decoration: underline; } - -/* Tab nav */ -.tab { padding: 0.7rem 0.2rem; color: var(--muted); border-bottom: 2px solid transparent; - margin-right: 1.5rem; font-weight: 500; } -.tab.active { color: var(--text); border-color: var(--primary); } -.tab:hover { color: var(--text); text-decoration: none; } - -/* Mono / forensic JSON tree */ -.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; } -details > summary { cursor: pointer; user-select: none; } -details > summary::marker { color: var(--muted); } diff --git a/web/templates/_wohnungen_body.html b/web/templates/_wohnungen_body.html index 6672a6e..2c3cef9 100644 --- a/web/templates/_wohnungen_body.html +++ b/web/templates/_wohnungen_body.html @@ -158,7 +158,8 @@ {% if partner and f.id in partner.applied_flat_ids %} @@ -175,7 +176,8 @@ {% if partner and f.id in partner.rejected_flat_ids %} diff --git a/web/templates/base.html b/web/templates/base.html index 251e485..bfd7139 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -12,9 +12,162 @@ integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""> - + {% block body %}{% endblock %}