UX: alarm-status, ablehnen-button, annika-footer, map polish

* Alarm-Status ist jetzt nur 'aktiv' wenn ein echter Push-Channel (Telegram
  mit Token+Chat oder E-Mail mit Adresse) konfiguriert ist. UI-only zählt
  nicht mehr als eingerichteter Alarm.
* Ablehnen-Button in der Wohnungsliste: flat_rejections (migration v4)
  speichert pro-User-Ablehnungen, abgelehnte Flats fallen aus Liste und
  Karte raus. Wiederholbar pro User unabhängig.
* Footer 'Programmiert für Annika ♥' erscheint nur auf Seiten, wenn annika
  angemeldet ist.
* Map: Hinweistext unter leerer Karte entfernt; alle Zoom-Mechanismen
  deaktiviert (Scrollrad, Doppelklick, Box, Touch, Tastatur, +/- Buttons).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Moritz 2026-04-21 12:09:44 +02:00
parent 376551213a
commit 42377f0b67
5 changed files with 97 additions and 18 deletions

View file

@ -217,16 +217,25 @@ def _has_filters(f) -> bool:
return False
def _alert_status(filters_row, notifications_row) -> tuple[str, str]:
"""Return (label, chip_kind) describing the user's alert setup."""
if not _has_filters(filters_row):
def _alert_status(notifications_row) -> tuple[str, str]:
"""Return (label, chip_kind) for the user's alarm (notification) setup.
'aktiv' only if a real push channel (telegram/email) is configured with
credentials. 'ui' is not a real alarm the dashboard already shows
matches when you happen to be looking.
"""
if not notifications_row:
return "nicht eingerichtet", "warn"
ch = (notifications_row["channel"] if notifications_row else "ui") or "ui"
if ch == "telegram" and not (notifications_row["telegram_bot_token"] and notifications_row["telegram_chat_id"]):
return "Benachrichtigung fehlt", "warn"
if ch == "email" and not notifications_row["email_address"]:
return "Benachrichtigung fehlt", "warn"
return "aktiv", "ok"
ch = (notifications_row["channel"] or "ui").strip()
if ch == "telegram":
if notifications_row["telegram_bot_token"] and notifications_row["telegram_chat_id"]:
return "aktiv (Telegram)", "ok"
return "unvollständig", "warn"
if ch == "email":
if notifications_row["email_address"]:
return "aktiv (E-Mail)", "ok"
return "unvollständig", "warn"
return "nicht eingerichtet", "warn"
def _filter_summary(f) -> str:
@ -382,8 +391,11 @@ def _wohnungen_context(user) -> dict:
filters = row_to_dict(filters_row)
flats = db.recent_flats(100)
rejected = db.rejected_flat_ids(uid)
flats_view = []
for f in flats:
if f["id"] in rejected:
continue
if not flat_matches_filter({
"rooms": f["rooms"], "total_rent": f["total_rent"], "size": f["size"],
"wbs": f["wbs"], "connectivity": {"morning_time": f["connectivity_morning_time"]},
@ -393,7 +405,7 @@ def _wohnungen_context(user) -> dict:
flats_view.append({"row": f, "last": last})
allowed, reason = _manual_apply_allowed()
alert_label, alert_chip = _alert_status(filters_row, notif_row)
alert_label, alert_chip = _alert_status(notif_row)
has_running = _has_running_application(uid)
map_points = []
for item in flats_view:
@ -411,6 +423,7 @@ def _wohnungen_context(user) -> dict:
return {
"flats": flats_view,
"map_points": map_points,
"has_filters": _has_filters(filters_row),
"alert_label": alert_label,
"alert_chip": alert_chip,
"filter_summary": _filter_summary(filters_row),
@ -521,6 +534,20 @@ async def action_apply(
return _wohnungen_partial_or_redirect(request, user)
@app.post("/actions/reject")
async def action_reject(
request: Request,
flat_id: str = Form(...),
csrf: str = Form(...),
user=Depends(require_user),
):
require_csrf(user["id"], csrf)
db.reject_flat(user["id"], flat_id)
db.log_audit(user["username"], "flat.rejected", f"flat_id={flat_id}",
user_id=user["id"], ip=client_ip(request))
return _wohnungen_partial_or_redirect(request, user)
# ---------------------------------------------------------------------------
# Tab: Bewerbungen
# ---------------------------------------------------------------------------

View file

@ -185,6 +185,16 @@ MIGRATIONS: list[str] = [
ALTER TABLE flats ADD COLUMN lat REAL;
ALTER TABLE flats ADD COLUMN lng REAL;
""",
# 0004: per-user rejections — flats the user doesn't want in the list anymore
"""
CREATE TABLE IF NOT EXISTS flat_rejections (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
flat_id TEXT NOT NULL REFERENCES flats(id),
rejected_at TEXT NOT NULL,
PRIMARY KEY (user_id, flat_id)
);
CREATE INDEX IF NOT EXISTS idx_rejections_user ON flat_rejections(user_id);
""",
]
@ -471,6 +481,33 @@ def recent_applications(user_id: Optional[int], limit: int = 50) -> list[sqlite3
).fetchall())
# ---------------------------------------------------------------------------
# Rejections (flats a user doesn't want to see anymore)
# ---------------------------------------------------------------------------
def reject_flat(user_id: int, flat_id: str) -> None:
with _lock:
_conn.execute(
"INSERT OR IGNORE INTO flat_rejections(user_id, flat_id, rejected_at) VALUES (?, ?, ?)",
(user_id, flat_id, now_iso()),
)
def unreject_flat(user_id: int, flat_id: str) -> None:
with _lock:
_conn.execute(
"DELETE FROM flat_rejections WHERE user_id = ? AND flat_id = ?",
(user_id, flat_id),
)
def rejected_flat_ids(user_id: int) -> set[str]:
rows = _conn.execute(
"SELECT flat_id FROM flat_rejections WHERE user_id = ?", (user_id,)
).fetchall()
return {row["flat_id"] for row in rows}
def last_application_for_flat(user_id: int, flat_id: str) -> Optional[sqlite3.Row]:
return _conn.execute(
"""SELECT * FROM applications

View file

@ -14,7 +14,14 @@ function initFlatsMap() {
try { mapInstance.remove(); } catch (e) {}
mapInstance = null;
}
mapInstance = L.map(el).setView(BERLIN_CENTER, BERLIN_ZOOM);
mapInstance = L.map(el, {
zoomControl: false,
scrollWheelZoom: false,
doubleClickZoom: false,
boxZoom: false,
touchZoom: false,
keyboard: false,
}).setView(BERLIN_CENTER, BERLIN_ZOOM);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© OpenStreetMap",
maxZoom: 18,

View file

@ -29,4 +29,9 @@
<main class="max-w-6xl mx-auto px-6 py-6 space-y-6">
{% block content %}{% endblock %}
</main>
{% if user.username == 'annika' %}
<footer class="text-center text-xs text-slate-500 py-6">
Programmiert für Annika ♥
</footer>
{% endif %}
{% endblock %}

View file

@ -100,12 +100,6 @@
<section class="view-map">
<div class="card p-3">
<div id="flats-map" data-flats='{{ map_points | tojson }}'></div>
{% if not map_points %}
<p class="mt-3 text-xs text-slate-500">
Keine Koordinaten für passende Wohnungen vorhanden —
entweder sind noch keine neuen Flats geocoded worden oder die Filter lassen noch nichts durch.
</p>
{% endif %}
</div>
</section>
@ -153,11 +147,20 @@
</button>
</form>
{% endif %}
<form method="post" action="/actions/reject"
hx-post="/actions/reject" hx-target="#wohnungen-body" hx-swap="outerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="flat_id" value="{{ f.id }}">
<button class="btn btn-ghost text-sm" type="submit"
hx-confirm="Ablehnen und aus der Liste entfernen?">
Ablehnen
</button>
</form>
</div>
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">
{% if alert_label == 'nicht eingerichtet' %}
{% if not has_filters %}
Bitte zuerst Filter einstellen, damit passende Wohnungen angezeigt werden.
{% else %}
Aktuell keine Wohnung, die alle Filter erfüllt.