bewerben UX: instant feedback; drop forensics detail; partner feature
1. Bewerben button: hx-disabled-elt + hx-on::before-request flips the text to "läuft…" and disables the button the moment the confirm is accepted. .btn[disabled] now renders at 55% opacity with not-allowed cursor. Existing 3s poll interval picks up the running state for the chip beside the address. 2. Bewerbungen tab: delete the /bewerbungen/<id> forensics detail page + template entirely. The list now shows a plain "Report (ZIP)" button for every row regardless of success — same download route (/bewerbungen/<id>/report.zip), same visual style. External link to the listing moved onto the address itself. 3. Verified retention: web/retention.py runs cleanup_retention() hourly, which DELETEs errors + audit_log rows older than RETENTION_DAYS (14) and nulls applications.forensics_json for older rows. No code change needed. 4. Partner feature. Migration v8 adds partnerships(from_user_id, to_user_id, status, created_at, accepted_at). Einstellungen → Partner lets users: - send a request by username - accept / decline incoming requests - withdraw outgoing requests - unlink the active partnership A user can only have one accepted partnership; accepting one wipes stale pending rows involving either side. On the Wohnungen list, if the partner has applied to a flat, a small primary-colored circle with the partner's first-name initial sits on the top-right of the Bewerben button; if they've rejected it, the badge sits on Ablehnen. Badge is hover-tooltipped with the partner's name + verb. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3bb04210c4
commit
a212dff4d9
8 changed files with 379 additions and 166 deletions
100
web/templates/_settings_partner.html
Normal file
100
web/templates/_settings_partner.html
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<h2 class="font-semibold mb-2">Partner</h2>
|
||||
<p class="text-sm text-slate-600 mb-4">
|
||||
Verknüpfe deinen Account mit einem anderen Benutzer. Ihr seht dann gegenseitig,
|
||||
welche Wohnungen der/die andere schon beworben oder abgelehnt hat.
|
||||
</p>
|
||||
|
||||
{% if partner_flash %}
|
||||
<div class="chip mb-4
|
||||
{% if partner_flash in ('sent','accepted','declined','unlinked') %}chip-ok
|
||||
{% elif partner_flash in ('nouser','exists','accept_failed') %}chip-bad
|
||||
{% endif %}">
|
||||
{% if partner_flash == 'sent' %}Anfrage gesendet.
|
||||
{% elif partner_flash == 'accepted' %}Partnerschaft aktiv.
|
||||
{% elif partner_flash == 'declined' %}Anfrage abgelehnt.
|
||||
{% elif partner_flash == 'unlinked' %}Partnerschaft beendet.
|
||||
{% elif partner_flash == 'nouser' %}Benutzer nicht gefunden.
|
||||
{% elif partner_flash == 'exists' %}Bereits eine Anfrage oder Verknüpfung vorhanden.
|
||||
{% elif partner_flash == 'accept_failed' %}Annahme fehlgeschlagen.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if partner %}
|
||||
<section class="card p-4 mb-6 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Verknüpft mit</div>
|
||||
<div class="text-sm font-medium">
|
||||
{{ partner_profile.firstname or partner.username }}
|
||||
<span class="text-slate-500">({{ partner.username }})</span>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/actions/partner/unlink"
|
||||
onsubmit="return confirm('Partnerschaft wirklich beenden?');">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<button class="btn btn-danger text-sm" type="submit">Trennen</button>
|
||||
</form>
|
||||
</section>
|
||||
{% else %}
|
||||
{% if incoming_requests %}
|
||||
<section class="card mb-6">
|
||||
<div class="px-4 py-2 border-b border-soft text-xs uppercase tracking-wide text-slate-500">
|
||||
Eingegangene Anfragen
|
||||
</div>
|
||||
<div class="divide-y divide-soft">
|
||||
{% for r in incoming_requests %}
|
||||
<div class="px-4 py-3 flex items-center justify-between gap-3">
|
||||
<span class="text-sm">{{ r.from_username }}</span>
|
||||
<div class="flex gap-2">
|
||||
<form method="post" action="/actions/partner/accept">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<input type="hidden" name="request_id" value="{{ r.id }}">
|
||||
<button class="btn btn-primary text-sm" type="submit">Annehmen</button>
|
||||
</form>
|
||||
<form method="post" action="/actions/partner/decline">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<input type="hidden" name="request_id" value="{{ r.id }}">
|
||||
<button class="btn btn-ghost text-sm" type="submit">Ablehnen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if outgoing_requests %}
|
||||
<section class="card mb-6">
|
||||
<div class="px-4 py-2 border-b border-soft text-xs uppercase tracking-wide text-slate-500">
|
||||
Ausgehende Anfragen
|
||||
</div>
|
||||
<div class="divide-y divide-soft">
|
||||
{% for r in outgoing_requests %}
|
||||
<div class="px-4 py-3 flex items-center justify-between gap-3">
|
||||
<span class="text-sm">{{ r.to_username }} <span class="text-slate-500">(ausstehend)</span></span>
|
||||
<form method="post" action="/actions/partner/decline">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<input type="hidden" name="request_id" value="{{ r.id }}">
|
||||
<button class="btn btn-ghost text-sm" type="submit">Zurückziehen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="card p-4">
|
||||
<h3 class="font-semibold mb-3">Neue Anfrage</h3>
|
||||
<form method="post" action="/actions/partner/request"
|
||||
class="flex items-end gap-3 flex-wrap"
|
||||
autocomplete="off" data-lpignore="true" data-1p-ignore data-bwignore data-form-type="other">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<div class="flex-1 min-w-0">
|
||||
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">Benutzername</label>
|
||||
<input class="input" name="partner_username" required
|
||||
autocomplete="off" data-lpignore="true" data-1p-ignore data-bwignore>
|
||||
</div>
|
||||
<button class="btn btn-primary text-sm" type="submit">Anfrage senden</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
|
@ -149,17 +149,25 @@
|
|||
{% if apply_allowed and not (item.last and item.last.success == 1) %}
|
||||
{% 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">
|
||||
class="btn-with-badge"
|
||||
hx-post="/actions/apply" hx-target="#wohnungen-body" hx-swap="outerHTML"
|
||||
hx-disabled-elt="find button">
|
||||
<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"
|
||||
{% if is_running %}disabled{% endif %}
|
||||
hx-confirm="Bewerbung für {{ (f.address or f.link)|e }} starten?">
|
||||
hx-confirm="Bewerbung für {{ (f.address or f.link)|e }} starten?"
|
||||
hx-on::before-request="this.textContent='läuft…'; this.disabled=true">
|
||||
{% if is_running %}läuft…{% else %}Bewerben{% endif %}
|
||||
</button>
|
||||
{% if partner and f.id in partner.applied_flat_ids %}
|
||||
<span class="partner-badge"
|
||||
title="{{ partner.name }} hat sich bereits beworben">{{ partner.initial }}</span>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/actions/reject"
|
||||
class="btn-with-badge"
|
||||
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 }}">
|
||||
|
|
@ -167,6 +175,10 @@
|
|||
hx-confirm="Ablehnen und aus der Liste entfernen?">
|
||||
Ablehnen
|
||||
</button>
|
||||
{% if partner and f.id in partner.rejected_flat_ids %}
|
||||
<span class="partner-badge"
|
||||
title="{{ partner.name }} hat abgelehnt">{{ partner.initial }}</span>
|
||||
{% endif %}
|
||||
</form>
|
||||
<button type="button" class="flat-expand-btn" aria-label="Details"
|
||||
data-flat-id="{{ f.id }}">
|
||||
|
|
|
|||
|
|
@ -38,6 +38,20 @@
|
|||
.btn { border-radius: 9px; padding: 0.45rem 0.95rem; font-weight: 500;
|
||||
transition: background 0.15s, box-shadow 0.15s, transform 0.05s; display: inline-block; }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn:disabled, .btn[disabled] { opacity: .55; cursor: not-allowed; pointer-events: none; }
|
||||
|
||||
/* Partner-Aktionsbadge — kleiner Kreis mit dem Anfangsbuchstaben oben rechts am Button */
|
||||
.btn-with-badge { position: relative; display: inline-block; }
|
||||
.partner-badge {
|
||||
position: absolute; top: -6px; right: -6px;
|
||||
width: 18px; height: 18px; border-radius: 9999px;
|
||||
background: var(--primary); color: #fff;
|
||||
font-size: 10px; font-weight: 700; line-height: 1;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 1px 2px rgba(16,37,63,.25);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.btn-primary { background: var(--primary); color: white; box-shadow: 0 1px 2px rgba(47,138,224,.25); }
|
||||
.btn-primary:hover { background: var(--primary-hover); }
|
||||
.btn-danger { background: var(--danger); color: white; }
|
||||
|
|
|
|||
|
|
@ -1,127 +0,0 @@
|
|||
{% extends "_layout.html" %}
|
||||
{% block content %}
|
||||
<a href="/bewerbungen" class="text-sm">← zurück zu den Bewerbungen</a>
|
||||
|
||||
<section class="card p-5">
|
||||
<div class="flex items-center gap-2 flex-wrap mb-3">
|
||||
<h2 class="font-semibold text-lg">Bewerbung #{{ application.id }}</h2>
|
||||
{% if application.success == 1 %}<span class="chip chip-ok">erfolgreich</span>
|
||||
{% elif application.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
|
||||
{% else %}<span class="chip chip-warn">läuft</span>{% endif %}
|
||||
<span class="chip chip-info">{% if application.triggered_by == 'auto' %}automatisch{% else %}manuell{% endif %}</span>
|
||||
{% if application.provider %}<span class="chip chip-info">{{ application.provider }}</span>{% endif %}
|
||||
{% if application.submit_forms_used %}<span class="chip chip-warn">echt gesendet</span>
|
||||
{% else %}<span class="chip chip-info">nicht abgeschickt</span>{% endif %}
|
||||
|
||||
{% if application.success == 0 %}
|
||||
<a class="btn btn-danger text-sm ml-auto"
|
||||
href="/bewerbungen/{{ application.id }}/report.zip">
|
||||
Fehler-Report herunterladen (ZIP)
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-sm text-slate-600 space-y-1">
|
||||
<div><span class="text-slate-500">URL:</span> <a href="{{ application.url }}" target="_blank" rel="noopener">{{ application.url }}</a></div>
|
||||
<div><span class="text-slate-500">gestartet:</span> {{ application.started_at|de_dt }}</div>
|
||||
<div><span class="text-slate-500">beendet:</span> {{ application.finished_at|de_dt if application.finished_at else "—" }}</div>
|
||||
{% if application.message %}<div><span class="text-slate-500">Meldung:</span> {{ application.message }}</div>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card p-5">
|
||||
<details>
|
||||
<summary class="font-semibold">Profil-Snapshot zum Bewerbungszeitpunkt</summary>
|
||||
<pre class="mono whitespace-pre-wrap break-all mt-3 p-3 bg-[#f6fafd] rounded-lg border border-soft">{{ profile_snapshot | tojson(indent=2) }}</pre>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
{% if forensics %}
|
||||
<section class="card p-5 space-y-4">
|
||||
<h3 class="font-semibold">Forensik</h3>
|
||||
|
||||
<details open>
|
||||
<summary class="font-medium">Step-Log ({{ forensics.steps|length }} Einträge, {{ forensics.duration_s }} s)</summary>
|
||||
<div class="mono mt-2 space-y-0.5">
|
||||
{% for s in forensics.steps %}
|
||||
<div class="{% if s.status != 'ok' %}text-[#b8404e]{% else %}text-slate-700{% endif %}">
|
||||
[{{ "%.2f"|format(s.ts) }}s] {{ s.step }} {% if s.status != 'ok' %}({{ s.status }}){% endif %}
|
||||
{% if s.detail %}— {{ s.detail }}{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{% if forensics.screenshots %}
|
||||
<details>
|
||||
<summary class="font-medium">Momentaufnahmen ({{ forensics.screenshots|length }})</summary>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
|
||||
{% for s in forensics.screenshots %}
|
||||
<div class="border border-soft rounded-lg p-2 space-y-2">
|
||||
<div class="text-xs text-slate-500">{{ s.label }} @ {{ "%.2f"|format(s.ts) }}s — {{ s.url }}</div>
|
||||
{% if s.b64_jpeg %}
|
||||
<img src="data:image/jpeg;base64,{{ s.b64_jpeg }}" class="w-full rounded" alt="{{ s.label }}">
|
||||
{% endif %}
|
||||
{% if s.html %}
|
||||
<details>
|
||||
<summary class="text-xs text-slate-500">Quellcode anzeigen ({{ s.html_size }} B)</summary>
|
||||
<pre class="mono whitespace-pre-wrap break-all mt-2 p-2 bg-[#f6fafd] rounded border border-soft max-h-72 overflow-auto">{{ s.html }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% if forensics.console %}
|
||||
<details>
|
||||
<summary class="font-medium">Browser-Konsole ({{ forensics.console|length }})</summary>
|
||||
<div class="mono mt-2 space-y-0.5">
|
||||
{% for c in forensics.console %}
|
||||
<div class="text-slate-700">[{{ "%.2f"|format(c.ts) }}s] [{{ c.type }}] {{ c.text }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% if forensics.errors %}
|
||||
<details open>
|
||||
<summary class="font-medium text-[#b8404e]">Browser-Errors ({{ forensics.errors|length }})</summary>
|
||||
<div class="mono mt-2 space-y-0.5 text-[#b8404e]">
|
||||
{% for e in forensics.errors %}
|
||||
<div>[{{ "%.2f"|format(e.ts) }}s] {{ e.message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% if forensics.network %}
|
||||
<details>
|
||||
<summary class="font-medium">Netzwerk ({{ forensics.network|length }})</summary>
|
||||
<div class="mono mt-2 space-y-0.5">
|
||||
{% for n in forensics.network %}
|
||||
<div class="text-slate-700 break-all">
|
||||
[{{ "%.2f"|format(n.ts) }}s]
|
||||
{% if n.kind == 'response' %}← {{ n.status }} {{ n.url }}
|
||||
{% if n.body_snippet %}<div class="pl-4 text-slate-500">{{ n.body_snippet[:500] }}</div>{% endif %}
|
||||
{% else %}→ {{ n.method }} {{ n.url }} [{{ n.resource_type }}]
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% if forensics.final_html %}
|
||||
<details>
|
||||
<summary class="font-medium">Page HTML ({{ forensics.final_html|length }} B)</summary>
|
||||
<pre class="mono whitespace-pre-wrap break-all mt-3 p-3 bg-[#f6fafd] rounded-lg border border-soft max-h-96 overflow-auto">{{ forensics.final_html }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% elif application.finished_at %}
|
||||
<section class="card p-5 text-sm text-slate-500">
|
||||
Forensik für diese Bewerbung ist nicht mehr verfügbar (älter als Retention-Zeitraum).
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -7,27 +7,25 @@
|
|||
</div>
|
||||
<div class="divide-y divide-soft">
|
||||
{% for a in applications %}
|
||||
<div class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{% if a.success == 1 %}<span class="chip chip-ok">ok</span>
|
||||
{% elif a.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
|
||||
{% else %}<span class="chip chip-warn">läuft</span>{% endif %}
|
||||
<span class="chip chip-info">{% if a.triggered_by == 'auto' %}automatisch{% else %}manuell{% endif %}</span>
|
||||
{% if a.provider %}<span class="chip chip-info">{{ a.provider }}</span>{% endif %}
|
||||
{% if a.submit_forms_used %}<span class="chip chip-warn">echt gesendet</span>
|
||||
{% else %}<span class="chip chip-info">nicht abgeschickt</span>{% endif %}
|
||||
<span class="text-slate-500 text-xs ml-auto"
|
||||
title="{{ a.started_at|de_dt }}">{{ a.started_at|de_dt }}</span>
|
||||
<div class="px-4 py-3 text-sm flex flex-col md:flex-row md:items-center gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{% if a.success == 1 %}<span class="chip chip-ok">ok</span>
|
||||
{% elif a.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
|
||||
{% else %}<span class="chip chip-warn">läuft</span>{% endif %}
|
||||
<span class="chip chip-info">{% if a.triggered_by == 'auto' %}automatisch{% else %}manuell{% endif %}</span>
|
||||
{% if a.provider %}<span class="chip chip-info">{{ a.provider }}</span>{% endif %}
|
||||
{% if a.submit_forms_used %}<span class="chip chip-warn">echt gesendet</span>
|
||||
{% else %}<span class="chip chip-info">nicht abgeschickt</span>{% endif %}
|
||||
<span class="text-slate-500 text-xs ml-auto"
|
||||
title="{{ a.started_at|de_dt }}">{{ a.started_at|de_dt }}</span>
|
||||
</div>
|
||||
<div class="mt-1 truncate">
|
||||
#{{ a.id }} — <a href="{{ a.url }}" target="_blank" rel="noopener">{{ a.address or a.url }}</a>
|
||||
</div>
|
||||
{% if a.message %}<div class="text-xs text-slate-500 mt-0.5 truncate">{{ a.message }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="mt-1 truncate">
|
||||
<a href="/bewerbungen/{{ a.id }}">#{{ a.id }} — {{ a.address or a.url }}</a>
|
||||
</div>
|
||||
{% if a.message %}<div class="text-xs text-slate-500 mt-0.5 truncate">{{ a.message }}</div>{% endif %}
|
||||
{% if a.success == 0 %}
|
||||
<div class="mt-1">
|
||||
<a class="text-xs" href="/bewerbungen/{{ a.id }}/report.zip">↓ Fehler-Report (ZIP)</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<a class="btn btn-ghost text-sm" href="/bewerbungen/{{ a.id }}/report.zip">Report (ZIP)</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-4 py-8 text-center text-slate-500">Noch keine Bewerbungen.</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
{% block content %}
|
||||
<section class="card">
|
||||
<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'),('partner','Partner'),('account','Account')] %}
|
||||
{% for key, label in sections %}
|
||||
<a href="/einstellungen/{{ key }}"
|
||||
class="tab {% if section == key %}active{% endif %}">{{ label }}</a>
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
{% if section == 'profil' %}{% include "_settings_profil.html" %}
|
||||
{% elif section == 'filter' %}{% include "_settings_filter.html" %}
|
||||
{% elif section == 'benachrichtigungen' %}{% include "_settings_notifications.html" %}
|
||||
{% elif section == 'partner' %}{% include "_settings_partner.html" %}
|
||||
{% elif section == 'account' %}{% include "_settings_account.html" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue