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)
|
||||
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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue