"""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 berlin_districts import DISTRICTS, district_for_address 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"], "district": district_for_address(f["address"]), }, 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, user=Depends(require_user)): """The Bezirk filter uses multi-valued `districts` checkboxes, which FastAPI's Form(...) args don't express cleanly — read the whole form via request.form() so we can getlist() on it.""" form = await request.form() require_csrf(user["id"], form.get("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 # Canonicalise district selection against the known set. Treat "all # ticked" and "none ticked" identically as "no filter" so the default # and the nuclear-no-results states both mean the same thing. all_districts = set(DISTRICTS) submitted = {d for d in form.getlist("districts") if d in all_districts} if submitted in (set(), all_districts): districts_csv = "" else: districts_csv = ",".join(d for d in DISTRICTS if d in submitted) db.update_filters(user["id"], { "rooms_min": _f(form.get("rooms_min", "")), "rooms_max": _f(form.get("rooms_max", "")), "max_rent": _f(form.get("max_rent", "")), "min_size": _f(form.get("min_size", "")), "wbs_required": (form.get("wbs_required") or "").strip(), "max_age_hours": _i(form.get("max_age_hours", "")), "districts": districts_csv, }) 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)