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

146
web/db.py
View file

@ -214,6 +214,22 @@ MIGRATIONS: list[str] = [
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
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# 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:
cutoff = (datetime.now(timezone.utc) - timedelta(days=RETENTION_DAYS)).isoformat(timespec="seconds")
stats = {}