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
146
web/db.py
146
web/db.py
|
|
@ -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 = {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue