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

120
web/berlin_districts.py Normal file
View file

@ -0,0 +1,120 @@
"""Berlin-Bezirke taxonomy + PLZ lookup.
The user-visible taxonomy is the 12 Berlin Bezirke. We derive the Bezirk
from the flat's freeform address by extracting the 5-digit PLZ and
looking it up in PLZ_TO_DISTRICT.
Some PLZs straddle two Bezirke in reality. Each is mapped to its
dominant Bezirk close enough for a list filter. Unmapped PLZs / flats
without a parseable address resolve to None; the filter treats those as
"unknown" and excludes them when any Bezirk filter is active.
"""
import re
DISTRICTS: list[str] = [
"Mitte",
"Friedrichshain-Kreuzberg",
"Pankow",
"Charlottenburg-Wilmersdorf",
"Spandau",
"Steglitz-Zehlendorf",
"Tempelhof-Schöneberg",
"Neukölln",
"Treptow-Köpenick",
"Marzahn-Hellersdorf",
"Lichtenberg",
"Reinickendorf",
]
_DISTRICT_PLZ: dict[str, tuple[str, ...]] = {
"Mitte": (
"10115", "10117", "10119", "10178", "10179",
"10551", "10553", "10555", "10557", "10559",
"13347", "13349", "13351", "13353", "13355", "13357", "13359",
),
"Friedrichshain-Kreuzberg": (
"10243", "10245", "10247", "10249",
"10961", "10963", "10965", "10967", "10969",
"10997", "10999",
),
"Pankow": (
"10405", "10407", "10409",
"10435", "10437", "10439",
"13086", "13088", "13089",
"13125", "13127", "13129",
"13156", "13158", "13159",
"13187", "13189",
),
"Charlottenburg-Wilmersdorf": (
"10585", "10587", "10589",
"10623", "10625", "10627", "10629",
"10707", "10709", "10711", "10713", "10715", "10717", "10719",
"10789",
"14050", "14052", "14053", "14055", "14057", "14059",
"14193", "14195", "14197", "14199",
),
"Spandau": (
"13581", "13583", "13585", "13587", "13589",
"13591", "13593", "13595", "13597", "13599",
"14089",
),
"Steglitz-Zehlendorf": (
"12157", "12159", "12161", "12163", "12165", "12167", "12169",
"12203", "12205", "12207", "12209",
"12247", "12249",
"14109", "14129",
"14163", "14165", "14167", "14169",
),
"Tempelhof-Schöneberg": (
"10777", "10779", "10781", "10783", "10785", "10787",
"10823", "10825", "10827", "10829",
"12099", "12101", "12103", "12105", "12107", "12109",
"12277", "12279",
"12305", "12307", "12309",
),
"Neukölln": (
"12043", "12045", "12047", "12049",
"12051", "12053", "12055", "12057", "12059",
"12347", "12349",
"12351", "12353", "12355", "12357", "12359",
),
"Treptow-Köpenick": (
"12435", "12437", "12439",
"12459", "12487", "12489",
"12524", "12526", "12527",
"12555", "12557", "12559",
"12587", "12589",
),
"Marzahn-Hellersdorf": (
"12619", "12621", "12623", "12627", "12629",
"12679", "12681", "12683", "12685", "12687", "12689",
),
"Lichtenberg": (
"10315", "10317", "10318",
"10365", "10367", "10369",
"13051", "13053", "13055", "13057", "13059",
),
"Reinickendorf": (
"13403", "13405", "13407", "13409",
"13435", "13437", "13439",
"13465", "13467", "13469",
"13503", "13505", "13507", "13509",
),
}
PLZ_TO_DISTRICT: dict[str, str] = {
plz: district
for district, plzs in _DISTRICT_PLZ.items()
for plz in plzs
}
_PLZ_RE = re.compile(r"\b(\d{5})\b")
def district_for_address(address: str | None) -> str | None:
if not address:
return None
m = _PLZ_RE.search(address)
if not m:
return None
return PLZ_TO_DISTRICT.get(m.group(1))

View file

@ -126,7 +126,7 @@ def _is_htmx(request: Request) -> bool:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
FILTER_KEYS = ("rooms_min", "rooms_max", "max_rent", "min_size", FILTER_KEYS = ("rooms_min", "rooms_max", "max_rent", "min_size",
"wbs_required", "max_age_hours") "wbs_required", "max_age_hours", "districts")
def _has_filters(f) -> bool: def _has_filters(f) -> bool:
@ -175,6 +175,13 @@ def _filter_summary(f) -> str:
parts.append("ohne WBS") parts.append("ohne WBS")
if f["max_age_hours"]: if f["max_age_hours"]:
parts.append(f"{int(f['max_age_hours'])} h alt") parts.append(f"{int(f['max_age_hours'])} h alt")
try:
districts_csv = (f["districts"] or "").strip()
except (KeyError, IndexError):
districts_csv = ""
if districts_csv:
n = sum(1 for d in districts_csv.split(",") if d.strip())
parts.append(f"{n} Bezirk{'e' if n != 1 else ''}")
return " · ".join(parts) return " · ".join(parts)

View file

@ -270,6 +270,11 @@ MIGRATIONS: list[str] = [
ALTER TABLE flats ADD COLUMN offline_at TEXT; ALTER TABLE flats ADD COLUMN offline_at TEXT;
CREATE INDEX IF NOT EXISTS idx_flats_offline ON flats(offline_at); CREATE INDEX IF NOT EXISTS idx_flats_offline ON flats(offline_at);
""", """,
# 0011: per-user Berlin-Bezirk filter. CSV of selected Bezirk names;
# empty = no filter (= all Bezirke match).
"""
ALTER TABLE user_filters ADD COLUMN districts TEXT NOT NULL DEFAULT '';
""",
] ]
@ -463,7 +468,7 @@ def get_filters(user_id: int) -> sqlite3.Row:
def update_filters(user_id: int, data: dict) -> None: def update_filters(user_id: int, data: dict) -> None:
_ensure_user_rows(user_id) _ensure_user_rows(user_id)
allowed = {"rooms_min", "rooms_max", "max_rent", "min_size", allowed = {"rooms_min", "rooms_max", "max_rent", "min_size",
"wbs_required", "max_age_hours"} "wbs_required", "max_age_hours", "districts"}
clean = {k: data.get(k) for k in allowed if k in data} clean = {k: data.get(k) for k in allowed if k in data}
if not clean: if not clean:
return return

View file

@ -37,6 +37,17 @@ def flat_matches_filter(flat: dict, f: dict | None) -> bool:
if wbs_str and wbs_str not in ("kein", "nein", "no", "ohne", "-"): if wbs_str and wbs_str not in ("kein", "nein", "no", "ohne", "-"):
return False return False
# Berlin-Bezirk filter. Empty string = no filter = all Bezirke match.
# When active, flats with an unknown/unmapped PLZ are excluded — if the
# user bothered to narrow by district, we shouldn't sneak in flats we
# couldn't place.
districts_csv = (f.get("districts") or "").strip()
if districts_csv:
selected = {d.strip() for d in districts_csv.split(",") if d.strip()}
flat_district = (flat.get("district") or "").strip()
if not flat_district or flat_district not in selected:
return False
return True return True

View file

@ -7,6 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
import db import db
from auth import current_user, hash_password, require_csrf, require_user 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 common import base_context, client_ip, templates
from matching import row_to_dict from matching import row_to_dict
from notifications import telegram_send from notifications import telegram_send
@ -40,6 +41,13 @@ def tab_settings(request: Request, section: str):
ctx["profile"] = db.get_profile(u["id"]) ctx["profile"] = db.get_profile(u["id"])
elif section == "filter": elif section == "filter":
ctx["filters"] = row_to_dict(db.get_filters(u["id"])) 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": elif section == "benachrichtigungen":
ctx["notifications"] = db.get_notifications(u["id"]) ctx["notifications"] = db.get_notifications(u["id"])
ctx["notif_flash"] = request.query_params.get("flash") or "" ctx["notif_flash"] = request.query_params.get("flash") or ""

