From fe43a402d89d289019eb0fc32a1f5b3613b83701 Mon Sep 17 00:00:00 2001 From: EiSiMo Date: Thu, 23 Apr 2026 11:27:49 +0200 Subject: [PATCH] feat(wohnungen): "Rausgefilterte Wohnungen" section with reason chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Below "Abgelehnte Wohnungen", surface flats that survived the time filter and aren't rejected but failed at least one of the user's filters. Same collapsed-card style. Action buttons are replaced by chips naming each failed dimension — "Zimmer", "Preis", "Größe", "WBS", "Bezirk" — so it's obvious which constraint to relax. Refactored matching: flat_matches_filter now delegates to a new flat_filter_failures(flat, f) that returns the failed-dimension labels (empty list = full match). rooms_min and rooms_max collapse to a single "Zimmer" chip; reasons emit in stable _REASON_ORDER for consistent rendering. The section is suppressed entirely when the user has no filters set, since "everything matches" makes the chips meaningless. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/matching.py | 44 ++++++++++++++++++++---------- web/routes/wohnungen.py | 15 ++++++++-- web/templates/_wohnungen_body.html | 34 +++++++++++++++++++++++ 3 files changed, 75 insertions(+), 18 deletions(-) diff --git a/web/matching.py b/web/matching.py index 61e0ed0..f2586d0 100644 --- a/web/matching.py +++ b/web/matching.py @@ -10,10 +10,23 @@ from typing import Iterable logger = logging.getLogger("web.matching") -def flat_matches_filter(flat: dict, f: dict | None) -> bool: - """f is a user_filters row converted to dict (or None = no filter set).""" +# German labels per filter dimension. The order also defines the order chips +# appear in the "Rausgefilterte Wohnungen" section. +_REASON_ORDER = ("Zimmer", "Preis", "Größe", "WBS", "Bezirk") + + +def flat_filter_failures(flat: dict, f: dict | None) -> list[str]: + """Return the German labels of filter dimensions the flat fails. Empty list = full match. + + Each label appears at most once (rooms_min and rooms_max both map to "Zimmer"), + in stable _REASON_ORDER so chips render consistently. Berlin-Bezirk filter: + empty string = no filter = all Bezirke match. When active, flats with an + unknown/unmapped PLZ count as a Bezirk failure — if the user bothered to + narrow by district, we shouldn't sneak in flats we couldn't place. + """ if not f: - return True + return [] + failures: set[str] = set() rooms = flat.get("rooms") or 0.0 rent = flat.get("total_rent") or 0.0 @@ -21,34 +34,35 @@ def flat_matches_filter(flat: dict, f: dict | None) -> bool: wbs_str = str(flat.get("wbs", "")).strip().lower() if f.get("rooms_min") is not None and rooms < float(f["rooms_min"]): - return False + failures.add("Zimmer") if f.get("rooms_max") is not None and rooms > float(f["rooms_max"]): - return False + failures.add("Zimmer") if f.get("max_rent") is not None and rent > float(f["max_rent"]): - return False + failures.add("Preis") if f.get("min_size") is not None and size < float(f["min_size"]): - return False + failures.add("Größe") wbs_req = (f.get("wbs_required") or "").strip().lower() if wbs_req == "yes": if not wbs_str or wbs_str in ("kein", "nein", "no", "ohne", "-"): - return False + failures.add("WBS") elif wbs_req == "no": if wbs_str and wbs_str not in ("kein", "nein", "no", "ohne", "-"): - return False + failures.add("WBS") - # 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 + failures.add("Bezirk") - return True + return [r for r in _REASON_ORDER if r in failures] + + +def flat_matches_filter(flat: dict, f: dict | None) -> bool: + """f is a user_filters row converted to dict (or None = no filter set).""" + return not flat_filter_failures(flat, f) def row_to_dict(row) -> dict: diff --git a/web/routes/wohnungen.py b/web/routes/wohnungen.py index 07de6ae..999601c 100644 --- a/web/routes/wohnungen.py +++ b/web/routes/wohnungen.py @@ -28,7 +28,7 @@ from common import ( templates, ) from berlin_districts import DISTRICTS, district_for_address -from matching import flat_matches_filter, row_to_dict +from matching import flat_filter_failures, row_to_dict router = APIRouter() @@ -50,7 +50,9 @@ def _wohnungen_context(user) -> dict: # One query for this user's latest application per flat, instead of a # per-flat query inside the loop. latest_apps = db.latest_applications_by_flat(uid) + has_filters_set = _has_filters(filters_row) flats_view = [] + filtered_out_view = [] for f in flats: if f["id"] in rejected: continue @@ -62,11 +64,17 @@ def _wohnungen_context(user) -> dict: disc = disc.replace(tzinfo=timezone.utc) if disc < age_cutoff: continue - if not flat_matches_filter({ + failures = flat_filter_failures({ "rooms": f["rooms"], "total_rent": f["total_rent"], "size": f["size"], "wbs": f["wbs"], "district": district_for_address(f["address"]), - }, filters): + }, filters) + if failures: + # Only surface a "Rausgefiltert" entry if the user actually has + # filters set — otherwise nothing fails and the section is empty + # anyway, but skip the work to keep the loop tight. + if has_filters_set: + filtered_out_view.append({"row": f, "reasons": failures}) continue flats_view.append({"row": f, "last": latest_apps.get(f["id"])}) @@ -124,6 +132,7 @@ def _wohnungen_context(user) -> dict: return { "flats": flats_view, "rejected_flats": rejected_view, + "filtered_out_flats": filtered_out_view, "enrichment_counts": enrichment_counts, "partner": partner_info, "map_points": map_points, diff --git a/web/templates/_wohnungen_body.html b/web/templates/_wohnungen_body.html index 6672a6e..72faa89 100644 --- a/web/templates/_wohnungen_body.html +++ b/web/templates/_wohnungen_body.html @@ -239,4 +239,38 @@ {% endif %} +{% if filtered_out_flats %} +
+
+ + Rausgefilterte Wohnungen + {{ filtered_out_flats|length }} + +
+ {% for item in filtered_out_flats %} + {% set f = item.row %} +
+
+ + {{ f.address or f.link }} + +
+ {% if f.rooms %}{{ "%.1f"|format(f.rooms) }} Z{% endif %} + {% if f.size %} · {{ "%.0f"|format(f.size) }} m²{% endif %} + {% if f.total_rent %} · {{ "%.0f"|format(f.total_rent) }} €{% endif %} + · gefunden +
+
+
+ {% for reason in item.reasons %} + {{ reason }} + {% endfor %} +
+
+ {% endfor %} +
+
+
+{% endif %} +