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:
parent
376551213a
commit
42377f0b67
5 changed files with 97 additions and 18 deletions
47
web/app.py
47
web/app.py
|
|
@ -217,16 +217,25 @@ def _has_filters(f) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _alert_status(filters_row, notifications_row) -> tuple[str, str]:
|
def _alert_status(notifications_row) -> tuple[str, str]:
|
||||||
"""Return (label, chip_kind) describing the user's alert setup."""
|
"""Return (label, chip_kind) for the user's alarm (notification) setup.
|
||||||
if not _has_filters(filters_row):
|
|
||||||
|
'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"] 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"
|
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"
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_summary(f) -> str:
|
def _filter_summary(f) -> str:
|
||||||
|
|
@ -382,8 +391,11 @@ def _wohnungen_context(user) -> dict:
|
||||||
filters = row_to_dict(filters_row)
|
filters = row_to_dict(filters_row)
|
||||||
flats = db.recent_flats(100)
|
flats = db.recent_flats(100)
|
||||||
|
|
||||||
|
rejected = db.rejected_flat_ids(uid)
|
||||||
flats_view = []
|
flats_view = []
|
||||||
for f in flats:
|
for f in flats:
|
||||||
|
if f["id"] in rejected:
|
||||||
|
continue
|
||||||
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"], "connectivity": {"morning_time": f["connectivity_morning_time"]},
|
"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})
|
flats_view.append({"row": f, "last": last})
|
||||||
|
|
||||||
allowed, reason = _manual_apply_allowed()
|
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)
|
has_running = _has_running_application(uid)
|
||||||
map_points = []
|
map_points = []
|
||||||
for item in flats_view:
|
for item in flats_view:
|
||||||
|
|
@ -411,6 +423,7 @@ def _wohnungen_context(user) -> dict:
|
||||||
return {
|
return {
|
||||||
"flats": flats_view,
|
"flats": flats_view,
|
||||||
"map_points": map_points,
|
"map_points": map_points,
|
||||||
|
"has_filters": _has_filters(filters_row),
|
||||||
"alert_label": alert_label,
|
"alert_label": alert_label,
|
||||||
"alert_chip": alert_chip,
|
"alert_chip": alert_chip,
|
||||||
"filter_summary": _filter_summary(filters_row),
|
"filter_summary": _filter_summary(filters_row),
|
||||||
|
|
@ -521,6 +534,20 @@ async def action_apply(
|
||||||
return _wohnungen_partial_or_redirect(request, user)
|
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
|
# Tab: Bewerbungen
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
37
web/db.py
37
web/db.py
|
|
@ -185,6 +185,16 @@ MIGRATIONS: list[str] = [
|
||||||
ALTER TABLE flats ADD COLUMN lat REAL;
|
ALTER TABLE flats ADD COLUMN lat REAL;
|
||||||
ALTER TABLE flats ADD COLUMN lng 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())
|
).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]:
|
def last_application_for_flat(user_id: int, flat_id: str) -> Optional[sqlite3.Row]:
|
||||||
return _conn.execute(
|
return _conn.execute(
|
||||||
"""SELECT * FROM applications
|
"""SELECT * FROM applications
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,14 @@ function initFlatsMap() {
|
||||||
try { mapInstance.remove(); } catch (e) {}
|
try { mapInstance.remove(); } catch (e) {}
|
||||||
mapInstance = null;
|
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", {
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
attribution: "© OpenStreetMap",
|
attribution: "© OpenStreetMap",
|
||||||
maxZoom: 18,
|
maxZoom: 18,
|
||||||
|
|
|
||||||
|
|
@ -29,4 +29,9 @@
|
||||||
<main class="max-w-6xl mx-auto px-6 py-6 space-y-6">
|
<main class="max-w-6xl mx-auto px-6 py-6 space-y-6">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
{% if user.username == 'annika' %}
|
||||||
|
<footer class="text-center text-xs text-slate-500 py-6">
|
||||||
|
Programmiert für Annika ♥
|
||||||
|
</footer>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -100,12 +100,6 @@
|
||||||
<section class="view-map">
|
<section class="view-map">
|
||||||
<div class="card p-3">
|
<div class="card p-3">
|
||||||
<div id="flats-map" data-flats='{{ map_points | tojson }}'></div>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -153,11 +147,20 @@
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="px-4 py-8 text-center text-slate-500">
|
<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.
|
Bitte zuerst Filter einstellen, damit passende Wohnungen angezeigt werden.
|
||||||
{% else %}
|
{% else %}
|
||||||
Aktuell keine Wohnung, die alle Filter erfüllt.
|
Aktuell keine Wohnung, die alle Filter erfüllt.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue