diff --git a/web/app.py b/web/app.py index b2ee03e..f278172 100644 --- a/web/app.py +++ b/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() diff --git a/web/db.py b/web/db.py index 3adb02d..d18c6fe 100644 --- a/web/db.py +++ b/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 = {} diff --git a/web/templates/_settings_partner.html b/web/templates/_settings_partner.html new file mode 100644 index 0000000..a978812 --- /dev/null +++ b/web/templates/_settings_partner.html @@ -0,0 +1,100 @@ +

Partner

+

+ Verknüpfe deinen Account mit einem anderen Benutzer. Ihr seht dann gegenseitig, + welche Wohnungen der/die andere schon beworben oder abgelehnt hat. +

+ +{% if partner_flash %} +
+ {% 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 %} +
+{% endif %} + +{% if partner %} +
+
+
Verknüpft mit
+
+ {{ partner_profile.firstname or partner.username }} + ({{ partner.username }}) +
+
+
+ + +
+
+{% else %} + {% if incoming_requests %} +
+
+ Eingegangene Anfragen +
+
+ {% for r in incoming_requests %} +
+ {{ r.from_username }} +
+
+ + + +
+
+ + + +
+
+
+ {% endfor %} +
+
+ {% endif %} + + {% if outgoing_requests %} +
+
+ Ausgehende Anfragen +
+
+ {% for r in outgoing_requests %} +
+ {{ r.to_username }} (ausstehend) +
+ + + +
+
+ {% endfor %} +
+
+ {% endif %} + +
+

Neue Anfrage

+
+ +
+ + +
+ +
+
+{% endif %} diff --git a/web/templates/_wohnungen_body.html b/web/templates/_wohnungen_body.html index 131cba1..71d6e33 100644 --- a/web/templates/_wohnungen_body.html +++ b/web/templates/_wohnungen_body.html @@ -149,17 +149,25 @@ {% if apply_allowed and not (item.last and item.last.success == 1) %} {% set is_running = item.last and item.last.finished_at is none %}
+ class="btn-with-badge" + hx-post="/actions/apply" hx-target="#wohnungen-body" hx-swap="outerHTML" + hx-disabled-elt="find button"> + {% if partner and f.id in partner.applied_flat_ids %} + {{ partner.initial }} + {% endif %}
{% endif %}
@@ -167,6 +175,10 @@ hx-confirm="Ablehnen und aus der Liste entfernen?"> Ablehnen + {% if partner and f.id in partner.rejected_flat_ids %} + {{ partner.initial }} + {% endif %}