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:
parent
abd614604f
commit
d13f9c5b6e
7 changed files with 202 additions and 20 deletions
120
web/berlin_districts.py
Normal file
120
web/berlin_districts.py
Normal 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))
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue