ui batch: admin tab, time filter, count-up, chevron sync, tidy
1. New /admin route with sub-tabs (Protokoll, Benutzer) for admins. Top nav: "Protokoll" dropped, "Admin" added right of Einstellungen. /logs and /einstellungen/benutzer issue 301 redirects to the new paths. Benutzer is no longer part of Einstellungen sub-nav. 2. User_filters.max_age_hours (migration v6) — new dropdown (1–10 h / beliebig) under Einstellungen → Filter; Wohnungen list drops flats older than the cutoff by discovered_at. 3. Header shows "aktualisiert vor X s" instead of a countdown. Template emits data-counter-up-utc with last_alert_heartbeat; app.js ticks up each second. When a scrape runs, the heartbeat updates and the HTMX swap resets the counter naturally. 4. Chevron state synced after HTMX swaps: panes preserved via hx-preserve keep the user's open/closed state, and the sibling button's .open class is re-applied by syncFlatExpandState() on afterSwap — previously a scroll-triggered poll would flip the chevron back to closed while the pane stayed open. 5. "Final absenden" footer removed from the profile page (functionality is unchanged, the switch still sits atop Wohnungen). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
83db8cd902
commit
da180bd7c7
10 changed files with 175 additions and 85 deletions
104
web/app.py
104
web/app.py
|
|
@ -1,11 +1,11 @@
|
||||||
"""
|
"""
|
||||||
lazyflat web app.
|
lazyflat web app.
|
||||||
|
|
||||||
Four tabs:
|
Tabs:
|
||||||
- / → Wohnungen (all flats, per-user match highlighting, filter block, auto-apply switch)
|
- / → Wohnungen
|
||||||
- /bewerbungen → Bewerbungen (history + forensics; failed apps expose a ZIP report download)
|
- /bewerbungen → Bewerbungen (history + forensics ZIP for failed runs)
|
||||||
- /logs → Logs (user-scoped audit log)
|
- /einstellungen/<section> → Einstellungen: profil | filter | benachrichtigungen | account
|
||||||
- /einstellungen/<section> → Einstellungen: profile, filter, notifications, account, admin users
|
- /admin/<section> → Admin-only: protokoll | benutzer
|
||||||
|
|
||||||
All state-changing POSTs require CSRF. Internal endpoints require INTERNAL_API_KEY.
|
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")
|
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:
|
def _has_filters(f) -> bool:
|
||||||
|
|
@ -258,6 +269,8 @@ def _filter_summary(f) -> str:
|
||||||
parts.append("WBS")
|
parts.append("WBS")
|
||||||
elif f["wbs_required"] == "no":
|
elif f["wbs_required"] == "no":
|
||||||
parts.append("ohne WBS")
|
parts.append("ohne WBS")
|
||||||
|
if f["max_age_hours"]:
|
||||||
|
parts.append(f"≤ {int(f['max_age_hours'])} h alt")
|
||||||
return " · ".join(parts)
|
return " · ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -393,10 +406,22 @@ def _wohnungen_context(user) -> dict:
|
||||||
flats = db.recent_flats(100)
|
flats = db.recent_flats(100)
|
||||||
|
|
||||||
rejected = db.rejected_flat_ids(uid)
|
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 = []
|
flats_view = []
|
||||||
for f in flats:
|
for f in flats:
|
||||||
if f["id"] in rejected:
|
if f["id"] in rejected:
|
||||||
continue
|
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({
|
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"]},
|
||||||
|
|
@ -456,6 +481,7 @@ def _wohnungen_context(user) -> dict:
|
||||||
"apply_block_reason": reason,
|
"apply_block_reason": reason,
|
||||||
"apply_reachable": apply_client.health(),
|
"apply_reachable": apply_client.health(),
|
||||||
"next_scrape_utc": _next_scrape_utc(),
|
"next_scrape_utc": _next_scrape_utc(),
|
||||||
|
"last_scrape_utc": _last_scrape_utc(),
|
||||||
"has_running_apply": has_running,
|
"has_running_apply": has_running,
|
||||||
"poll_interval": 3 if has_running else 30,
|
"poll_interval": 3 if has_running else 30,
|
||||||
}
|
}
|
||||||
|
|
@ -528,6 +554,7 @@ async def action_save_filters(
|
||||||
min_size: str = Form(""),
|
min_size: str = Form(""),
|
||||||
max_morning_commute: str = Form(""),
|
max_morning_commute: str = Form(""),
|
||||||
wbs_required: str = Form(""),
|
wbs_required: str = Form(""),
|
||||||
|
max_age_hours: str = Form(""),
|
||||||
user=Depends(require_user),
|
user=Depends(require_user),
|
||||||
):
|
):
|
||||||
require_csrf(user["id"], csrf)
|
require_csrf(user["id"], csrf)
|
||||||
|
|
@ -536,6 +563,13 @@ async def action_save_filters(
|
||||||
v = (v or "").strip().replace(",", ".")
|
v = (v or "").strip().replace(",", ".")
|
||||||
return float(v) if v else None
|
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"], {
|
db.update_filters(user["id"], {
|
||||||
"rooms_min": _f(rooms_min),
|
"rooms_min": _f(rooms_min),
|
||||||
"rooms_max": _f(rooms_max),
|
"rooms_max": _f(rooms_max),
|
||||||
|
|
@ -543,6 +577,7 @@ async def action_save_filters(
|
||||||
"min_size": _f(min_size),
|
"min_size": _f(min_size),
|
||||||
"max_morning_commute": _f(max_morning_commute),
|
"max_morning_commute": _f(max_morning_commute),
|
||||||
"wbs_required": (wbs_required or "").strip(),
|
"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))
|
db.log_audit(user["username"], "filters.updated", user_id=user["id"], ip=client_ip(request))
|
||||||
return RedirectResponse("/", status_code=303)
|
return RedirectResponse("/", status_code=303)
|
||||||
|
|
@ -784,23 +819,45 @@ def _collect_events(start_iso: str | None, end_iso: str | None) -> list[dict]:
|
||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
@app.get("/logs", response_class=HTMLResponse)
|
@app.get("/logs")
|
||||||
def tab_logs(request: Request):
|
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)
|
u = current_user(request)
|
||||||
if not u:
|
if not u:
|
||||||
return RedirectResponse("/login", status_code=303)
|
return RedirectResponse("/login", status_code=303)
|
||||||
if not u["is_admin"]:
|
if not u["is_admin"]:
|
||||||
raise HTTPException(403, "admin only")
|
raise HTTPException(403, "admin only")
|
||||||
|
if section not in ADMIN_SECTIONS:
|
||||||
|
raise HTTPException(404)
|
||||||
|
|
||||||
|
ctx = base_context(request, u, "admin")
|
||||||
|
ctx["section"] = section
|
||||||
|
|
||||||
|
if section == "protokoll":
|
||||||
q = request.query_params
|
q = request.query_params
|
||||||
from_str = q.get("from") or ""
|
from_str = q.get("from") or ""
|
||||||
to_str = q.get("to") or ""
|
to_str = q.get("to") or ""
|
||||||
start_iso, end_iso = _parse_date_range(from_str or None, to_str or None)
|
start_iso, end_iso = _parse_date_range(from_str or None, to_str or None)
|
||||||
events = _collect_events(start_iso, end_iso)[:500]
|
ctx.update({
|
||||||
|
"events": _collect_events(start_iso, end_iso)[:500],
|
||||||
ctx = base_context(request, u, "logs")
|
"from_str": from_str, "to_str": to_str,
|
||||||
ctx.update({"events": events, "from_str": from_str, "to_str": to_str})
|
})
|
||||||
return templates.TemplateResponse("logs.html", ctx)
|
elif section == "benutzer":
|
||||||
|
ctx["users"] = db.list_users()
|
||||||
|
return templates.TemplateResponse("admin.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/logs/export.csv")
|
@app.get("/logs/export.csv")
|
||||||
|
|
@ -846,7 +903,7 @@ def tab_logs_export(request: Request):
|
||||||
# Tab: Einstellungen (sub-tabs)
|
# Tab: Einstellungen (sub-tabs)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
VALID_SECTIONS = ("profil", "filter", "benachrichtigungen", "account", "benutzer")
|
VALID_SECTIONS = ("profil", "filter", "benachrichtigungen", "account")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/einstellungen", response_class=HTMLResponse)
|
@app.get("/einstellungen", response_class=HTMLResponse)
|
||||||
|
|
@ -859,10 +916,11 @@ def tab_settings(request: Request, section: str):
|
||||||
u = current_user(request)
|
u = current_user(request)
|
||||||
if not u:
|
if not u:
|
||||||
return RedirectResponse("/login", status_code=303)
|
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:
|
if section not in VALID_SECTIONS:
|
||||||
raise HTTPException(404)
|
raise HTTPException(404)
|
||||||
if section == "benutzer" and not u["is_admin"]:
|
|
||||||
raise HTTPException(403)
|
|
||||||
|
|
||||||
ctx = base_context(request, u, "einstellungen")
|
ctx = base_context(request, u, "einstellungen")
|
||||||
ctx["section"] = section
|
ctx["section"] = section
|
||||||
|
|
@ -873,10 +931,6 @@ def tab_settings(request: Request, section: str):
|
||||||
ctx["filters"] = row_to_dict(db.get_filters(u["id"]))
|
ctx["filters"] = row_to_dict(db.get_filters(u["id"]))
|
||||||
elif section == "benachrichtigungen":
|
elif section == "benachrichtigungen":
|
||||||
ctx["notifications"] = db.get_notifications(u["id"])
|
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)
|
return templates.TemplateResponse("einstellungen.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -995,10 +1049,10 @@ async def action_users_create(
|
||||||
uid = db.create_user(username, hash_password(password),
|
uid = db.create_user(username, hash_password(password),
|
||||||
is_admin=(is_admin.lower() in ("on", "true", "yes", "1")))
|
is_admin=(is_admin.lower() in ("on", "true", "yes", "1")))
|
||||||
except sqlite3.IntegrityError:
|
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}",
|
db.log_audit(admin["username"], "user.created", f"new_user={username} id={uid}",
|
||||||
user_id=admin["id"], ip=client_ip(request))
|
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")
|
@app.post("/actions/users/disable")
|
||||||
|
|
@ -1016,7 +1070,7 @@ async def action_users_disable(
|
||||||
db.log_audit(admin["username"], "user.toggle_disable",
|
db.log_audit(admin["username"], "user.toggle_disable",
|
||||||
f"target={target_id} disabled={value=='on'}",
|
f"target={target_id} disabled={value=='on'}",
|
||||||
user_id=admin["id"], ip=client_ip(request))
|
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")
|
@app.post("/actions/enrich-all")
|
||||||
|
|
@ -1059,12 +1113,12 @@ async def action_users_delete(
|
||||||
raise HTTPException(400, "refusing to delete self")
|
raise HTTPException(400, "refusing to delete self")
|
||||||
target = db.get_user(target_id)
|
target = db.get_user(target_id)
|
||||||
if not target:
|
if not target:
|
||||||
return RedirectResponse("/einstellungen/benutzer", status_code=303)
|
return RedirectResponse("/admin/benutzer", status_code=303)
|
||||||
db.delete_user(target_id)
|
db.delete_user(target_id)
|
||||||
db.log_audit(admin["username"], "user.deleted",
|
db.log_audit(admin["username"], "user.deleted",
|
||||||
f"target={target_id} username={target['username']}",
|
f"target={target_id} username={target['username']}",
|
||||||
user_id=admin["id"], ip=client_ip(request))
|
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)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,10 @@ MIGRATIONS: list[str] = [
|
||||||
ALTER TABLE flats ADD COLUMN enrichment_updated_at TEXT;
|
ALTER TABLE flats ADD COLUMN enrichment_updated_at TEXT;
|
||||||
ALTER TABLE flats ADD COLUMN image_count INTEGER NOT NULL DEFAULT 0;
|
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:
|
def update_filters(user_id: int, data: dict) -> None:
|
||||||
_ensure_user_rows(user_id)
|
_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}
|
clean = {k: data.get(k) for k in allowed if k in data}
|
||||||
if not clean:
|
if not clean:
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
// lazyflat — live time helpers.
|
// lazyflat — live time helpers.
|
||||||
// Any element with [data-rel-utc="<iso>"] gets its text replaced every 5s
|
// [data-rel-utc="<iso>"] → "vor 3 min" (relative-past, updated every 5 s)
|
||||||
// with a German relative-time string ("vor 3 min"). Elements with
|
// [data-counter-up-utc="<iso>"] → "vor X s" counting up each second (used for
|
||||||
// [data-countdown-utc="<iso>"] show "in Xs" counting down each second.
|
// "aktualisiert vor X s"; resets when the
|
||||||
|
// server ships a newer timestamp in the swap)
|
||||||
|
|
||||||
function fmtRelative(iso) {
|
function fmtRelative(iso) {
|
||||||
const ts = Date.parse(iso);
|
const ts = Date.parse(iso);
|
||||||
|
|
@ -14,14 +15,13 @@ function fmtRelative(iso) {
|
||||||
return `vor ${Math.floor(diff / 86400)} Tagen`;
|
return `vor ${Math.floor(diff / 86400)} Tagen`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtCountdown(iso) {
|
function fmtCountUp(iso) {
|
||||||
const ts = Date.parse(iso);
|
const ts = Date.parse(iso);
|
||||||
if (!iso || Number.isNaN(ts)) return "—";
|
if (!iso || Number.isNaN(ts)) return "—";
|
||||||
const secs = Math.floor((ts - Date.now()) / 1000);
|
const diff = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
||||||
if (secs <= 0) return "aktualisiere…";
|
if (diff < 60) return `vor ${diff} s`;
|
||||||
if (secs < 60) return `in ${secs} s`;
|
if (diff < 3600) return `vor ${Math.floor(diff / 60)} min`;
|
||||||
if (secs < 3600) return `in ${Math.floor(secs / 60)} min`;
|
return `vor ${Math.floor(diff / 3600)} h`;
|
||||||
return `in ${Math.floor(secs / 3600)} h`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRelativeTimes() {
|
function updateRelativeTimes() {
|
||||||
|
|
@ -30,22 +30,38 @@ function updateRelativeTimes() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCountdowns() {
|
function updateCounterUps() {
|
||||||
document.querySelectorAll("[data-countdown-utc]").forEach((el) => {
|
document.querySelectorAll("[data-counter-up-utc]").forEach((el) => {
|
||||||
el.textContent = fmtCountdown(el.dataset.countdownUtc);
|
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() {
|
function tick() {
|
||||||
updateRelativeTimes();
|
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.addEventListener("DOMContentLoaded", tick);
|
||||||
document.body && document.body.addEventListener("htmx:afterSwap", tick);
|
document.addEventListener("DOMContentLoaded", syncFlatExpandState);
|
||||||
setInterval(updateCountdowns, 1000);
|
if (document.body) {
|
||||||
|
document.body.addEventListener("htmx:afterSwap", tick);
|
||||||
|
document.body.addEventListener("htmx:afterSwap", syncFlatExpandState);
|
||||||
|
}
|
||||||
|
setInterval(updateCounterUps, 1000);
|
||||||
setInterval(updateRelativeTimes, 5000);
|
setInterval(updateRelativeTimes, 5000);
|
||||||
|
|
||||||
// Flat detail expand — lazily fetches /partials/wohnung/<id> into the sibling
|
// Flat detail expand — lazily fetches /partials/wohnung/<id> into the sibling
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
{% extends "_layout.html" %}
|
<form method="get" action="/admin/protokoll" class="flex flex-wrap items-end gap-3 mb-5">
|
||||||
{% block content %}
|
|
||||||
<section class="card p-4">
|
|
||||||
<form method="get" action="/logs" class="flex flex-wrap items-end gap-3">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">von</label>
|
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">von</label>
|
||||||
<input class="input" type="date" name="from" value="{{ from_str }}">
|
<input class="input" type="date" name="from" value="{{ from_str }}">
|
||||||
|
|
@ -11,15 +8,14 @@
|
||||||
<input class="input" type="date" name="to" value="{{ to_str }}">
|
<input class="input" type="date" name="to" value="{{ to_str }}">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary text-sm" type="submit">Anwenden</button>
|
<button class="btn btn-primary text-sm" type="submit">Anwenden</button>
|
||||||
<a class="btn btn-ghost text-sm" href="/logs">zurücksetzen</a>
|
<a class="btn btn-ghost text-sm" href="/admin/protokoll">zurücksetzen</a>
|
||||||
<a class="btn btn-ghost text-sm"
|
<a class="btn btn-ghost text-sm"
|
||||||
href="/logs/export.csv?from={{ from_str }}&to={{ to_str }}">
|
href="/logs/export.csv?from={{ from_str }}&to={{ to_str }}">
|
||||||
CSV herunterladen
|
CSV herunterladen
|
||||||
</a>
|
</a>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
<div class="card">
|
||||||
<div class="px-4 py-3 border-b border-soft flex items-center justify-between">
|
<div class="px-4 py-3 border-b border-soft flex items-center justify-between">
|
||||||
<h2 class="font-semibold">System-Protokoll</h2>
|
<h2 class="font-semibold">System-Protokoll</h2>
|
||||||
<span class="text-xs text-slate-500">
|
<span class="text-xs text-slate-500">
|
||||||
|
|
@ -50,5 +46,4 @@
|
||||||
<div class="px-4 py-8 text-center text-slate-500">Keine Einträge im gewählten Zeitraum.</div>
|
<div class="px-4 py-8 text-center text-slate-500">Keine Einträge im gewählten Zeitraum.</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -20,10 +20,10 @@
|
||||||
<nav class="max-w-6xl mx-auto px-6 flex border-b border-soft -mb-px">
|
<nav class="max-w-6xl mx-auto px-6 flex border-b border-soft -mb-px">
|
||||||
<a class="tab {% if active_tab=='wohnungen' %}active{% endif %}" href="/">Wohnungen</a>
|
<a class="tab {% if active_tab=='wohnungen' %}active{% endif %}" href="/">Wohnungen</a>
|
||||||
<a class="tab {% if active_tab=='bewerbungen' %}active{% endif %}" href="/bewerbungen">Bewerbungen</a>
|
<a class="tab {% if active_tab=='bewerbungen' %}active{% endif %}" href="/bewerbungen">Bewerbungen</a>
|
||||||
{% if is_admin %}
|
|
||||||
<a class="tab {% if active_tab=='logs' %}active{% endif %}" href="/logs">Protokoll</a>
|
|
||||||
{% endif %}
|
|
||||||
<a class="tab {% if active_tab=='einstellungen' %}active{% endif %}" href="/einstellungen">Einstellungen</a>
|
<a class="tab {% if active_tab=='einstellungen' %}active{% endif %}" href="/einstellungen">Einstellungen</a>
|
||||||
|
{% if is_admin %}
|
||||||
|
<a class="tab {% if active_tab=='admin' %}active{% endif %}" href="/admin">Admin</a>
|
||||||
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<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">
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,15 @@
|
||||||
<option value="no" {% if filters.wbs_required == 'no' %}selected{% endif %}>nein</option>
|
<option value="no" {% if filters.wbs_required == 'no' %}selected{% endif %}>nein</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">max Alter</label>
|
||||||
|
<select class="input" name="max_age_hours">
|
||||||
|
<option value="" {% if not filters.max_age_hours %}selected{% endif %}>beliebig</option>
|
||||||
|
{% for h in range(1, 11) %}
|
||||||
|
<option value="{{ h }}" {% if filters.max_age_hours == h %}selected{% endif %}>≤ {{ h }} h</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="col-span-2 md:col-span-3">
|
<div class="col-span-2 md:col-span-3">
|
||||||
<button class="btn btn-primary" type="submit">Filter speichern</button>
|
<button class="btn btn-primary" type="submit">Filter speichern</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -118,12 +118,3 @@
|
||||||
<button class="btn btn-primary" type="submit">Speichern</button>
|
<button class="btn btn-primary" type="submit">Speichern</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<hr class="my-6 border-soft">
|
|
||||||
<h3 class="font-semibold mb-2">Final absenden</h3>
|
|
||||||
<p class="text-sm text-slate-600 mb-3">
|
|
||||||
<span class="chip chip-warn">experimentell</span>
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
|
|
|
||||||
|
|
@ -80,9 +80,9 @@
|
||||||
<h2 class="font-semibold">Passende Wohnungen auf inberlinwohnen.de</h2>
|
<h2 class="font-semibold">Passende Wohnungen auf inberlinwohnen.de</h2>
|
||||||
<div class="flex items-center gap-3 text-xs text-slate-500">
|
<div class="flex items-center gap-3 text-xs text-slate-500">
|
||||||
<span>{{ flats|length }} gefunden</span>
|
<span>{{ flats|length }} gefunden</span>
|
||||||
{% if next_scrape_utc %}
|
{% if last_scrape_utc %}
|
||||||
<span class="sep">·</span>
|
<span class="sep">·</span>
|
||||||
<span>nächste Aktualisierung <span class="countdown" data-countdown-utc="{{ next_scrape_utc }}">…</span></span>
|
<span>aktualisiert <span class="countdown" data-counter-up-utc="{{ last_scrape_utc }}">…</span></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if is_admin and (enrichment_counts.pending or enrichment_counts.failed) %}
|
{% if is_admin and (enrichment_counts.pending or enrichment_counts.failed) %}
|
||||||
<span class="sep">·</span>
|
<span class="sep">·</span>
|
||||||
|
|
|
||||||
22
web/templates/admin.html
Normal file
22
web/templates/admin.html
Normal file
|
|
@ -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 %}
|
||||||
|
<section class="card">
|
||||||
|
<nav class="flex flex-wrap border-b border-soft px-4">
|
||||||
|
{% set sections = [('protokoll','Protokoll'), ('benutzer','Benutzer')] %}
|
||||||
|
{% for key, label in sections %}
|
||||||
|
<a href="/admin/{{ key }}"
|
||||||
|
class="tab {% if section == key %}active{% endif %}">{{ label }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="p-5">
|
||||||
|
{% if section == 'protokoll' %}{% include "_admin_logs.html" %}
|
||||||
|
{% elif section == 'benutzer' %}{% include "_settings_users.html" %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<nav class="flex flex-wrap border-b border-soft px-4">
|
<nav class="flex flex-wrap border-b border-soft px-4">
|
||||||
{% set sections = [('profil','Profil'),('filter','Filter'),('benachrichtigungen','Benachrichtigungen'),('account','Account')] %}
|
{% set sections = [('profil','Profil'),('filter','Filter'),('benachrichtigungen','Benachrichtigungen'),('account','Account')] %}
|
||||||
{% if is_admin %}{% set sections = sections + [('benutzer','Benutzer')] %}{% endif %}
|
|
||||||
{% for key, label in sections %}
|
{% for key, label in sections %}
|
||||||
<a href="/einstellungen/{{ key }}"
|
<a href="/einstellungen/{{ key }}"
|
||||||
class="tab {% if section == key %}active{% endif %}">{{ label }}</a>
|
class="tab {% if section == key %}active{% endif %}">{{ label }}</a>
|
||||||
|
|
@ -15,7 +14,6 @@
|
||||||
{% elif section == 'filter' %}{% include "_settings_filter.html" %}
|
{% elif section == 'filter' %}{% include "_settings_filter.html" %}
|
||||||
{% elif section == 'benachrichtigungen' %}{% include "_settings_notifications.html" %}
|
{% elif section == 'benachrichtigungen' %}{% include "_settings_notifications.html" %}
|
||||||
{% elif section == 'account' %}{% include "_settings_account.html" %}
|
{% elif section == 'account' %}{% include "_settings_account.html" %}
|
||||||
{% elif section == 'benutzer' %}{% include "_settings_users.html" %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue