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:
EiSiMo 2026-04-21 18:18:24 +02:00
parent 3bb04210c4
commit a212dff4d9
8 changed files with 379 additions and 166 deletions

View file

@ -432,6 +432,23 @@ def _wohnungen_context(user) -> dict:
rejected_view = db.rejected_flats(uid)
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()
alert_label, alert_chip = _alert_status(notif_row)
has_running = _has_running_application(uid)
@ -467,6 +484,7 @@ def _wohnungen_context(user) -> dict:
"flats": flats_view,
"rejected_flats": rejected_view,
"enrichment_counts": enrichment_counts,
"partner": partner_info,
"map_points": map_points,
"has_filters": _has_filters(filters_row),
"alert_label": alert_label,
@ -676,21 +694,6 @@ def tab_bewerbungen(request: Request):
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")
def bewerbung_zip(request: Request, app_id: int):
u = current_user(request)
@ -912,7 +915,7 @@ def tab_logs_export(request: Request):
# Tab: Einstellungen (sub-tabs)
# ---------------------------------------------------------------------------
VALID_SECTIONS = ("profil", "filter", "benachrichtigungen", "account")
VALID_SECTIONS = ("profil", "filter", "benachrichtigungen", "partner", "account")
@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"]))
elif section == "benachrichtigungen":
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)
@ -980,6 +989,66 @@ async def action_profile(request: Request, user=Depends(require_user)):
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")
async def action_notifications(request: Request, user=Depends(require_user)):
form = await request.form()