diff --git a/web/app.py b/web/app.py index 7ce4dd9..8430a43 100644 --- a/web/app.py +++ b/web/app.py @@ -1,11 +1,11 @@ """ lazyflat web app. -Four tabs: -- / → Wohnungen (all flats, per-user match highlighting, filter block, auto-apply switch) -- /bewerbungen → Bewerbungen (history + forensics; failed apps expose a ZIP report download) -- /logs → Logs (user-scoped audit log) -- /einstellungen/
→ Einstellungen: profile, filter, notifications, account, admin users +Tabs: +- / → Wohnungen +- /bewerbungen → Bewerbungen (history + forensics ZIP for failed runs) +- /einstellungen/
→ Einstellungen: profil | filter | benachrichtigungen | account +- /admin/
→ Admin-only: protokoll | benutzer All state-changing POSTs require CSRF. Internal endpoints require INTERNAL_API_KEY. """ @@ -209,7 +209,18 @@ def _next_scrape_utc() -> str: return (dt + timedelta(seconds=ALERT_SCRAPE_INTERVAL_SECONDS)).astimezone(timezone.utc).isoformat(timespec="seconds") -FILTER_KEYS = ("rooms_min", "rooms_max", "max_rent", "min_size", "max_morning_commute", "wbs_required") +def _last_scrape_utc() -> str: + hb = db.get_state("last_alert_heartbeat") + dt = _parse_iso(hb) + if dt is None: + return "" + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).isoformat(timespec="seconds") + + +FILTER_KEYS = ("rooms_min", "rooms_max", "max_rent", "min_size", + "max_morning_commute", "wbs_required", "max_age_hours") def _has_filters(f) -> bool: @@ -258,6 +269,8 @@ def _filter_summary(f) -> str: parts.append("WBS") elif f["wbs_required"] == "no": parts.append("ohne WBS") + if f["max_age_hours"]: + parts.append(f"≤ {int(f['max_age_hours'])} h alt") return " · ".join(parts) @@ -393,10 +406,22 @@ def _wohnungen_context(user) -> dict: flats = db.recent_flats(100) rejected = db.rejected_flat_ids(uid) + max_age_hours = filters_row["max_age_hours"] if filters_row else None + age_cutoff = None + if max_age_hours: + age_cutoff = datetime.now(timezone.utc) - timedelta(hours=int(max_age_hours)) flats_view = [] for f in flats: if f["id"] in rejected: continue + if age_cutoff is not None: + disc = _parse_iso(f["discovered_at"]) + if disc is None: + continue + if disc.tzinfo is None: + disc = disc.replace(tzinfo=timezone.utc) + if disc < age_cutoff: + 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"]}, @@ -456,6 +481,7 @@ def _wohnungen_context(user) -> dict: "apply_block_reason": reason, "apply_reachable": apply_client.health(), "next_scrape_utc": _next_scrape_utc(), + "last_scrape_utc": _last_scrape_utc(), "has_running_apply": has_running, "poll_interval": 3 if has_running else 30, } @@ -528,6 +554,7 @@ async def action_save_filters( min_size: str = Form(""), max_morning_commute: str = Form(""), wbs_required: str = Form(""), + max_age_hours: str = Form(""), user=Depends(require_user), ): require_csrf(user["id"], csrf) @@ -536,6 +563,13 @@ async def action_save_filters( v = (v or "").strip().replace(",", ".") return float(v) if v else None + def _i(v): + v = (v or "").strip() + try: + return int(v) if v else None + except ValueError: + return None + db.update_filters(user["id"], { "rooms_min": _f(rooms_min), "rooms_max": _f(rooms_max), @@ -543,6 +577,7 @@ async def action_save_filters( "min_size": _f(min_size), "max_morning_commute": _f(max_morning_commute), "wbs_required": (wbs_required or "").strip(), + "max_age_hours": _i(max_age_hours), }) db.log_audit(user["username"], "filters.updated", user_id=user["id"], ip=client_ip(request)) return RedirectResponse("/", status_code=303) @@ -784,23 +819,45 @@ def _collect_events(start_iso: str | None, end_iso: str | None) -> list[dict]: return events -@app.get("/logs", response_class=HTMLResponse) -def tab_logs(request: Request): +@app.get("/logs") +def tab_logs_legacy(): + # Old top-level Protokoll tab was merged into /admin/protokoll. + return RedirectResponse("/admin/protokoll", status_code=301) + + +ADMIN_SECTIONS = ("protokoll", "benutzer") + + +@app.get("/admin", response_class=HTMLResponse) +def tab_admin_root(request: Request): + return RedirectResponse("/admin/protokoll", status_code=303) + + +@app.get("/admin/{section}", response_class=HTMLResponse) +def tab_admin(request: Request, section: str): u = current_user(request) if not u: return RedirectResponse("/login", status_code=303) if not u["is_admin"]: raise HTTPException(403, "admin only") + if section not in ADMIN_SECTIONS: + raise HTTPException(404) - q = request.query_params - from_str = q.get("from") or "" - to_str = q.get("to") or "" - start_iso, end_iso = _parse_date_range(from_str or None, to_str or None) - events = _collect_events(start_iso, end_iso)[:500] + ctx = base_context(request, u, "admin") + ctx["section"] = section - ctx = base_context(request, u, "logs") - ctx.update({"events": events, "from_str": from_str, "to_str": to_str}) - return templates.TemplateResponse("logs.html", ctx) + if section == "protokoll": + q = request.query_params + from_str = q.get("from") or "" + to_str = q.get("to") or "" + start_iso, end_iso = _parse_date_range(from_str or None, to_str or None) + ctx.update({ + "events": _collect_events(start_iso, end_iso)[:500], + "from_str": from_str, "to_str": to_str, + }) + elif section == "benutzer": + ctx["users"] = db.list_users() + return templates.TemplateResponse("admin.html", ctx) @app.get("/logs/export.csv") @@ -846,7 +903,7 @@ def tab_logs_export(request: Request): # Tab: Einstellungen (sub-tabs) # --------------------------------------------------------------------------- -VALID_SECTIONS = ("profil", "filter", "benachrichtigungen", "account", "benutzer") +VALID_SECTIONS = ("profil", "filter", "benachrichtigungen", "account") @app.get("/einstellungen", response_class=HTMLResponse) @@ -859,10 +916,11 @@ def tab_settings(request: Request, section: str): u = current_user(request) if not u: return RedirectResponse("/login", status_code=303) + # Benutzer verwaltung lives under /admin/benutzer since the admin tab rework. + if section == "benutzer": + return RedirectResponse("/admin/benutzer", status_code=301) if section not in VALID_SECTIONS: raise HTTPException(404) - if section == "benutzer" and not u["is_admin"]: - raise HTTPException(403) ctx = base_context(request, u, "einstellungen") ctx["section"] = section @@ -873,10 +931,6 @@ def tab_settings(request: Request, section: str): ctx["filters"] = row_to_dict(db.get_filters(u["id"])) elif section == "benachrichtigungen": ctx["notifications"] = db.get_notifications(u["id"]) - elif section == "account": - pass - elif section == "benutzer": - ctx["users"] = db.list_users() return templates.TemplateResponse("einstellungen.html", ctx) @@ -995,10 +1049,10 @@ async def action_users_create( uid = db.create_user(username, hash_password(password), is_admin=(is_admin.lower() in ("on", "true", "yes", "1"))) except sqlite3.IntegrityError: - return RedirectResponse("/einstellungen/benutzer?err=exists", status_code=303) + return RedirectResponse("/admin/benutzer?err=exists", status_code=303) db.log_audit(admin["username"], "user.created", f"new_user={username} id={uid}", user_id=admin["id"], ip=client_ip(request)) - return RedirectResponse("/einstellungen/benutzer?ok=1", status_code=303) + return RedirectResponse("/admin/benutzer?ok=1", status_code=303) @app.post("/actions/users/disable") @@ -1016,7 +1070,7 @@ async def action_users_disable( db.log_audit(admin["username"], "user.toggle_disable", f"target={target_id} disabled={value=='on'}", user_id=admin["id"], ip=client_ip(request)) - return RedirectResponse("/einstellungen/benutzer", status_code=303) + return RedirectResponse("/admin/benutzer", status_code=303) @app.post("/actions/enrich-all") @@ -1059,12 +1113,12 @@ async def action_users_delete( raise HTTPException(400, "refusing to delete self") target = db.get_user(target_id) if not target: - return RedirectResponse("/einstellungen/benutzer", status_code=303) + return RedirectResponse("/admin/benutzer", status_code=303) db.delete_user(target_id) db.log_audit(admin["username"], "user.deleted", f"target={target_id} username={target['username']}", user_id=admin["id"], ip=client_ip(request)) - return RedirectResponse("/einstellungen/benutzer?deleted=1", status_code=303) + return RedirectResponse("/admin/benutzer?deleted=1", status_code=303) # --------------------------------------------------------------------------- diff --git a/web/db.py b/web/db.py index 7e4471a..0f9d9a8 100644 --- a/web/db.py +++ b/web/db.py @@ -202,6 +202,10 @@ MIGRATIONS: list[str] = [ ALTER TABLE flats ADD COLUMN enrichment_updated_at TEXT; ALTER TABLE flats ADD COLUMN image_count INTEGER NOT NULL DEFAULT 0; """, + # 0006: time filter — hide flats older than X hours (NULL = no limit) + """ + ALTER TABLE user_filters ADD COLUMN max_age_hours INTEGER; + """, ] @@ -353,7 +357,8 @@ def get_filters(user_id: int) -> sqlite3.Row: def update_filters(user_id: int, data: dict) -> None: _ensure_user_rows(user_id) - allowed = {"rooms_min", "rooms_max", "max_rent", "min_size", "max_morning_commute", "wbs_required"} + allowed = {"rooms_min", "rooms_max", "max_rent", "min_size", + "max_morning_commute", "wbs_required", "max_age_hours"} clean = {k: data.get(k) for k in allowed if k in data} if not clean: return diff --git a/web/static/app.js b/web/static/app.js index 2c7662d..d8c4762 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -1,7 +1,8 @@ // lazyflat — live time helpers. -// Any element with [data-rel-utc=""] gets its text replaced every 5s -// with a German relative-time string ("vor 3 min"). Elements with -// [data-countdown-utc=""] show "in Xs" counting down each second. +// [data-rel-utc=""] → "vor 3 min" (relative-past, updated every 5 s) +// [data-counter-up-utc=""] → "vor X s" counting up each second (used for +// "aktualisiert vor X s"; resets when the +// server ships a newer timestamp in the swap) function fmtRelative(iso) { const ts = Date.parse(iso); @@ -14,14 +15,13 @@ function fmtRelative(iso) { return `vor ${Math.floor(diff / 86400)} Tagen`; } -function fmtCountdown(iso) { +function fmtCountUp(iso) { const ts = Date.parse(iso); if (!iso || Number.isNaN(ts)) return "—"; - const secs = Math.floor((ts - Date.now()) / 1000); - if (secs <= 0) return "aktualisiere…"; - if (secs < 60) return `in ${secs} s`; - if (secs < 3600) return `in ${Math.floor(secs / 60)} min`; - return `in ${Math.floor(secs / 3600)} h`; + const diff = Math.max(0, Math.floor((Date.now() - ts) / 1000)); + if (diff < 60) return `vor ${diff} s`; + if (diff < 3600) return `vor ${Math.floor(diff / 60)} min`; + return `vor ${Math.floor(diff / 3600)} h`; } function updateRelativeTimes() { @@ -30,22 +30,38 @@ function updateRelativeTimes() { }); } -function updateCountdowns() { - document.querySelectorAll("[data-countdown-utc]").forEach((el) => { - el.textContent = fmtCountdown(el.dataset.countdownUtc); +function updateCounterUps() { + document.querySelectorAll("[data-counter-up-utc]").forEach((el) => { + el.textContent = fmtCountUp(el.dataset.counterUpUtc); + }); +} + +// After the HTMX swap rebuilds the list, the open-chevron class is gone even +// though the corresponding .flat-detail pane is preserved. Re-sync by looking +// at pane visibility. +function syncFlatExpandState() { + document.querySelectorAll(".flat-detail").forEach((pane) => { + const row = pane.closest(".flat-row"); + if (!row) return; + const btn = row.querySelector(".flat-expand-btn"); + if (!btn) return; + const open = pane.dataset.loaded === "1" && pane.style.display !== "none"; + btn.classList.toggle("open", open); }); } function tick() { updateRelativeTimes(); - updateCountdowns(); + updateCounterUps(); } -// Run immediately + on intervals. Also re-run after HTMX swaps so freshly -// injected DOM gets formatted too. document.addEventListener("DOMContentLoaded", tick); -document.body && document.body.addEventListener("htmx:afterSwap", tick); -setInterval(updateCountdowns, 1000); +document.addEventListener("DOMContentLoaded", syncFlatExpandState); +if (document.body) { + document.body.addEventListener("htmx:afterSwap", tick); + document.body.addEventListener("htmx:afterSwap", syncFlatExpandState); +} +setInterval(updateCounterUps, 1000); setInterval(updateRelativeTimes, 5000); // Flat detail expand — lazily fetches /partials/wohnung/ into the sibling diff --git a/web/templates/logs.html b/web/templates/_admin_logs.html similarity index 60% rename from web/templates/logs.html rename to web/templates/_admin_logs.html index aabd44b..b6924b1 100644 --- a/web/templates/logs.html +++ b/web/templates/_admin_logs.html @@ -1,25 +1,21 @@ -{% extends "_layout.html" %} -{% block content %} -
-
-
- - -
-
- - -
- - zurücksetzen - - CSV herunterladen - -
-
+
+
+ + +
+
+ + +
+ + zurücksetzen + + CSV herunterladen + +
-
+

