feat(wohnungen): "Rausgefilterte Wohnungen" section with reason chips
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) <noreply@anthropic.com>
This commit is contained in:
parent
b5b4908ee7
commit
fe43a402d8
3 changed files with 75 additions and 18 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -239,4 +239,38 @@
|
|||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if filtered_out_flats %}
|
||||
<section class="card">
|
||||
<details class="group">
|
||||
<summary class="px-4 py-3 text-sm font-medium flex items-center justify-between cursor-pointer">
|
||||
<span>Rausgefilterte Wohnungen</span>
|
||||
<span class="text-xs text-slate-500">{{ filtered_out_flats|length }}</span>
|
||||
</summary>
|
||||
<div class="divide-y divide-soft border-t border-soft">
|
||||
{% for item in filtered_out_flats %}
|
||||
{% set f = item.row %}
|
||||
<div class="px-4 py-3 flex flex-col md:flex-row md:items-center gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<a class="font-medium truncate" href="{{ f.link }}" target="_blank" rel="noopener noreferrer">
|
||||
{{ f.address or f.link }}
|
||||
</a>
|
||||
<div class="text-xs text-slate-500 mt-0.5">
|
||||
{% 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 <span data-rel-utc="{{ f.discovered_at|iso_utc }}" title="{{ f.discovered_at|de_dt }}">…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1.5 flex-wrap">
|
||||
{% for reason in item.reasons %}
|
||||
<span class="chip chip-warn">{{ reason }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue