app.py was ~1300 lines with every route, helper, and middleware mixed
together. Split into:
- app.py (~100 lines): FastAPI bootstrap, lifespan, /health, security
headers, Jinja filter registration, include_router calls
- common.py: shared helpers (templates, apply_client, base_context,
_is_htmx, client_ip, require_internal, time helpers, filter helpers,
apply-gate helpers, _kick_apply / _finish_apply_background,
_bg_tasks, _spawn, _mask_secret, _has_running_application, BERLIN_TZ)
- routes/auth.py: /login (GET+POST), /logout
- routes/wohnungen.py: /, /partials/wohnungen, /partials/wohnung/{id},
/flat-images/{slug}/{idx}, /actions/apply|reject|unreject|auto-apply|
submit-forms|reset-circuit|filters|enrich-all|enrich-flat; owns
_wohnungen_context + _wohnungen_partial_or_redirect
- routes/bewerbungen.py: /bewerbungen, /bewerbungen/{id}/report.zip
- routes/einstellungen.py: /einstellungen, /einstellungen/{section},
/actions/profile|notifications|account/password|partner/*; owns
VALID_SECTIONS
- routes/admin.py: /logs redirect, /admin, /admin/{section},
/logs/export.csv, /actions/users/*|secrets; owns ADMIN_SECTIONS,
_parse_date_range, _collect_events
- routes/internal.py: /internal/flats|heartbeat|error|secrets
Route-diff before/after is empty — all 41 routes + /static mount
preserved. No behavior changes, pure mechanical split.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
188 lines
7.9 KiB
Python
188 lines
7.9 KiB
Python
"""Einstellungen (settings) tab: profile, filter info, notifications, partner,
|
|
account, plus the related action endpoints."""
|
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
|
|
import db
|
|
from auth import current_user, hash_password, require_csrf, require_user
|
|
from common import base_context, client_ip, templates
|
|
from matching import row_to_dict
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
VALID_SECTIONS = ("profil", "filter", "benachrichtigungen", "partner", "account")
|
|
|
|
|
|
@router.get("/einstellungen", response_class=HTMLResponse)
|
|
def tab_settings_root(request: Request):
|
|
return RedirectResponse("/einstellungen/profil", status_code=303)
|
|
|
|
|
|
@router.get("/einstellungen/{section}", response_class=HTMLResponse)
|
|
def tab_settings(request: Request, section: str):
|
|
u = current_user(request)
|
|
if not u:
|
|
return RedirectResponse("/login", status_code=303)
|
|
# Benutzer verwaltung lives under /admin/benutzer since the admin tab rework.
|
|
if section == "benutzer":
|
|
return RedirectResponse("/admin/benutzer", status_code=301)
|
|
if section not in VALID_SECTIONS:
|
|
raise HTTPException(404)
|
|
|
|
ctx = base_context(request, u, "einstellungen")
|
|
ctx["section"] = section
|
|
|
|
if section == "profil":
|
|
ctx["profile"] = db.get_profile(u["id"])
|
|
elif section == "filter":
|
|
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)
|
|
|
|
|
|
@router.post("/actions/profile")
|
|
async def action_profile(request: Request, user=Depends(require_user)):
|
|
form = await request.form()
|
|
require_csrf(user["id"], form.get("csrf", ""))
|
|
|
|
def _b(name): return form.get(name, "").lower() in ("true", "on", "yes", "1")
|
|
def _i(name):
|
|
try: return int(form.get(name) or 0)
|
|
except ValueError: return 0
|
|
|
|
# Field names are intentionally opaque ("contact_addr", "immomio_login",
|
|
# "immomio_secret") to keep password managers — specifically Bitwarden —
|
|
# from recognising the form as a login/identity form and autofilling.
|
|
db.update_profile(user["id"], {
|
|
"salutation": form.get("salutation", ""),
|
|
"firstname": form.get("firstname", ""),
|
|
"lastname": form.get("lastname", ""),
|
|
"email": form.get("contact_addr", ""),
|
|
"telephone": form.get("telephone", ""),
|
|
"street": form.get("street", ""),
|
|
"house_number": form.get("house_number", ""),
|
|
"postcode": form.get("postcode", ""),
|
|
"city": form.get("city", ""),
|
|
"is_possessing_wbs": 1 if _b("is_possessing_wbs") else 0,
|
|
"wbs_type": form.get("wbs_type", "0"),
|
|
"wbs_valid_till": form.get("wbs_valid_till", "1970-01-01"),
|
|
"wbs_rooms": _i("wbs_rooms"),
|
|
"wbs_adults": _i("wbs_adults"),
|
|
"wbs_children": _i("wbs_children"),
|
|
"is_prio_wbs": 1 if _b("is_prio_wbs") else 0,
|
|
"immomio_email": form.get("immomio_login", ""),
|
|
"immomio_password": form.get("immomio_secret", ""),
|
|
})
|
|
db.log_audit(user["username"], "profile.updated", user_id=user["id"], ip=client_ip(request))
|
|
return RedirectResponse("/einstellungen/profil", status_code=303)
|
|
|
|
|
|
@router.post("/actions/notifications")
|
|
async def action_notifications(request: Request, user=Depends(require_user)):
|
|
form = await request.form()
|
|
require_csrf(user["id"], form.get("csrf", ""))
|
|
def _b(n): return 1 if form.get(n, "").lower() in ("on", "true", "1", "yes") else 0
|
|
channel = form.get("channel", "ui")
|
|
if channel not in ("ui", "telegram"):
|
|
channel = "ui"
|
|
db.update_notifications(user["id"], {
|
|
"channel": channel,
|
|
"telegram_bot_token": form.get("telegram_bot_token", ""),
|
|
"telegram_chat_id": form.get("telegram_chat_id", ""),
|
|
"notify_on_match": _b("notify_on_match"),
|
|
"notify_on_apply_success": _b("notify_on_apply_success"),
|
|
"notify_on_apply_fail": _b("notify_on_apply_fail"),
|
|
})
|
|
db.log_audit(user["username"], "notifications.updated", user_id=user["id"], ip=client_ip(request))
|
|
return RedirectResponse("/einstellungen/benachrichtigungen", status_code=303)
|
|
|
|
|
|
@router.post("/actions/account/password")
|
|
async def action_password(
|
|
request: Request,
|
|
old_password: str = Form(""),
|
|
new_password: str = Form(""),
|
|
new_password_repeat: str = Form(""),
|
|
csrf: str = Form(...),
|
|
user=Depends(require_user),
|
|
):
|
|
require_csrf(user["id"], csrf)
|
|
if not new_password or new_password != new_password_repeat:
|
|
return RedirectResponse("/einstellungen/account?err=mismatch", status_code=303)
|
|
if len(new_password) < 10:
|
|
return RedirectResponse("/einstellungen/account?err=tooshort", status_code=303)
|
|
row = db.get_user_by_username(user["username"])
|
|
from auth import verify_hash
|
|
if not row or not verify_hash(row["password_hash"], old_password):
|
|
return RedirectResponse("/einstellungen/account?err=wrongold", status_code=303)
|
|
db.set_user_password(user["id"], hash_password(new_password))
|
|
db.log_audit(user["username"], "password.changed", user_id=user["id"], ip=client_ip(request))
|
|
return RedirectResponse("/einstellungen/account?ok=1", status_code=303)
|
|
|
|
|
|
@router.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)
|
|
|
|
|
|
@router.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)
|
|
|
|
|
|
@router.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)
|
|
|
|
|
|
@router.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)
|