feat(filter): Berlin-Bezirk filter in Einstellungen

Adds a collapsible 12-Bezirk checkbox list to the filter tab. The UI
speaks Bezirke; internally the match runs on the PLZ extracted from
flat.address and resolved to a dominant Bezirk via a curated 187-PLZ
map (berlin_districts.py).

- Migration 0011 adds user_filters.districts (CSV of selected names)
- Empty stored value = no filter = all Bezirke ticked in the UI.
  Submitting "all ticked" or "none ticked" both normalise to empty
  so the defaults and the nuclear state mean the same thing.
- When a Bezirk filter is active, flats with an unknown/unmapped PLZ
  are excluded — if the user bothered to narrow by district, sneaking
  in unplaceable flats would be the wrong default.
- Filter summary on the Wohnungen page shows "N Bezirke" so it's
  visible the filter is active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
EiSiMo 2026-04-23 10:05:55 +02:00
parent abd614604f
commit d13f9c5b6e
7 changed files with 202 additions and 20 deletions

View file

@ -7,6 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
import db
from auth import current_user, hash_password, require_csrf, require_user
from berlin_districts import DISTRICTS
from common import base_context, client_ip, templates
from matching import row_to_dict
from notifications import telegram_send
@ -40,6 +41,13 @@ def tab_settings(request: Request, section: str):
ctx["profile"] = db.get_profile(u["id"])
elif section == "filter":
ctx["filters"] = row_to_dict(db.get_filters(u["id"]))
stored = (ctx["filters"].get("districts") or "").strip()
# Empty stored value = no filter = all Bezirke ticked in the UI.
ctx["all_districts"] = DISTRICTS
ctx["selected_districts"] = (
set(DISTRICTS) if not stored
else {d.strip() for d in stored.split(",") if d.strip()}
)
elif section == "benachrichtigungen":
ctx["notifications"] = db.get_notifications(u["id"])
ctx["notif_flash"] = request.query_params.get("flash") or ""

View file

@ -27,6 +27,7 @@ from common import (
client_ip,
templates,
)
from berlin_districts import DISTRICTS, district_for_address
from matching import flat_matches_filter, row_to_dict
@ -64,6 +65,7 @@ def _wohnungen_context(user) -> dict:
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"])})
@ -209,18 +211,12 @@ def flat_image(slug: str, index: int):
@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)
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(",", ".")
@ -233,13 +229,24 @@ async def action_save_filters(
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(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),
"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)