System-Protokoll

@@ -50,5 +46,4 @@
Keine Einträge im gewählten Zeitraum.
{% endfor %}
-
-{% endblock %} + diff --git a/web/templates/_layout.html b/web/templates/_layout.html index 34f1f21..ffdb4be 100644 --- a/web/templates/_layout.html +++ b/web/templates/_layout.html @@ -20,10 +20,10 @@
diff --git a/web/templates/_settings_filter.html b/web/templates/_settings_filter.html index 195390b..a21604e 100644 --- a/web/templates/_settings_filter.html +++ b/web/templates/_settings_filter.html @@ -38,6 +38,15 @@ +
+ + +
diff --git a/web/templates/_settings_profil.html b/web/templates/_settings_profil.html index 03486a8..235fd4c 100644 --- a/web/templates/_settings_profil.html +++ b/web/templates/_settings_profil.html @@ -118,12 +118,3 @@ - -
-

Final absenden

-

- experimentell - Solange „Final absenden" aus ist, füllt die Automation das Formular aus, klickt aber - nicht auf „Senden". Erst einschalten, wenn du jeden Anbieter einmal durchgetestet hast. - Der Schalter liegt auch oben auf der Wohnungen-Seite. -

diff --git a/web/templates/_wohnungen_body.html b/web/templates/_wohnungen_body.html index cc14ba6..131cba1 100644 --- a/web/templates/_wohnungen_body.html +++ b/web/templates/_wohnungen_body.html @@ -80,9 +80,9 @@

Passende Wohnungen auf inberlinwohnen.de

{{ flats|length }} gefunden - {% if next_scrape_utc %} + {% if last_scrape_utc %} · - nächste Aktualisierung + aktualisiert {% endif %} {% if is_admin and (enrichment_counts.pending or enrichment_counts.failed) %} · diff --git a/web/templates/admin.html b/web/templates/admin.html new file mode 100644 index 0000000..1cc4c2b --- /dev/null +++ b/web/templates/admin.html @@ -0,0 +1,22 @@ +{# + Admin-only landing page. Merges the former /logs tab and the former + Benutzer sub-section of Einstellungen into one place, with sub-tabs. +#} +{% extends "_layout.html" %} +{% block content %} +
+ + +
+ {% if section == 'protokoll' %}{% include "_admin_logs.html" %} + {% elif section == 'benutzer' %}{% include "_settings_users.html" %} + {% endif %} +
+
+{% endblock %} diff --git a/web/templates/einstellungen.html b/web/templates/einstellungen.html index 6c323bc..6a71e15 100644 --- a/web/templates/einstellungen.html +++ b/web/templates/einstellungen.html @@ -3,7 +3,6 @@