View file

@ -27,6 +27,7 @@ from common import (
client_ip, client_ip,
templates, templates,
) )
from berlin_districts import DISTRICTS, district_for_address
from matching import flat_matches_filter, row_to_dict from matching import flat_matches_filter, row_to_dict
@ -64,6 +65,7 @@ def _wohnungen_context(user) -> dict:
if not flat_matches_filter({ if not flat_matches_filter({
"rooms": f["rooms"], "total_rent": f["total_rent"], "size": f["size"], "rooms": f["rooms"], "total_rent": f["total_rent"], "size": f["size"],
"wbs": f["wbs"], "wbs": f["wbs"],
"district": district_for_address(f["address"]),
}, filters): }, filters):
continue continue
flats_view.append({"row": f, "last": latest_apps.get(f["id"])}) 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") @router.post("/actions/filters")
async def action_save_filters( async def action_save_filters(request: Request, user=Depends(require_user)):
request: Request, """The Bezirk filter uses multi-valued `districts` checkboxes, which
csrf: str = Form(...), FastAPI's Form(...) args don't express cleanly read the whole form
rooms_min: str = Form(""), via request.form() so we can getlist() on it."""
rooms_max: str = Form(""), form = await request.form()
max_rent: str = Form(""), require_csrf(user["id"], form.get("csrf", ""))
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): def _f(v):
v = (v or "").strip().replace(",", ".") v = (v or "").strip().replace(",", ".")
@ -233,13 +229,24 @@ async def action_save_filters(
except ValueError: except ValueError:
return None 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"], { db.update_filters(user["id"], {
"rooms_min": _f(rooms_min), "rooms_min": _f(form.get("rooms_min", "")),
"rooms_max": _f(rooms_max), "rooms_max": _f(form.get("rooms_max", "")),
"max_rent": _f(max_rent), "max_rent": _f(form.get("max_rent", "")),
"min_size": _f(min_size), "min_size": _f(form.get("min_size", "")),
"wbs_required": (wbs_required or "").strip(), "wbs_required": (form.get("wbs_required") or "").strip(),
"max_age_hours": _i(max_age_hours), "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)) db.log_audit(user["username"], "filters.updated", user_id=user["id"], ip=client_ip(request))
return RedirectResponse("/", status_code=303) return RedirectResponse("/", status_code=303)

View file

@ -43,6 +43,30 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="col-span-2 md:col-span-3">
<details class="border border-soft rounded p-3"
{% if selected_districts|length != all_districts|length %}open{% endif %}>
<summary class="cursor-pointer font-medium text-sm select-none">
Stadtteile
<span class="text-slate-500 text-xs ml-1">
{% if selected_districts|length == all_districts|length %}alle{% else %}{{ selected_districts|length }} / {{ all_districts|length }}{% endif %}
</span>
</summary>
<div class="grid grid-cols-2 md:grid-cols-3 gap-2 mt-3">
{% for d in all_districts %}
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="districts" value="{{ d }}"
{% if d in selected_districts %}checked{% endif %}>
<span>{{ d }}</span>
</label>
{% endfor %}
</div>
<p class="text-xs text-slate-500 mt-2">
Standard: alle angekreuzt. Wohnungen ohne erkennbare Berliner PLZ werden
ausgeblendet, sobald du mindestens einen Haken entfernst.
</p>
</details>
</div>
<div class="col-span-2 md:col-span-3"> <div class="col-span-2 md:col-span-3">
<button class="btn btn-primary" type="submit">Filter speichern</button> <button class="btn btn-primary" type="submit">Filter speichern</button>
</div> </div>