- Surface "X/Y passende Wohnungen mit Koordinaten" on the Karte view +
admin-only "Koordinaten nachladen" button (POST /actions/backfill-coords)
that geocodes missing flats via Google Maps directly from the web container
- Add googlemaps dep + GMAPS_API_KEY env to web service
- Light console.log in map.js ("rendering N/M markers", "building Leaflet…")
so the browser DevTools shows what's happening
- Drop e-mail channel from notifications UI, notify dispatcher, and _alert_status;
coerce legacy 'email' channel rows back to 'ui' on save
- Countdown said "Aktualisierung läuft…" next to "nächste Aktualisierung" →
shortened to "aktualisiere…"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1042 lines
38 KiB
Python
1042 lines
38 KiB
Python
"""
|
||
lazyflat web app.
|
||
|
||
Four tabs:
|
||
- / → Wohnungen (all flats, per-user match highlighting, filter block, auto-apply switch)
|
||
- /bewerbungen → Bewerbungen (history + forensics; failed apps expose a ZIP report download)
|
||
- /logs → Logs (user-scoped audit log)
|
||
- /einstellungen/<section> → Einstellungen: profile, filter, notifications, account, admin users
|
||
|
||
All state-changing POSTs require CSRF. Internal endpoints require INTERNAL_API_KEY.
|
||
"""
|
||
import asyncio
|
||
import base64
|
||
import hmac
|
||
import io
|
||
import json
|
||
import logging
|
||
import sqlite3
|
||
import zipfile
|
||
from contextlib import asynccontextmanager
|
||
from datetime import datetime, timedelta, timezone
|
||
from typing import Any
|
||
|
||
from fastapi import Depends, FastAPI, Form, Header, HTTPException, Request, Response, status
|
||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, 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 geocode
|
||
import notifications
|
||
import retention
|
||
from apply_client import ApplyClient, _row_to_profile
|
||
from auth import (
|
||
bootstrap_admin,
|
||
clear_session_cookie,
|
||
current_user,
|
||
hash_password,
|
||
issue_csrf_token,
|
||
issue_session_cookie,
|
||
rate_limit_login,
|
||
require_admin,
|
||
require_csrf,
|
||
require_user,
|
||
verify_login,
|
||
)
|
||
from matching import flat_matches_filter, row_to_dict
|
||
from settings import (
|
||
ALERT_SCRAPE_INTERVAL_SECONDS,
|
||
APPLY_FAILURE_THRESHOLD,
|
||
INTERNAL_API_KEY,
|
||
PUBLIC_URL,
|
||
)
|
||
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s %(levelname)-5s %(name)s: %(message)s",
|
||
datefmt="%H:%M:%S",
|
||
)
|
||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||
logger = logging.getLogger("web")
|
||
|
||
apply_client = ApplyClient()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# App + Jinja
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(_app: FastAPI):
|
||
db.init_db()
|
||
bootstrap_admin()
|
||
retention.start()
|
||
logger.info("web service ready")
|
||
yield
|
||
|
||
|
||
app = FastAPI(lifespan=lifespan, title="lazyflat", 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")
|
||
|
||
|
||
templates.env.filters["de_dt"] = _de_dt
|
||
templates.env.filters["iso_utc"] = _iso_utc
|
||
|
||
|
||
@app.middleware("http")
|
||
async def security_headers(request: Request, call_next):
|
||
resp: Response = await call_next(request)
|
||
resp.headers.setdefault("X-Frame-Options", "DENY")
|
||
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||
resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||
resp.headers.setdefault("Permissions-Policy", "geolocation=(), camera=(), microphone=()")
|
||
resp.headers.setdefault(
|
||
"Content-Security-Policy",
|
||
"default-src 'self'; "
|
||
"script-src 'self' https://cdn.tailwindcss.com https://unpkg.com; "
|
||
"style-src 'self' https://cdn.tailwindcss.com https://unpkg.com 'unsafe-inline'; "
|
||
"img-src 'self' data: https://*.tile.openstreetmap.org https://tile.openstreetmap.org https://unpkg.com; "
|
||
"connect-src 'self'; frame-ancestors 'none';"
|
||
)
|
||
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 _next_scrape_utc() -> str:
|
||
hb = db.get_state("last_alert_heartbeat")
|
||
dt = _parse_iso(hb)
|
||
if dt is None:
|
||
return ""
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=timezone.utc)
|
||
return (dt + timedelta(seconds=ALERT_SCRAPE_INTERVAL_SECONDS)).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||
|
||
|
||
FILTER_KEYS = ("rooms_min", "rooms_max", "max_rent", "min_size", "max_morning_commute", "wbs_required")
|
||
|
||
|
||
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["max_morning_commute"]:
|
||
parts.append(f"≤ {int(f['max_morning_commute'])} min")
|
||
if f["wbs_required"] == "yes":
|
||
parts.append("WBS")
|
||
elif f["wbs_required"] == "no":
|
||
parts.append("ohne WBS")
|
||
return " · ".join(parts)
|
||
|
||
|
||
def _has_running_application(user_id: int) -> bool:
|
||
row = db._conn.execute(
|
||
"SELECT 1 FROM applications WHERE user_id = ? AND finished_at IS NULL LIMIT 1",
|
||
(user_id,),
|
||
).fetchone()
|
||
return row is not None
|
||
|
||
|
||
def _run_apply_background(user_id: int, flat_id: str, url: str, triggered_by: str) -> None:
|
||
prefs = db.get_preferences(user_id)
|
||
profile_row = db.get_profile(user_id)
|
||
profile = _row_to_profile(profile_row)
|
||
submit_forms = bool(prefs["submit_forms"])
|
||
|
||
app_id = db.start_application(
|
||
user_id=user_id, flat_id=flat_id, url=url,
|
||
triggered_by=triggered_by, submit_forms=submit_forms,
|
||
profile_snapshot=profile,
|
||
)
|
||
|
||
logger.info("apply.start user=%s flat=%s application=%s submit=%s",
|
||
user_id, flat_id, app_id, submit_forms)
|
||
|
||
result = apply_client.apply(url=url, profile=profile,
|
||
submit_forms=submit_forms, application_id=app_id)
|
||
success = bool(result.get("success"))
|
||
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)
|
||
|
||
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:
|
||
asyncio.create_task(asyncio.to_thread(
|
||
_run_apply_background, user_id, flat_id, url, triggered_by,
|
||
))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Public routes
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@app.get("/health")
|
||
def health():
|
||
return {"status": "ok"}
|
||
|
||
|
||
@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)
|
||
flats_view = []
|
||
for f in flats:
|
||
if f["id"] in rejected:
|
||
continue
|
||
if not flat_matches_filter({
|
||
"rooms": f["rooms"], "total_rent": f["total_rent"], "size": f["size"],
|
||
"wbs": f["wbs"], "connectivity": {"morning_time": f["connectivity_morning_time"]},
|
||
}, filters):
|
||
continue
|
||
last = db.last_application_for_flat(uid, f["id"])
|
||
flats_view.append({"row": f, "last": last})
|
||
|
||
rejected_view = db.rejected_flats(uid)
|
||
matched_count = len(flats_view)
|
||
matched_with_coords = sum(
|
||
1 for item in flats_view
|
||
if item["row"]["lat"] is not None and item["row"]["lng"] is not None
|
||
)
|
||
matched_without_coords = matched_count - matched_with_coords
|
||
|
||
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
|
||
map_points.append({
|
||
"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"],
|
||
})
|
||
return {
|
||
"flats": flats_view,
|
||
"rejected_flats": rejected_view,
|
||
"map_points": map_points,
|
||
"map_matched_total": matched_count,
|
||
"map_matched_with_coords": matched_with_coords,
|
||
"map_matched_without_coords": matched_without_coords,
|
||
"gmaps_available": bool(geocode._get_client() is not None),
|
||
"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(),
|
||
"next_scrape_utc": _next_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.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(""),
|
||
max_morning_commute: str = Form(""),
|
||
wbs_required: 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
|
||
|
||
db.update_filters(user["id"], {
|
||
"rooms_min": _f(rooms_min),
|
||
"rooms_max": _f(rooms_max),
|
||
"max_rent": _f(max_rent),
|
||
"min_size": _f(min_size),
|
||
"max_morning_commute": _f(max_morning_commute),
|
||
"wbs_required": (wbs_required or "").strip(),
|
||
})
|
||
db.log_audit(user["username"], "filters.updated", user_id=user["id"], ip=client_ip(request))
|
||
return RedirectResponse("/", status_code=303)
|
||
|
||
|
||
@app.post("/actions/auto-apply")
|
||
async def action_auto_apply(
|
||
request: Request,
|
||
value: str = Form(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")
|
||
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/backfill-coords")
|
||
async def action_backfill_coords(
|
||
request: Request,
|
||
csrf: str = Form(...),
|
||
admin=Depends(require_admin),
|
||
):
|
||
require_csrf(admin["id"], csrf)
|
||
rows = db.flats_missing_coords(limit=500)
|
||
total = len(rows)
|
||
resolved = 0
|
||
skipped = 0
|
||
for row in rows:
|
||
coords = geocode.geocode(row["address"])
|
||
if coords is None:
|
||
skipped += 1
|
||
continue
|
||
db.set_flat_coords(row["id"], coords[0], coords[1])
|
||
resolved += 1
|
||
summary = f"{resolved}/{total} geocoded, {skipped} übersprungen"
|
||
logger.info("coord backfill: %s", summary)
|
||
db.log_audit(admin["username"], "coords.backfill", summary,
|
||
user_id=admin["id"], ip=client_ip(request))
|
||
return _wohnungen_partial_or_redirect(request, admin)
|
||
|
||
|
||
@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}", response_class=HTMLResponse)
|
||
def bewerbung_detail(request: Request, app_id: int):
|
||
u = current_user(request)
|
||
if not u:
|
||
return RedirectResponse("/login", status_code=303)
|
||
a = db.get_application(app_id)
|
||
if not a or (a["user_id"] != u["id"] and not u["is_admin"]):
|
||
raise HTTPException(404, "not found")
|
||
forensics = json.loads(a["forensics_json"]) if a["forensics_json"] else None
|
||
profile = json.loads(a["profile_snapshot_json"]) if a["profile_snapshot_json"] else {}
|
||
ctx = base_context(request, u, "bewerbungen")
|
||
ctx.update({"application": a, "forensics": forensics, "profile_snapshot": profile})
|
||
return templates.TemplateResponse("bewerbung_detail.html", ctx)
|
||
|
||
|
||
@app.get("/bewerbungen/{app_id}/report.zip")
|
||
def bewerbung_zip(request: Request, app_id: int):
|
||
u = current_user(request)
|
||
if not u:
|
||
raise HTTPException(401)
|
||
a = db.get_application(app_id)
|
||
if not a or (a["user_id"] != u["id"] and not u["is_admin"]):
|
||
raise HTTPException(404)
|
||
|
||
flat = db.get_flat(a["flat_id"])
|
||
forensics = json.loads(a["forensics_json"]) if a["forensics_json"] else {}
|
||
profile = json.loads(a["profile_snapshot_json"]) if a["profile_snapshot_json"] else {}
|
||
app_meta = {k: a[k] for k in a.keys() if k not in ("forensics_json", "profile_snapshot_json")}
|
||
|
||
buf = io.BytesIO()
|
||
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||
zf.writestr(
|
||
"README.txt",
|
||
f"lazyflat application report\n"
|
||
f"application_id={a['id']}\n"
|
||
f"flat_id={a['flat_id']}\n"
|
||
f"provider={a['provider']}\n"
|
||
f"success={a['success']}\n"
|
||
f"started_at_utc={a['started_at']}\n"
|
||
f"finished_at_utc={a['finished_at']}\n"
|
||
f"submit_forms_used={bool(a['submit_forms_used'])}\n"
|
||
f"\n"
|
||
f"Contents:\n"
|
||
f" application.json DB row + metadata\n"
|
||
f" flat.json Flat details at discovery time\n"
|
||
f" profile_snapshot.json Profile used for this attempt\n"
|
||
f" forensics.json Full captured forensics\n"
|
||
f" step_log.txt Human-readable step log\n"
|
||
f" page.html Final page HTML (if captured)\n"
|
||
f" console.json Browser console entries\n"
|
||
f" errors.json Browser pageerror events\n"
|
||
f" network.json Network requests + partial responses\n"
|
||
f" 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"lazyflat-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) -> list[dict]:
|
||
users = {row["id"]: row["username"] for row in db.list_users()}
|
||
events: list[dict] = []
|
||
for a in db.recent_audit(None, limit=5000):
|
||
if start_iso and a["timestamp"] < start_iso: continue
|
||
if end_iso and a["timestamp"] >= end_iso: continue
|
||
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.recent_errors(None, limit=5000):
|
||
if start_iso and e["timestamp"] < start_iso: continue
|
||
if end_iso and e["timestamp"] >= end_iso: continue
|
||
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", response_class=HTMLResponse)
|
||
def tab_logs(request: Request):
|
||
u = current_user(request)
|
||
if not u:
|
||
return RedirectResponse("/login", status_code=303)
|
||
if not u["is_admin"]:
|
||
raise HTTPException(403, "admin only")
|
||
|
||
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)
|
||
events = _collect_events(start_iso, end_iso)[:500]
|
||
|
||
ctx = base_context(request, u, "logs")
|
||
ctx.update({"events": events, "from_str": from_str, "to_str": to_str})
|
||
return templates.TemplateResponse("logs.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)
|
||
|
||
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 = "lazyflat-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", "account", "benutzer")
|
||
|
||
|
||
@app.get("/einstellungen", response_class=HTMLResponse)
|
||
def tab_settings_root(request: Request):
|
||
return RedirectResponse("/einstellungen/profil", status_code=303)
|
||
|
||
|
||
@app.get("/einstellungen/{section}", response_class=HTMLResponse)
|
||
def tab_settings(request: Request, section: str):
|
||
u = current_user(request)
|
||
if not u:
|
||
return RedirectResponse("/login", status_code=303)
|
||
if section not in VALID_SECTIONS:
|
||
raise HTTPException(404)
|
||
if section == "benutzer" and not u["is_admin"]:
|
||
raise HTTPException(403)
|
||
|
||
ctx = base_context(request, u, "einstellungen")
|
||
ctx["section"] = section
|
||
|
||
if section == "profil":
|
||
ctx["profile"] = db.get_profile(u["id"])
|
||
elif section == "filter":
|
||
ctx["filters"] = row_to_dict(db.get_filters(u["id"]))
|
||
elif section == "benachrichtigungen":
|
||
ctx["notifications"] = db.get_notifications(u["id"])
|
||
elif section == "account":
|
||
pass
|
||
elif section == "benutzer":
|
||
ctx["users"] = db.list_users()
|
||
return templates.TemplateResponse("einstellungen.html", ctx)
|
||
|
||
|
||
@app.post("/actions/profile")
|
||
async def action_profile(request: Request, user=Depends(require_user)):
|
||
form = await request.form()
|
||
require_csrf(user["id"], form.get("csrf", ""))
|
||
|
||
def _b(name): return form.get(name, "").lower() in ("true", "on", "yes", "1")
|
||
def _i(name):
|
||
try: return int(form.get(name) or 0)
|
||
except ValueError: return 0
|
||
|
||
db.update_profile(user["id"], {
|
||
"salutation": form.get("salutation", ""),
|
||
"firstname": form.get("firstname", ""),
|
||
"lastname": form.get("lastname", ""),
|
||
"email": form.get("email", ""),
|
||
"telephone": form.get("telephone", ""),
|
||
"street": form.get("street", ""),
|
||
"house_number": form.get("house_number", ""),
|
||
"postcode": form.get("postcode", ""),
|
||
"city": form.get("city", ""),
|
||
"is_possessing_wbs": 1 if _b("is_possessing_wbs") else 0,
|
||
"wbs_type": form.get("wbs_type", "0"),
|
||
"wbs_valid_till": form.get("wbs_valid_till", "1970-01-01"),
|
||
"wbs_rooms": _i("wbs_rooms"),
|
||
"wbs_adults": _i("wbs_adults"),
|
||
"wbs_children": _i("wbs_children"),
|
||
"is_prio_wbs": 1 if _b("is_prio_wbs") else 0,
|
||
"immomio_email": form.get("immomio_email", ""),
|
||
"immomio_password": form.get("immomio_password", ""),
|
||
})
|
||
db.log_audit(user["username"], "profile.updated", user_id=user["id"], ip=client_ip(request))
|
||
return RedirectResponse("/einstellungen/profil", status_code=303)
|
||
|
||
|
||
@app.post("/actions/notifications")
|
||
async def action_notifications(request: Request, user=Depends(require_user)):
|
||
form = await request.form()
|
||
require_csrf(user["id"], form.get("csrf", ""))
|
||
def _b(n): return 1 if form.get(n, "").lower() in ("on", "true", "1", "yes") else 0
|
||
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", ""),
|
||
"email_address": "",
|
||
"notify_on_match": _b("notify_on_match"),
|
||
"notify_on_apply_success": _b("notify_on_apply_success"),
|
||
"notify_on_apply_fail": _b("notify_on_apply_fail"),
|
||
})
|
||
db.log_audit(user["username"], "notifications.updated", user_id=user["id"], ip=client_ip(request))
|
||
return RedirectResponse("/einstellungen/benachrichtigungen", status_code=303)
|
||
|
||
|
||
@app.post("/actions/account/password")
|
||
async def action_password(
|
||
request: Request,
|
||
old_password: str = Form(""),
|
||
new_password: str = Form(""),
|
||
new_password_repeat: str = Form(""),
|
||
csrf: str = Form(...),
|
||
user=Depends(require_user),
|
||
):
|
||
require_csrf(user["id"], csrf)
|
||
if not new_password or new_password != new_password_repeat:
|
||
return RedirectResponse("/einstellungen/account?err=mismatch", status_code=303)
|
||
if len(new_password) < 10:
|
||
return RedirectResponse("/einstellungen/account?err=tooshort", status_code=303)
|
||
row = db.get_user_by_username(user["username"])
|
||
from auth import verify_hash
|
||
if not row or not verify_hash(row["password_hash"], old_password):
|
||
return RedirectResponse("/einstellungen/account?err=wrongold", status_code=303)
|
||
db.set_user_password(user["id"], hash_password(new_password))
|
||
db.log_audit(user["username"], "password.changed", user_id=user["id"], ip=client_ip(request))
|
||
return RedirectResponse("/einstellungen/account?ok=1", status_code=303)
|
||
|
||
|
||
@app.post("/actions/submit-forms")
|
||
async def action_submit_forms(
|
||
request: Request,
|
||
value: str = Form(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("/einstellungen/benutzer?err=exists", status_code=303)
|
||
db.log_audit(admin["username"], "user.created", f"new_user={username} id={uid}",
|
||
user_id=admin["id"], ip=client_ip(request))
|
||
return RedirectResponse("/einstellungen/benutzer?ok=1", status_code=303)
|
||
|
||
|
||
@app.post("/actions/users/disable")
|
||
async def action_users_disable(
|
||
request: Request,
|
||
target_id: int = Form(...),
|
||
value: str = Form(...),
|
||
csrf: str = Form(...),
|
||
admin=Depends(require_admin),
|
||
):
|
||
require_csrf(admin["id"], csrf)
|
||
if target_id == admin["id"]:
|
||
raise HTTPException(400, "refusing to disable self")
|
||
db.set_user_disabled(target_id, value == "on")
|
||
db.log_audit(admin["username"], "user.toggle_disable",
|
||
f"target={target_id} disabled={value=='on'}",
|
||
user_id=admin["id"], ip=client_ip(request))
|
||
return RedirectResponse("/einstellungen/benutzer", status_code=303)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Internal endpoints
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@app.post("/internal/flats")
|
||
async def internal_submit_flat(
|
||
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"}
|
||
|
||
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"}
|