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
101
web/app.py
101
web/app.py
|
|
@ -432,6 +432,23 @@ def _wohnungen_context(user) -> dict:
|
||||||
rejected_view = db.rejected_flats(uid)
|
rejected_view = db.rejected_flats(uid)
|
||||||
enrichment_counts = db.enrichment_counts()
|
enrichment_counts = db.enrichment_counts()
|
||||||
|
|
||||||
|
partner = db.get_partner_user(uid)
|
||||||
|
partner_info = None
|
||||||
|
if partner:
|
||||||
|
partner_profile = db.get_profile(partner["id"])
|
||||||
|
initial = ((partner_profile["firstname"] if partner_profile else "")
|
||||||
|
or partner["username"] or "?")[:1].upper()
|
||||||
|
display_name = (partner_profile["firstname"]
|
||||||
|
if partner_profile and partner_profile["firstname"]
|
||||||
|
else partner["username"])
|
||||||
|
actions = db.partner_flat_actions(partner["id"])
|
||||||
|
partner_info = {
|
||||||
|
"initial": initial,
|
||||||
|
"name": display_name,
|
||||||
|
"applied_flat_ids": actions["applied"],
|
||||||
|
"rejected_flat_ids": actions["rejected"],
|
||||||
|
}
|
||||||
|
|
||||||
allowed, reason = _manual_apply_allowed()
|
allowed, reason = _manual_apply_allowed()
|
||||||
alert_label, alert_chip = _alert_status(notif_row)
|
alert_label, alert_chip = _alert_status(notif_row)
|
||||||
has_running = _has_running_application(uid)
|
has_running = _has_running_application(uid)
|
||||||
|
|
@ -467,6 +484,7 @@ def _wohnungen_context(user) -> dict:
|
||||||
"flats": flats_view,
|
"flats": flats_view,
|
||||||
"rejected_flats": rejected_view,
|
"rejected_flats": rejected_view,
|
||||||
"enrichment_counts": enrichment_counts,
|
"enrichment_counts": enrichment_counts,
|
||||||
|
"partner": partner_info,
|
||||||
"map_points": map_points,
|
"map_points": map_points,
|
||||||
"has_filters": _has_filters(filters_row),
|
"has_filters": _has_filters(filters_row),
|
||||||
"alert_label": alert_label,
|
"alert_label": alert_label,
|
||||||
|
|
@ -676,21 +694,6 @@ def tab_bewerbungen(request: Request):
|
||||||
return templates.TemplateResponse("bewerbungen.html", ctx)
|
return templates.TemplateResponse("bewerbungen.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/bewerbungen/{app_id}", response_class=HTMLResponse)
|
|
||||||
def bewerbung_detail(request: Request, app_id: int):
|
|
||||||
u = current_user(request)
|
|
||||||
if not u:
|
|
||||||
return RedirectResponse("/login", status_code=303)
|
|
||||||
a = db.get_application(app_id)
|
|
||||||
if not a or (a["user_id"] != u["id"] and not u["is_admin"]):
|
|
||||||
raise HTTPException(404, "not found")
|
|
||||||
forensics = json.loads(a["forensics_json"]) if a["forensics_json"] else None
|
|
||||||
profile = json.loads(a["profile_snapshot_json"]) if a["profile_snapshot_json"] else {}
|
|
||||||
ctx = base_context(request, u, "bewerbungen")
|
|
||||||
ctx.update({"application": a, "forensics": forensics, "profile_snapshot": profile})
|
|
||||||
return templates.TemplateResponse("bewerbung_detail.html", ctx)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/bewerbungen/{app_id}/report.zip")
|
@app.get("/bewerbungen/{app_id}/report.zip")
|
||||||
def bewerbung_zip(request: Request, app_id: int):
|
def bewerbung_zip(request: Request, app_id: int):
|
||||||
u = current_user(request)
|
u = current_user(request)
|
||||||
|
|
@ -912,7 +915,7 @@ def tab_logs_export(request: Request):
|
||||||
# Tab: Einstellungen (sub-tabs)
|
# Tab: Einstellungen (sub-tabs)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
VALID_SECTIONS = ("profil", "filter", "benachrichtigungen", "account")
|
VALID_SECTIONS = ("profil", "filter", "benachrichtigungen", "partner", "account")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/einstellungen", response_class=HTMLResponse)
|
@app.get("/einstellungen", response_class=HTMLResponse)
|
||||||
|
|
@ -940,6 +943,12 @@ 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 == "partner":
|
||||||
|
ctx["partner"] = db.get_partner_user(u["id"])
|
||||||
|
ctx["partner_profile"] = db.get_profile(ctx["partner"]["id"]) if ctx["partner"] else None
|
||||||
|
ctx["incoming_requests"] = db.partnership_incoming(u["id"])
|
||||||
|
ctx["outgoing_requests"] = db.partnership_outgoing(u["id"])
|
||||||
|
ctx["partner_flash"] = request.query_params.get("flash") or ""
|
||||||
return templates.TemplateResponse("einstellungen.html", ctx)
|
return templates.TemplateResponse("einstellungen.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -980,6 +989,66 @@ async def action_profile(request: Request, user=Depends(require_user)):
|
||||||
return RedirectResponse("/einstellungen/profil", status_code=303)
|
return RedirectResponse("/einstellungen/profil", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/actions/partner/request")
|
||||||
|
async def action_partner_request(
|
||||||
|
request: Request,
|
||||||
|
partner_username: str = Form(...),
|
||||||
|
csrf: str = Form(...),
|
||||||
|
user=Depends(require_user),
|
||||||
|
):
|
||||||
|
require_csrf(user["id"], csrf)
|
||||||
|
target = db.get_user_by_username((partner_username or "").strip())
|
||||||
|
if not target or target["id"] == user["id"]:
|
||||||
|
return RedirectResponse("/einstellungen/partner?flash=nouser", status_code=303)
|
||||||
|
req_id = db.partnership_request(user["id"], target["id"])
|
||||||
|
if req_id is None:
|
||||||
|
return RedirectResponse("/einstellungen/partner?flash=exists", status_code=303)
|
||||||
|
db.log_audit(user["username"], "partner.requested",
|
||||||
|
f"target={target['username']}", user_id=user["id"], ip=client_ip(request))
|
||||||
|
return RedirectResponse("/einstellungen/partner?flash=sent", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/actions/partner/accept")
|
||||||
|
async def action_partner_accept(
|
||||||
|
request: Request,
|
||||||
|
request_id: int = Form(...),
|
||||||
|
csrf: str = Form(...),
|
||||||
|
user=Depends(require_user),
|
||||||
|
):
|
||||||
|
require_csrf(user["id"], csrf)
|
||||||
|
if not db.partnership_accept(request_id, user["id"]):
|
||||||
|
return RedirectResponse("/einstellungen/partner?flash=accept_failed", status_code=303)
|
||||||
|
db.log_audit(user["username"], "partner.accepted",
|
||||||
|
f"request={request_id}", user_id=user["id"], ip=client_ip(request))
|
||||||
|
return RedirectResponse("/einstellungen/partner?flash=accepted", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/actions/partner/decline")
|
||||||
|
async def action_partner_decline(
|
||||||
|
request: Request,
|
||||||
|
request_id: int = Form(...),
|
||||||
|
csrf: str = Form(...),
|
||||||
|
user=Depends(require_user),
|
||||||
|
):
|
||||||
|
require_csrf(user["id"], csrf)
|
||||||
|
db.partnership_decline(request_id, user["id"])
|
||||||
|
db.log_audit(user["username"], "partner.declined",
|
||||||
|
f"request={request_id}", user_id=user["id"], ip=client_ip(request))
|
||||||
|
return RedirectResponse("/einstellungen/partner?flash=declined", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/actions/partner/unlink")
|
||||||
|
async def action_partner_unlink(
|
||||||
|
request: Request,
|
||||||
|
csrf: str = Form(...),
|
||||||
|
user=Depends(require_user),
|
||||||
|
):
|
||||||
|
require_csrf(user["id"], csrf)
|
||||||
|
db.partnership_unlink(user["id"])
|
||||||
|
db.log_audit(user["username"], "partner.unlinked", user_id=user["id"], ip=client_ip(request))
|
||||||
|
return RedirectResponse("/einstellungen/partner?flash=unlinked", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/actions/notifications")
|
@app.post("/actions/notifications")
|
||||||
async def action_notifications(request: Request, user=Depends(require_user)):
|
async def action_notifications(request: Request, user=Depends(require_user)):
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
|
|
|
||||||
146
web/db.py
146
web/db.py
|
|
@ -214,6 +214,22 @@ MIGRATIONS: list[str] = [
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
""",
|
""",
|
||||||
|
# 0008: partnerships — pair up two user accounts so each sees the other's
|
||||||
|
# apply/reject interactions in the flat list.
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS partnerships (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'accepted'
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
accepted_at TEXT,
|
||||||
|
UNIQUE(from_user_id, to_user_id),
|
||||||
|
CHECK(from_user_id != to_user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_partnerships_from ON partnerships(from_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_partnerships_to ON partnerships(to_user_id);
|
||||||
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -716,6 +732,136 @@ def recent_audit(user_id: Optional[int], limit: int = 100) -> list[sqlite3.Row]:
|
||||||
# Retention cleanup
|
# Retention cleanup
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Partnerships
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_accepted_partnership(user_id: int) -> Optional[sqlite3.Row]:
|
||||||
|
return _conn.execute(
|
||||||
|
"""SELECT * FROM partnerships
|
||||||
|
WHERE status = 'accepted'
|
||||||
|
AND (from_user_id = ? OR to_user_id = ?) LIMIT 1""",
|
||||||
|
(user_id, user_id),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def get_partner_user(user_id: int) -> Optional[sqlite3.Row]:
|
||||||
|
row = get_accepted_partnership(user_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
other = row["to_user_id"] if row["from_user_id"] == user_id else row["from_user_id"]
|
||||||
|
return get_user(other)
|
||||||
|
|
||||||
|
|
||||||
|
def partnership_incoming(user_id: int) -> list[sqlite3.Row]:
|
||||||
|
return list(_conn.execute(
|
||||||
|
"""SELECT p.*, u.username AS from_username
|
||||||
|
FROM partnerships p JOIN users u ON u.id = p.from_user_id
|
||||||
|
WHERE p.status = 'pending' AND p.to_user_id = ?
|
||||||
|
ORDER BY p.created_at DESC""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
def partnership_outgoing(user_id: int) -> list[sqlite3.Row]:
|
||||||
|
return list(_conn.execute(
|
||||||
|
"""SELECT p.*, u.username AS to_username
|
||||||
|
FROM partnerships p JOIN users u ON u.id = p.to_user_id
|
||||||
|
WHERE p.status = 'pending' AND p.from_user_id = ?
|
||||||
|
ORDER BY p.created_at DESC""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
def partnership_request(from_id: int, to_id: int) -> Optional[int]:
|
||||||
|
"""Create a pending request. Returns id or None if rejected (self, already
|
||||||
|
linked on either side, or a pending/accepted row already exists)."""
|
||||||
|
if from_id == to_id:
|
||||||
|
return None
|
||||||
|
if get_accepted_partnership(from_id) or get_accepted_partnership(to_id):
|
||||||
|
return None
|
||||||
|
with _lock:
|
||||||
|
# Reject duplicate in either direction.
|
||||||
|
dup = _conn.execute(
|
||||||
|
"""SELECT id FROM partnerships
|
||||||
|
WHERE (from_user_id = ? AND to_user_id = ?)
|
||||||
|
OR (from_user_id = ? AND to_user_id = ?)""",
|
||||||
|
(from_id, to_id, to_id, from_id),
|
||||||
|
).fetchone()
|
||||||
|
if dup:
|
||||||
|
return None
|
||||||
|
cur = _conn.execute(
|
||||||
|
"INSERT INTO partnerships(from_user_id, to_user_id, status, created_at) "
|
||||||
|
"VALUES (?, ?, 'pending', ?)",
|
||||||
|
(from_id, to_id, now_iso()),
|
||||||
|
)
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def partnership_accept(request_id: int, user_id: int) -> bool:
|
||||||
|
"""Accept a pending request addressed to user_id. Also wipes any other
|
||||||
|
pending rows involving either partner."""
|
||||||
|
row = _conn.execute(
|
||||||
|
"SELECT * FROM partnerships WHERE id = ? AND status = 'pending'",
|
||||||
|
(request_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row or row["to_user_id"] != user_id:
|
||||||
|
return False
|
||||||
|
# Don't allow accept if either side already has an accepted partner.
|
||||||
|
if get_accepted_partnership(row["from_user_id"]) or get_accepted_partnership(user_id):
|
||||||
|
return False
|
||||||
|
partner_id = row["from_user_id"]
|
||||||
|
with _lock:
|
||||||
|
_conn.execute(
|
||||||
|
"UPDATE partnerships SET status = 'accepted', accepted_at = ? WHERE id = ?",
|
||||||
|
(now_iso(), request_id),
|
||||||
|
)
|
||||||
|
# clean up any stale pending requests touching either user
|
||||||
|
_conn.execute(
|
||||||
|
"""DELETE FROM partnerships
|
||||||
|
WHERE status = 'pending'
|
||||||
|
AND (from_user_id IN (?, ?) OR to_user_id IN (?, ?))""",
|
||||||
|
(user_id, partner_id, user_id, partner_id),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def partnership_decline(request_id: int, user_id: int) -> bool:
|
||||||
|
"""Decline an incoming pending request (deletes the row)."""
|
||||||
|
with _lock:
|
||||||
|
cur = _conn.execute(
|
||||||
|
"""DELETE FROM partnerships
|
||||||
|
WHERE id = ? AND status = 'pending'
|
||||||
|
AND (to_user_id = ? OR from_user_id = ?)""",
|
||||||
|
(request_id, user_id, user_id),
|
||||||
|
)
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def partnership_unlink(user_id: int) -> bool:
|
||||||
|
"""Remove the current accepted partnership (either side can call)."""
|
||||||
|
with _lock:
|
||||||
|
cur = _conn.execute(
|
||||||
|
"""DELETE FROM partnerships
|
||||||
|
WHERE status = 'accepted'
|
||||||
|
AND (from_user_id = ? OR to_user_id = ?)""",
|
||||||
|
(user_id, user_id),
|
||||||
|
)
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def partner_flat_actions(partner_id: int) -> dict:
|
||||||
|
"""Flats the partner has touched. 'applied' = any application (regardless
|
||||||
|
of outcome); 'rejected' = in flat_rejections."""
|
||||||
|
applied = {r["flat_id"] for r in _conn.execute(
|
||||||
|
"SELECT DISTINCT flat_id FROM applications WHERE user_id = ?", (partner_id,)
|
||||||
|
).fetchall()}
|
||||||
|
rejected = {r["flat_id"] for r in _conn.execute(
|
||||||
|
"SELECT flat_id FROM flat_rejections WHERE user_id = ?", (partner_id,)
|
||||||
|
).fetchall()}
|
||||||
|
return {"applied": applied, "rejected": rejected}
|
||||||
|
|
||||||
|
|
||||||
def cleanup_retention() -> dict:
|
def cleanup_retention() -> dict:
|
||||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=RETENTION_DAYS)).isoformat(timespec="seconds")
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=RETENTION_DAYS)).isoformat(timespec="seconds")
|
||||||
stats = {}
|
stats = {}
|
||||||
|
|
|
||||||
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) %}
|
{% if apply_allowed and not (item.last and item.last.success == 1) %}
|
||||||
{% set is_running = item.last and item.last.finished_at is none %}
|
{% set is_running = item.last and item.last.finished_at is none %}
|
||||||
<form method="post" action="/actions/apply"
|
<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="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 %}
|
{% 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 %}
|
{% if is_running %}läuft…{% else %}Bewerben{% endif %}
|
||||||
</button>
|
</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>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="/actions/reject"
|
<form method="post" action="/actions/reject"
|
||||||
|
class="btn-with-badge"
|
||||||
hx-post="/actions/reject" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
hx-post="/actions/reject" 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 }}">
|
||||||
|
|
@ -167,6 +175,10 @@
|
||||||
hx-confirm="Ablehnen und aus der Liste entfernen?">
|
hx-confirm="Ablehnen und aus der Liste entfernen?">
|
||||||
Ablehnen
|
Ablehnen
|
||||||
</button>
|
</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>
|
</form>
|
||||||
<button type="button" class="flat-expand-btn" aria-label="Details"
|
<button type="button" class="flat-expand-btn" aria-label="Details"
|
||||||
data-flat-id="{{ f.id }}">
|
data-flat-id="{{ f.id }}">
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,20 @@
|
||||||
.btn { border-radius: 9px; padding: 0.45rem 0.95rem; font-weight: 500;
|
.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; }
|
transition: background 0.15s, box-shadow 0.15s, transform 0.05s; display: inline-block; }
|
||||||
.btn:active { transform: translateY(1px); }
|
.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 { background: var(--primary); color: white; box-shadow: 0 1px 2px rgba(47,138,224,.25); }
|
||||||
.btn-primary:hover { background: var(--primary-hover); }
|
.btn-primary:hover { background: var(--primary-hover); }
|
||||||
.btn-danger { background: var(--danger); color: white; }
|
.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,7 +7,8 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-soft">
|
<div class="divide-y divide-soft">
|
||||||
{% for a in applications %}
|
{% for a in applications %}
|
||||||
<div class="px-4 py-3 text-sm">
|
<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">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
{% if a.success == 1 %}<span class="chip chip-ok">ok</span>
|
{% if a.success == 1 %}<span class="chip chip-ok">ok</span>
|
||||||
{% elif a.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
|
{% elif a.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
|
||||||
|
|
@ -20,14 +21,11 @@
|
||||||
title="{{ a.started_at|de_dt }}">{{ a.started_at|de_dt }}</span>
|
title="{{ a.started_at|de_dt }}">{{ a.started_at|de_dt }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 truncate">
|
<div class="mt-1 truncate">
|
||||||
<a href="/bewerbungen/{{ a.id }}">#{{ a.id }} — {{ a.address or a.url }}</a>
|
#{{ a.id }} — <a href="{{ a.url }}" target="_blank" rel="noopener">{{ a.address or a.url }}</a>
|
||||||
</div>
|
</div>
|
||||||
{% if a.message %}<div class="text-xs text-slate-500 mt-0.5 truncate">{{ a.message }}</div>{% endif %}
|
{% 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>
|
</div>
|
||||||
{% endif %}
|
<a class="btn btn-ghost text-sm" href="/bewerbungen/{{ a.id }}/report.zip">Report (ZIP)</a>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="px-4 py-8 text-center text-slate-500">Noch keine Bewerbungen.</div>
|
<div class="px-4 py-8 text-center text-slate-500">Noch keine Bewerbungen.</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
{% 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">
|
||||||
{% 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 %}
|
{% 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>
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
{% if section == 'profil' %}{% include "_settings_profil.html" %}
|
{% if section == 'profil' %}{% include "_settings_profil.html" %}
|
||||||
{% 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 == 'partner' %}{% include "_settings_partner.html" %}
|
||||||
{% elif section == 'account' %}{% include "_settings_account.html" %}
|
{% elif section == 'account' %}{% include "_settings_account.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue