* DB: users + user_profiles/filters/notifications/preferences; applications gets user_id + forensics_json + profile_snapshot_json; new errors table with 14d retention; schema versioning via MIGRATIONS list * auth: password hashes in DB (argon2); env vars seed first admin; per-user sessions; CSRF bound to user id * apply: personal info/WBS moved out of env into the request body; providers take an ApplyContext with Profile + submit_forms; full Playwright recorder (step log, console, page errors, network, screenshots, final HTML) * web: five top-level tabs (Wohnungen/Bewerbungen/Logs/Fehler/Einstellungen); settings sub-tabs profil/filter/benachrichtigungen/account/benutzer; per-user matching, auto-apply and notifications (UI/Telegram/SMTP); red auto-apply switch on Wohnungen tab; forensics detail view for bewerbungen and fehler; retention background thread Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
182 lines
9.4 KiB
HTML
182 lines
9.4 KiB
HTML
{% extends "_layout.html" %}
|
||
{% block title %}Wohnungen — lazyflat{% endblock %}
|
||
{% block content %}
|
||
|
||
<!-- Auto-Bewerben + Status-Leiste -->
|
||
<section class="card p-5 flex flex-col md:flex-row md:items-center gap-4 justify-between">
|
||
<div class="flex flex-col gap-1">
|
||
<div class="text-xs uppercase tracking-wide text-slate-500">Auto-Bewerben</div>
|
||
<div class="flex items-center gap-2">
|
||
{% if auto_apply_enabled %}
|
||
<span class="chip chip-warn">an</span>
|
||
<span class="text-sm text-slate-600">bei Match wird automatisch beworben</span>
|
||
{% else %}
|
||
<span class="chip chip-info">aus</span>
|
||
<span class="text-sm text-slate-600">Matches werden nur angezeigt</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-wrap items-center gap-3">
|
||
<!-- der rote Knopf -->
|
||
<form method="post" action="/actions/auto-apply">
|
||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||
<input type="hidden" name="value" value="{% if auto_apply_enabled %}off{% else %}on{% endif %}">
|
||
<button class="btn btn-hot {% if not auto_apply_enabled %}off{% endif %}"
|
||
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: AN{% else %}AUTO-BEWERBEN AKTIVIEREN{% endif %}
|
||
</button>
|
||
</form>
|
||
|
||
<!-- kill switch -->
|
||
<form method="post" action="/actions/kill-switch">
|
||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||
<input type="hidden" name="value" value="{% if kill_switch %}off{% else %}on{% endif %}">
|
||
<button class="btn {% if kill_switch %}btn-ghost{% else %}btn-danger{% endif %} text-sm" type="submit">
|
||
{% if kill_switch %}Kill-Switch deaktivieren{% else %}Kill-Switch{% endif %}
|
||
</button>
|
||
</form>
|
||
|
||
{% if circuit_open %}
|
||
<form method="post" action="/actions/reset-circuit">
|
||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||
<button class="btn btn-ghost text-sm" type="submit">Circuit zurücksetzen</button>
|
||
</form>
|
||
{% endif %}
|
||
</div>
|
||
</section>
|
||
|
||
{% if not apply_allowed %}
|
||
<div class="card p-4">
|
||
<span class="chip chip-bad">apply blockiert</span>
|
||
<span class="ml-2 text-sm text-slate-600">{{ apply_block_reason }}</span>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- Status zeile -->
|
||
<section class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||
<div class="card p-3">
|
||
<div class="text-xs text-slate-500">alert</div>
|
||
<div class="mt-1">
|
||
{% if last_alert_heartbeat %}<span class="chip chip-ok">live</span>
|
||
{% else %}<span class="chip chip-warn">kein Heartbeat</span>{% endif %}
|
||
</div>
|
||
</div>
|
||
<div class="card p-3">
|
||
<div class="text-xs text-slate-500">apply</div>
|
||
<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 }}">
|
||
<div>
|
||
<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>
|
||
</details>
|
||
|
||
<!-- Liste aller Wohnungen -->
|
||
<section class="card">
|
||
<div class="flex items-center justify-between px-4 py-3 border-b border-soft">
|
||
<h2 class="font-semibold">Neueste Wohnungen auf inberlinwohnen.de</h2>
|
||
<span class="text-xs text-slate-500">{{ flats|length }} gesamt</span>
|
||
</div>
|
||
<div class="divide-y divide-soft">
|
||
{% for item in flats %}
|
||
{% set f = item.row %}
|
||
<div class="px-4 py-3 flex flex-col md:flex-row md:items-center gap-3 {% if item.matched %}bg-[#f2f8ff]{% endif %}">
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-center gap-2 flex-wrap">
|
||
<a class="font-medium truncate" href="{{ f.link }}" target="_blank" rel="noopener noreferrer">
|
||
{{ f.address or f.link }}
|
||
</a>
|
||
{% 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>
|
||
{% 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 %}
|
||
</div>
|
||
<div class="text-xs text-slate-500 mt-0.5">
|
||
{% if f.rooms %}{{ "%.1f"|format(f.rooms) }} Z{% endif %}
|
||
{% if f.size %} · {{ "%.0f"|format(f.size) }} m²{% endif %}
|
||
{% if f.total_rent %} · {{ "%.0f"|format(f.total_rent) }} €{% endif %}
|
||
{% if f.sqm_price %} ({{ "%.2f"|format(f.sqm_price) }} €/m²){% endif %}
|
||
{% if f.connectivity_morning_time %} · {{ "%.0f"|format(f.connectivity_morning_time) }} min morgens{% endif %}
|
||
{% if f.wbs %} · WBS: {{ f.wbs }}{% endif %}
|
||
· entdeckt {{ f.discovered_at }}
|
||
</div>
|
||
{% if item.last and item.last.message %}
|
||
<div class="text-xs text-slate-500 mt-1 truncate">↳ {{ item.last.message }}</div>
|
||
{% endif %}
|
||
</div>
|
||
<div class="flex gap-2">
|
||
{% if apply_allowed and not (item.last and item.last.success == 1) %}
|
||
<form method="post" action="/actions/apply">
|
||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||
<input type="hidden" name="flat_id" value="{{ f.id }}">
|
||
<button class="btn btn-primary text-sm" type="submit"
|
||
onclick="return confirm('Bewerbung für {{ (f.address or f.link)|e }} ausführen?');">
|
||
Bewerben
|
||
</button>
|
||
</form>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
{% else %}
|
||
<div class="px-4 py-8 text-center text-slate-500">Noch keine Wohnungen entdeckt.</div>
|
||
{% endfor %}
|
||
</div>
|
||
</section>
|
||
|
||
{% endblock %}
|