lazyflat/web/routes/wohnungen.py
EiSiMo f1e26b38d0 refactor: split web/app.py into routers
app.py was ~1300 lines with every route, helper, and middleware mixed
together. Split into:

- app.py (~100 lines): FastAPI bootstrap, lifespan, /health, security
  headers, Jinja filter registration, include_router calls
- common.py: shared helpers (templates, apply_client, base_context,
  _is_htmx, client_ip, require_internal, time helpers, filter helpers,
  apply-gate helpers, _kick_apply / _finish_apply_background,
  _bg_tasks, _spawn, _mask_secret, _has_running_application, BERLIN_TZ)
- routes/auth.py: /login (GET+POST), /logout
- routes/wohnungen.py: /, /partials/wohnungen, /partials/wohnung/{id},
  /flat-images/{slug}/{idx}, /actions/apply|reject|unreject|auto-apply|
  submit-forms|reset-circuit|filters|enrich-all|enrich-flat; owns
  _wohnungen_context + _wohnungen_partial_or_redirect
- routes/bewerbungen.py: /bewerbungen, /bewerbungen/{id}/report.zip
- routes/einstellungen.py: /einstellungen, /einstellungen/{section},
  /actions/profile|notifications|account/password|partner/*; owns
  VALID_SECTIONS
- routes/admin.py: /logs redirect, /admin, /admin/{section},
  /logs/export.csv, /actions/users/*|secrets; owns ADMIN_SECTIONS,
  _parse_date_range, _collect_events
- routes/internal.py: /internal/flats|heartbeat|error|secrets

Route-diff before/after is empty — all 41 routes + /static mount
preserved. No behavior changes, pure mechanical split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:27:12 +02:00

372 lines
13 KiB
Python

"""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.<ext>; 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)