diff --git a/web/berlin_districts.py b/web/berlin_districts.py new file mode 100644 index 0000000..c854e28 --- /dev/null +++ b/web/berlin_districts.py @@ -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)) diff --git a/web/common.py b/web/common.py index e07498e..d8a89f3 100644 --- a/web/common.py +++ b/web/common.py @@ -126,7 +126,7 @@ def _is_htmx(request: Request) -> bool: # --------------------------------------------------------------------------- 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: @@ -175,6 +175,13 @@ def _filter_summary(f) -> str: parts.append("ohne WBS") if f["max_age_hours"]: 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) diff --git a/web/db.py b/web/db.py index 0d0360e..ad30da7 100644 --- a/web/db.py +++ b/web/db.py @@ -270,6 +270,11 @@ MIGRATIONS: list[str] = [ ALTER TABLE flats ADD COLUMN offline_at TEXT; 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: _ensure_user_rows(user_id) 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} if not clean: return diff --git a/web/matching.py b/web/matching.py index f468277..61e0ed0 100644 --- a/web/matching.py +++ b/web/matching.py @@ -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", "-"): 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 diff --git a/web/routes/einstellungen.py b/web/routes/einstellungen.py index d87d5e7..4b031a4 100644 --- a/web/routes/einstellungen.py +++ b/web/routes/einstellungen.py @@ -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 "" diff --git a/web/routes/wohnungen.py b/web/routes/wohnungen.py index 1820edb..07de6ae 100644 --- a/web/routes/wohnungen.py +++ b/web/routes/wohnungen.py @@ -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) diff --git a/web/templates/_settings_filter.html b/web/templates/_settings_filter.html index a78726f..64da7c1 100644 --- a/web/templates/_settings_filter.html +++ b/web/templates/_settings_filter.html @@ -43,6 +43,30 @@ {% endfor %} +
+ Standard: alle angekreuzt. Wohnungen ohne erkennbare Berliner PLZ werden + ausgeblendet, sobald du mindestens einen Haken entfernst. +
+