Tints districts the user's Bezirk filter has EXCLUDED in light yellow
(#fde68a, fillOpacity 0.35) so the active selection is obvious from
the map alone — and you can see at a glance whether a "rausgefiltert"
flat fell in a no-go district. When no district filter is set the
overlay stays off entirely (nothing is excluded).
Wiring:
- Berlin Bezirke GeoJSON checked in at web/static/berlin-districts.geojson
(12 features, name property matches our DISTRICTS list 1:1, props
stripped down to {name} and minified — 312KB raw, ~80KB gzipped).
- Route exposes the user's selected_districts_csv to the template.
- The flats-map-data <script> carries it on data-selected-districts so
it flows in alongside csrf and the marker payload.
- map.js fetches the GeoJSON once (cache normally), keeps the layer in
a module-level reference, and re-styles it via setStyle() on every
swap (cheap). Marker layer is kicked to the front so pins always
paint above the shaded polygons. fingerprintOf now also folds in
the selected-districts CSV so a Bezirk-only filter change still
triggers a re-render.
Branch only — kept off main while we see if this reads well.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
381 lines
14 KiB
Python
381 lines
14 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 berlin_districts import DISTRICTS, district_for_address
|
|
from matching import flat_filter_failures, 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)
|
|
has_filters_set = _has_filters(filters_row)
|
|
flats_view = []
|
|
filtered_out_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
|
|
failures = flat_filter_failures({
|
|
"rooms": f["rooms"], "total_rent": f["total_rent"], "size": f["size"],
|
|
"wbs": f["wbs"],
|
|
"district": district_for_address(f["address"]),
|
|
}, filters)
|
|
if failures:
|
|
# Only surface a "Rausgefiltert" entry if the user actually has
|
|
# filters set — otherwise nothing fails and the section is empty
|
|
# anyway, but skip the work to keep the loop tight.
|
|
if has_filters_set:
|
|
filtered_out_view.append({"row": f, "reasons": failures})
|
|
continue
|
|
flats_view.append({"row": f, "last": latest_apps.get(f["id"])})
|
|
|
|
rejected_view = db.rejected_flats(uid)
|
|
|
|
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,
|
|
})
|
|
# Bezirke the user has narrowed to (CSV). Empty = no district filter, in
|
|
# which case the map's "excluded districts" overlay stays off entirely.
|
|
try:
|
|
selected_districts_csv = (filters_row["districts"] if filters_row else "") or ""
|
|
except (KeyError, IndexError, TypeError):
|
|
selected_districts_csv = ""
|
|
|
|
return {
|
|
"flats": flats_view,
|
|
"rejected_flats": rejected_view,
|
|
"filtered_out_flats": filtered_out_view,
|
|
"partner": partner_info,
|
|
"map_points": map_points,
|
|
"selected_districts_csv": selected_districts_csv,
|
|
"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, 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-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)
|