ui: slim 4-card strip, admin-only system log, HTMX apply, title cleanup
* Wohnungen header: single slim row with Alert status · Filter summary · Auto-Bewerben toggle · Trockenmodus toggle. Big filter panel removed — filters live only in /einstellungen/filter. * Alert status: 'nicht eingerichtet' until the user has actual filters (+ valid notification creds if telegram/email). 'aktiv' otherwise. * Logs tab: admin-only (gated both in layout and server-side). Shows merged audit + errors across all users, sorted newest-first, capped at 300. * Apply, auto-apply, trockenmodus and circuit reset buttons post via HTMX and swap the Wohnungen body. While any application is still running for the user the poll interval drops from 30s to 3s so status flips to 'beworben' or 'fehlgeschlagen' almost immediately. * Browser tab title is now always 'lazyflat'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
332d9eea19
commit
04b591fa9e
9 changed files with 204 additions and 136 deletions
122
web/app.py
122
web/app.py
|
|
@ -164,6 +164,19 @@ def base_context(request: Request, user, active_tab: str) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_htmx(request: Request) -> bool:
|
||||||
|
return request.headers.get("hx-request", "").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def _wohnungen_partial_or_redirect(request: Request, user):
|
||||||
|
"""If called via HTMX, render the body partial; otherwise redirect to /."""
|
||||||
|
if _is_htmx(request):
|
||||||
|
ctx = base_context(request, user, "wohnungen")
|
||||||
|
ctx.update(_wohnungen_context(user))
|
||||||
|
return templates.TemplateResponse("_wohnungen_body.html", ctx)
|
||||||
|
return RedirectResponse("/", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
def _manual_apply_allowed() -> tuple[bool, str]:
|
def _manual_apply_allowed() -> tuple[bool, str]:
|
||||||
"""Manual 'Bewerben' button is only blocked if the apply service is down."""
|
"""Manual 'Bewerben' button is only blocked if the apply service is down."""
|
||||||
if not apply_client.health():
|
if not apply_client.health():
|
||||||
|
|
@ -190,6 +203,61 @@ 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 _has_filters(f) -> bool:
|
||||||
|
if not f:
|
||||||
|
return False
|
||||||
|
for k in FILTER_KEYS:
|
||||||
|
v = f[k] if hasattr(f, "keys") else None
|
||||||
|
if v not in (None, "", 0, 0.0):
|
||||||
|
return True
|
||||||
|
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):
|
||||||
|
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:
|
||||||
|
if not _has_filters(f):
|
||||||
|
return "—"
|
||||||
|
parts = []
|
||||||
|
rmin, rmax = f["rooms_min"], f["rooms_max"]
|
||||||
|
if rmin or rmax:
|
||||||
|
def fmt(x):
|
||||||
|
return "–" if x is None else ("%g" % x)
|
||||||
|
parts.append(f"{fmt(rmin)}–{fmt(rmax)} Zi")
|
||||||
|
if f["max_rent"]:
|
||||||
|
parts.append(f"≤ {int(f['max_rent'])} €")
|
||||||
|
if f["min_size"]:
|
||||||
|
parts.append(f"≥ {int(f['min_size'])} m²")
|
||||||
|
if f["max_morning_commute"]:
|
||||||
|
parts.append(f"≤ {int(f['max_morning_commute'])} min")
|
||||||
|
if f["wbs_required"] == "yes":
|
||||||
|
parts.append("WBS")
|
||||||
|
elif f["wbs_required"] == "no":
|
||||||
|
parts.append("ohne WBS")
|
||||||
|
return " · ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_running_application(user_id: int) -> bool:
|
||||||
|
row = db._conn.execute(
|
||||||
|
"SELECT 1 FROM applications WHERE user_id = ? AND finished_at IS NULL LIMIT 1",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
def _run_apply_background(user_id: int, flat_id: str, url: str, triggered_by: str) -> None:
|
def _run_apply_background(user_id: int, flat_id: str, url: str, triggered_by: str) -> None:
|
||||||
prefs = db.get_preferences(user_id)
|
prefs = db.get_preferences(user_id)
|
||||||
profile_row = db.get_profile(user_id)
|
profile_row = db.get_profile(user_id)
|
||||||
|
|
@ -308,6 +376,7 @@ def logout(request: Request):
|
||||||
def _wohnungen_context(user) -> dict:
|
def _wohnungen_context(user) -> dict:
|
||||||
uid = user["id"]
|
uid = user["id"]
|
||||||
filters_row = db.get_filters(uid)
|
filters_row = db.get_filters(uid)
|
||||||
|
notif_row = db.get_notifications(uid)
|
||||||
prefs = db.get_preferences(uid)
|
prefs = db.get_preferences(uid)
|
||||||
filters = row_to_dict(filters_row)
|
filters = row_to_dict(filters_row)
|
||||||
flats = db.recent_flats(100)
|
flats = db.recent_flats(100)
|
||||||
|
|
@ -325,9 +394,13 @@ def _wohnungen_context(user) -> dict:
|
||||||
})
|
})
|
||||||
|
|
||||||
allowed, reason = _manual_apply_allowed()
|
allowed, reason = _manual_apply_allowed()
|
||||||
|
alert_label, alert_chip = _alert_status(filters_row, notif_row)
|
||||||
|
has_running = _has_running_application(uid)
|
||||||
return {
|
return {
|
||||||
"flats": flats_view,
|
"flats": flats_view,
|
||||||
"filters": filters,
|
"alert_label": alert_label,
|
||||||
|
"alert_chip": alert_chip,
|
||||||
|
"filter_summary": _filter_summary(filters_row),
|
||||||
"auto_apply_enabled": bool(prefs["auto_apply_enabled"]),
|
"auto_apply_enabled": bool(prefs["auto_apply_enabled"]),
|
||||||
"submit_forms": bool(prefs["submit_forms"]),
|
"submit_forms": bool(prefs["submit_forms"]),
|
||||||
"circuit_open": bool(prefs["apply_circuit_open"]),
|
"circuit_open": bool(prefs["apply_circuit_open"]),
|
||||||
|
|
@ -335,9 +408,9 @@ def _wohnungen_context(user) -> dict:
|
||||||
"apply_allowed": allowed,
|
"apply_allowed": allowed,
|
||||||
"apply_block_reason": reason,
|
"apply_block_reason": reason,
|
||||||
"apply_reachable": apply_client.health(),
|
"apply_reachable": apply_client.health(),
|
||||||
"last_alert_heartbeat": db.get_state("last_alert_heartbeat") or "",
|
|
||||||
"next_scrape_utc": _next_scrape_utc(),
|
"next_scrape_utc": _next_scrape_utc(),
|
||||||
"scrape_interval_seconds": ALERT_SCRAPE_INTERVAL_SECONDS,
|
"has_running_apply": has_running,
|
||||||
|
"poll_interval": 3 if has_running else 30,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -400,7 +473,7 @@ async def action_auto_apply(
|
||||||
db.update_preferences(user["id"], {"auto_apply_enabled": new})
|
db.update_preferences(user["id"], {"auto_apply_enabled": new})
|
||||||
db.log_audit(user["username"], "auto_apply", "on" if new else "off",
|
db.log_audit(user["username"], "auto_apply", "on" if new else "off",
|
||||||
user_id=user["id"], ip=client_ip(request))
|
user_id=user["id"], ip=client_ip(request))
|
||||||
return RedirectResponse("/", status_code=303)
|
return _wohnungen_partial_or_redirect(request, user)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/actions/reset-circuit")
|
@app.post("/actions/reset-circuit")
|
||||||
|
|
@ -412,7 +485,7 @@ async def action_reset_circuit(
|
||||||
require_csrf(user["id"], csrf)
|
require_csrf(user["id"], csrf)
|
||||||
db.update_preferences(user["id"], {"apply_circuit_open": 0, "apply_recent_failures": 0})
|
db.update_preferences(user["id"], {"apply_circuit_open": 0, "apply_recent_failures": 0})
|
||||||
db.log_audit(user["username"], "reset_circuit", user_id=user["id"], ip=client_ip(request))
|
db.log_audit(user["username"], "reset_circuit", user_id=user["id"], ip=client_ip(request))
|
||||||
return RedirectResponse("/", status_code=303)
|
return _wohnungen_partial_or_redirect(request, user)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/actions/apply")
|
@app.post("/actions/apply")
|
||||||
|
|
@ -432,7 +505,7 @@ async def action_apply(
|
||||||
db.log_audit(user["username"], "trigger_apply", f"flat_id={flat_id}",
|
db.log_audit(user["username"], "trigger_apply", f"flat_id={flat_id}",
|
||||||
user_id=user["id"], ip=client_ip(request))
|
user_id=user["id"], ip=client_ip(request))
|
||||||
_kick_apply(user["id"], flat_id, flat["link"], "user")
|
_kick_apply(user["id"], flat_id, flat["link"], "user")
|
||||||
return RedirectResponse("/", status_code=303)
|
return _wohnungen_partial_or_redirect(request, user)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -546,8 +619,39 @@ def tab_logs(request: Request):
|
||||||
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"]:
|
||||||
|
raise HTTPException(403, "admin only")
|
||||||
|
|
||||||
|
# Merge audit + errors across the whole system for a unified view.
|
||||||
|
users = {row["id"]: row["username"] for row in db.list_users()}
|
||||||
|
events: list[dict] = []
|
||||||
|
for a in db.recent_audit(None, limit=500):
|
||||||
|
events.append({
|
||||||
|
"kind": "audit",
|
||||||
|
"ts": a["timestamp"],
|
||||||
|
"source": "web",
|
||||||
|
"actor": a["actor"],
|
||||||
|
"action": a["action"],
|
||||||
|
"details": a["details"] or "",
|
||||||
|
"user": users.get(a["user_id"], ""),
|
||||||
|
"ip": a["ip"] or "",
|
||||||
|
})
|
||||||
|
for e in db.recent_errors(None, limit=500):
|
||||||
|
events.append({
|
||||||
|
"kind": "error",
|
||||||
|
"ts": e["timestamp"],
|
||||||
|
"source": e["source"],
|
||||||
|
"actor": e["source"],
|
||||||
|
"action": e["kind"],
|
||||||
|
"details": e["summary"] or "",
|
||||||
|
"user": users.get(e["user_id"], "") if e["user_id"] else "",
|
||||||
|
"ip": "",
|
||||||
|
})
|
||||||
|
events.sort(key=lambda x: x["ts"], reverse=True)
|
||||||
|
events = events[:300]
|
||||||
|
|
||||||
ctx = base_context(request, u, "logs")
|
ctx = base_context(request, u, "logs")
|
||||||
ctx["events"] = db.recent_audit(u["id"], limit=200)
|
ctx["events"] = events
|
||||||
return templates.TemplateResponse("logs.html", ctx)
|
return templates.TemplateResponse("logs.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -676,7 +780,9 @@ async def action_submit_forms(
|
||||||
db.update_preferences(user["id"], {"submit_forms": new})
|
db.update_preferences(user["id"], {"submit_forms": new})
|
||||||
db.log_audit(user["username"], "submit_forms", "on" if new else "off",
|
db.log_audit(user["username"], "submit_forms", "on" if new else "off",
|
||||||
user_id=user["id"], ip=client_ip(request))
|
user_id=user["id"], ip=client_ip(request))
|
||||||
return RedirectResponse("/einstellungen/profil", status_code=303)
|
if _is_htmx(request):
|
||||||
|
return _wohnungen_partial_or_redirect(request, user)
|
||||||
|
return RedirectResponse(request.headers.get("referer", "/einstellungen/profil"), status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/actions/users/create")
|
@app.post("/actions/users/create")
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,9 @@
|
||||||
<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">Logs</a>
|
<a class="tab {% if active_tab=='logs' %}active{% endif %}" href="/logs">Logs</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>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -1,120 +1,83 @@
|
||||||
{# Renders the full Wohnungen body; used both by the full page and by HTMX poll. #}
|
{# Renders the full Wohnungen body; used both by the full page and by HTMX poll. #}
|
||||||
<div id="wohnungen-body" class="space-y-6">
|
<div id="wohnungen-body"
|
||||||
|
class="space-y-5"
|
||||||
|
hx-get="/partials/wohnungen"
|
||||||
|
hx-trigger="every {{ poll_interval }}s"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
|
||||||
<!-- Auto-Bewerben + Status-Leiste -->
|
<!-- Slim status strip: Alert · Filter · Auto-Bewerben · Trockenmodus -->
|
||||||
<section class="card p-5 flex flex-col md:flex-row md:items-center gap-4 justify-between">
|
<section class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
<div class="flex flex-col gap-1">
|
<!-- Alert -->
|
||||||
<div class="text-xs uppercase tracking-wide text-slate-500">Auto-Bewerben</div>
|
<a class="card px-4 py-2.5 flex flex-col gap-0.5 hover:bg-[#f6fafd]" href="/einstellungen/filter">
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Alert</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if auto_apply_enabled %}
|
<span class="chip chip-{{ alert_chip }}">{{ alert_label }}</span>
|
||||||
<span class="chip chip-warn">aktiviert</span>
|
|
||||||
<span class="text-sm text-slate-600">bei Match wird automatisch beworben</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="chip chip-info">deaktiviert</span>
|
|
||||||
<span class="text-sm text-slate-600">Matches werden nur angezeigt</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<!-- Filter summary -->
|
||||||
<form method="post" action="/actions/auto-apply">
|
<a class="card px-4 py-2.5 flex flex-col gap-0.5 hover:bg-[#f6fafd]" href="/einstellungen/filter">
|
||||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Filter</div>
|
||||||
<input type="hidden" name="value" value="{% if auto_apply_enabled %}off{% else %}on{% endif %}">
|
<div class="text-sm text-slate-700 truncate">{{ filter_summary }}</div>
|
||||||
<button class="btn btn-hot {% if not auto_apply_enabled %}off{% endif %}"
|
</a>
|
||||||
onclick="return confirm('{% if auto_apply_enabled %}Auto-Bewerben deaktivieren?{% else %}Auto-Bewerben aktivieren? Das System bewirbt dann automatisch bei jedem Match – bitte Profil und Filter prüfen.{% endif %}');"
|
|
||||||
type="submit">
|
|
||||||
{% if auto_apply_enabled %}AUTO-BEWERBEN DEAKTIVIEREN{% else %}AUTO-BEWERBEN AKTIVIEREN{% endif %}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% if circuit_open %}
|
<!-- Auto-Bewerben toggle -->
|
||||||
<form method="post" action="/actions/reset-circuit">
|
<form class="card px-4 py-2.5 flex items-center justify-between gap-2"
|
||||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
method="post" action="/actions/auto-apply"
|
||||||
<button class="btn btn-ghost text-sm" type="submit">Circuit zurücksetzen</button>
|
hx-post="/actions/auto-apply" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
||||||
</form>
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||||
{% endif %}
|
<input type="hidden" name="value" value="{% if auto_apply_enabled %}off{% else %}on{% endif %}">
|
||||||
</div>
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Auto-Bewerben</div>
|
||||||
|
<div>{% if auto_apply_enabled %}<span class="chip chip-warn">aktiv</span>
|
||||||
|
{% else %}<span class="chip chip-info">aus</span>{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn {% if auto_apply_enabled %}btn-ghost{% else %}btn-hot{% endif %} text-xs"
|
||||||
|
onclick="return confirm('{% if auto_apply_enabled %}Auto-Bewerben deaktivieren?{% else %}Auto-Bewerben aktivieren? Bei jedem passenden Flat wird automatisch beworben.{% endif %}');"
|
||||||
|
type="submit">
|
||||||
|
{% if auto_apply_enabled %}AUS{% else %}AN{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Trockenmodus toggle -->
|
||||||
|
<form class="card px-4 py-2.5 flex items-center justify-between gap-2"
|
||||||
|
method="post" action="/actions/submit-forms"
|
||||||
|
hx-post="/actions/submit-forms" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
||||||
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||||
|
<input type="hidden" name="value" value="{% if submit_forms %}off{% else %}on{% endif %}">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Trockenmodus</div>
|
||||||
|
<div>{% if submit_forms %}<span class="chip chip-warn">aus (echt!)</span>
|
||||||
|
{% else %}<span class="chip chip-ok">an</span>{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost text-xs"
|
||||||
|
onclick="return confirm('{% if submit_forms %}Trockenmodus wieder einschalten?{% else %}Trockenmodus ausschalten? Formulare werden dann WIRKLICH abgesendet!{% endif %}');"
|
||||||
|
type="submit">
|
||||||
|
{% if submit_forms %}AN{% else %}AUS{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if not apply_allowed %}
|
{% if not apply_allowed %}
|
||||||
<div class="card p-4">
|
<div class="card p-3 text-sm">
|
||||||
<span class="chip chip-bad">apply blockiert</span>
|
<span class="chip chip-bad">apply blockiert</span>
|
||||||
<span class="ml-2 text-sm text-slate-600">{{ apply_block_reason }}</span>
|
<span class="ml-2 text-slate-600">{{ apply_block_reason }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Status-Reihe -->
|
{% if circuit_open %}
|
||||||
<section class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div class="card p-3 text-sm flex items-center justify-between">
|
||||||
<div class="card p-3">
|
<div>
|
||||||
<div class="text-xs text-slate-500">alert</div>
|
<span class="chip chip-bad">circuit open</span>
|
||||||
<div class="mt-1">
|
<span class="ml-2 text-slate-600">{{ apply_failures }} Fehler in Serie — Auto-Bewerben pausiert</span>
|
||||||
{% if last_alert_heartbeat %}<span class="chip chip-ok">live</span>
|
|
||||||
{% else %}<span class="chip chip-warn">kein Heartbeat</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-3">
|
<form method="post" action="/actions/reset-circuit"
|
||||||
<div class="text-xs text-slate-500">apply</div>
|
hx-post="/actions/reset-circuit" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
||||||
<div class="mt-1">
|
|
||||||
{% if apply_reachable %}<span class="chip chip-ok">ok</span>
|
|
||||||
{% else %}<span class="chip chip-bad">down</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-3">
|
|
||||||
<div class="text-xs text-slate-500">submit_forms</div>
|
|
||||||
<div class="mt-1">
|
|
||||||
{% if submit_forms %}<span class="chip chip-warn">echt senden</span>
|
|
||||||
{% else %}<span class="chip chip-info">dry-run</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-3">
|
|
||||||
<div class="text-xs text-slate-500">Fehler in Serie</div>
|
|
||||||
<div class="mt-1">
|
|
||||||
{% if circuit_open %}<span class="chip chip-bad">circuit open</span>
|
|
||||||
{% elif apply_failures > 0 %}<span class="chip chip-warn">{{ apply_failures }}</span>
|
|
||||||
{% else %}<span class="chip chip-ok">0</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Filter Panel -->
|
|
||||||
<details class="card" {% if not filters.rooms_min and not filters.max_rent %}open{% endif %}>
|
|
||||||
<summary class="px-5 py-3 font-semibold select-none">Eigene Filter</summary>
|
|
||||||
<form method="post" action="/actions/filters" class="p-5 grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
||||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||||
<div>
|
<button class="btn btn-ghost text-xs" type="submit">Zurücksetzen</button>
|
||||||
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">Zimmer min</label>
|
|
||||||
<input class="input" name="rooms_min" value="{{ filters.rooms_min if filters.rooms_min is not none else '' }}" placeholder="z.B. 2">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">Zimmer max</label>
|
|
||||||
<input class="input" name="rooms_max" value="{{ filters.rooms_max if filters.rooms_max is not none else '' }}" placeholder="z.B. 3">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">max Miete (€)</label>
|
|
||||||
<input class="input" name="max_rent" value="{{ filters.max_rent if filters.max_rent is not none else '' }}" placeholder="1500">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">min Größe (m²)</label>
|
|
||||||
<input class="input" name="min_size" value="{{ filters.min_size if filters.min_size is not none else '' }}" placeholder="40">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">max Anfahrt morgens (min)</label>
|
|
||||||
<input class="input" name="max_morning_commute" value="{{ filters.max_morning_commute if filters.max_morning_commute is not none else '' }}" placeholder="50">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">WBS benötigt</label>
|
|
||||||
<select class="input" name="wbs_required">
|
|
||||||
<option value="" {% if not filters.wbs_required %}selected{% endif %}>egal</option>
|
|
||||||
<option value="yes" {% if filters.wbs_required == 'yes' %}selected{% endif %}>ja</option>
|
|
||||||
<option value="no" {% if filters.wbs_required == 'no' %}selected{% endif %}>nein</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2 md:col-span-3 flex gap-2 pt-2">
|
|
||||||
<button class="btn btn-primary" type="submit">Filter speichern</button>
|
|
||||||
<span class="text-xs text-slate-500 self-center">Leer lassen = kein Limit. Filter bestimmen Match-Hervorhebung + Auto-Bewerben.</span>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Liste aller Wohnungen -->
|
<!-- Liste aller Wohnungen -->
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
|
@ -124,8 +87,6 @@
|
||||||
<span>{{ flats|length }} gesehen</span>
|
<span>{{ flats|length }} gesehen</span>
|
||||||
{% if next_scrape_utc %}
|
{% if next_scrape_utc %}
|
||||||
<span>· nächste Aktualisierung <span data-countdown-utc="{{ next_scrape_utc }}">…</span></span>
|
<span>· nächste Aktualisierung <span data-countdown-utc="{{ next_scrape_utc }}">…</span></span>
|
||||||
{% else %}
|
|
||||||
<span>· warte auf alert…</span>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -139,9 +100,11 @@
|
||||||
{{ f.address or f.link }}
|
{{ f.address or f.link }}
|
||||||
</a>
|
</a>
|
||||||
{% if item.matched %}<span class="chip chip-ok">match</span>{% endif %}
|
{% if item.matched %}<span class="chip chip-ok">match</span>{% endif %}
|
||||||
{% if item.last and item.last.success == 1 %}<span class="chip chip-ok">beworben</span>
|
{% if item.last and item.last.finished_at is none %}
|
||||||
|
<span class="chip chip-warn">läuft…</span>
|
||||||
|
{% elif item.last and item.last.success == 1 %}<span class="chip chip-ok">beworben</span>
|
||||||
{% elif item.last and item.last.success == 0 %}<span class="chip chip-bad">apply fehlgeschlagen</span>
|
{% elif item.last and item.last.success == 0 %}<span class="chip chip-bad">apply fehlgeschlagen</span>
|
||||||
{% elif item.last %}<span class="chip chip-warn">läuft</span>{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-slate-500 mt-0.5">
|
<div class="text-xs text-slate-500 mt-0.5">
|
||||||
{% if f.rooms %}{{ "%.1f"|format(f.rooms) }} Z{% endif %}
|
{% if f.rooms %}{{ "%.1f"|format(f.rooms) }} Z{% endif %}
|
||||||
|
|
@ -158,12 +121,15 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
{% if apply_allowed and not (item.last and item.last.success == 1) %}
|
{% if apply_allowed and not (item.last and item.last.success == 1) %}
|
||||||
<form method="post" action="/actions/apply">
|
{% set is_running = item.last and item.last.finished_at is none %}
|
||||||
|
<form method="post" action="/actions/apply"
|
||||||
|
hx-post="/actions/apply" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
||||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||||
<input type="hidden" name="flat_id" value="{{ f.id }}">
|
<input type="hidden" name="flat_id" value="{{ f.id }}">
|
||||||
<button class="btn btn-primary text-sm" type="submit"
|
<button class="btn btn-primary text-sm" type="submit"
|
||||||
|
{% if is_running %}disabled{% endif %}
|
||||||
onclick="return confirm('Bewerbung für {{ (f.address or f.link)|e }} ausführen?');">
|
onclick="return confirm('Bewerbung für {{ (f.address or f.link)|e }} ausführen?');">
|
||||||
Bewerben
|
{% if is_running %}läuft…{% else %}Bewerben{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{% extends "_layout.html" %}
|
{% extends "_layout.html" %}
|
||||||
{% block title %}Bewerbung #{{ application.id }} — lazyflat{% endblock %}
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<a href="/bewerbungen" class="text-sm">← zurück zu den Bewerbungen</a>
|
<a href="/bewerbungen" class="text-sm">← zurück zu den Bewerbungen</a>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{% extends "_layout.html" %}
|
{% extends "_layout.html" %}
|
||||||
{% block title %}Bewerbungen — lazyflat{% endblock %}
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="flex items-center justify-between px-4 py-3 border-b border-soft">
|
<div class="flex items-center justify-between px-4 py-3 border-b border-soft">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{% extends "_layout.html" %}
|
{% extends "_layout.html" %}
|
||||||
{% block title %}Einstellungen — lazyflat{% endblock %}
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<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">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Login — lazyflat{% endblock %}
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<main class="flex min-h-screen items-center justify-center p-4">
|
<main class="flex min-h-screen items-center justify-center p-4">
|
||||||
<div class="card w-full max-w-sm p-8">
|
<div class="card w-full max-w-sm p-8">
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,27 @@
|
||||||
{% extends "_layout.html" %}
|
{% extends "_layout.html" %}
|
||||||
{% block title %}Logs — lazyflat{% endblock %}
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="px-4 py-3 border-b border-soft"><h2 class="font-semibold">Meine Aktionen (Audit-Log)</h2></div>
|
<div class="px-4 py-3 border-b border-soft flex items-center justify-between">
|
||||||
|
<h2 class="font-semibold">System-Log</h2>
|
||||||
|
<span class="text-xs text-slate-500">{{ events|length }} Einträge (max 300, letzte 14 Tage)</span>
|
||||||
|
</div>
|
||||||
<div class="divide-y divide-soft">
|
<div class="divide-y divide-soft">
|
||||||
{% for e in events %}
|
{% for e in events %}
|
||||||
<div class="px-4 py-2 mono">
|
<div class="px-4 py-2 mono flex items-start gap-3 flex-wrap">
|
||||||
<span class="text-slate-500">{{ e.timestamp|de_dt }}</span>
|
<span class="text-slate-500 shrink-0">{{ e.ts|de_dt }}</span>
|
||||||
<span class="text-slate-400">{{ e.actor }}</span>
|
{% if e.kind == 'error' %}
|
||||||
|
<span class="chip chip-bad">{{ e.source }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="chip chip-info">{{ e.source }}</span>
|
||||||
|
{% endif %}
|
||||||
<span class="text-slate-700">{{ e.action }}</span>
|
<span class="text-slate-700">{{ e.action }}</span>
|
||||||
|
{% if e.user %}<span class="text-slate-500">user={{ e.user }}</span>{% endif %}
|
||||||
{% if e.details %}<span class="text-slate-500">— {{ e.details }}</span>{% endif %}
|
{% if e.details %}<span class="text-slate-500">— {{ e.details }}</span>{% endif %}
|
||||||
{% if e.ip %}<span class="text-slate-400"> [{{ e.ip }}]</span>{% endif %}
|
{% if e.ip %}<span class="text-slate-400">[{{ e.ip }}]</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="px-4 py-8 text-center text-slate-500">Keine Log-Einträge.</div>
|
<div class="px-4 py-8 text-center text-slate-500">Keine Einträge.</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="px-4 py-3 border-t border-soft text-xs text-slate-500">
|
|
||||||
Einträge werden nach 14 Tagen automatisch gelöscht.
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,4 @@
|
||||||
{% extends "_layout.html" %}
|
{% extends "_layout.html" %}
|
||||||
{% block title %}Wohnungen — lazyflat{% endblock %}
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="wohnungen-body"
|
{% include "_wohnungen_body.html" %}
|
||||||
hx-get="/partials/wohnungen"
|
|
||||||
hx-trigger="every 30s"
|
|
||||||
hx-swap="outerHTML">
|
|
||||||
{% include "_wohnungen_body.html" %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue