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 ""