multi-user: users, per-user profiles/filters/notifications, tab UI, apply forensics

* DB: users + user_profiles/filters/notifications/preferences; applications gets
  user_id + forensics_json + profile_snapshot_json; new errors table
  with 14d retention; schema versioning via MIGRATIONS list
* auth: password hashes in DB (argon2); env vars seed first admin; per-user
  sessions; CSRF bound to user id
* apply: personal info/WBS moved out of env into the request body; providers
  take an ApplyContext with Profile + submit_forms; full Playwright recorder
  (step log, console, page errors, network, screenshots, final HTML)
* web: five top-level tabs (Wohnungen/Bewerbungen/Logs/Fehler/Einstellungen);
  settings sub-tabs profil/filter/benachrichtigungen/account/benutzer;
  per-user matching, auto-apply and notifications (UI/Telegram/SMTP); red
  auto-apply switch on Wohnungen tab; forensics detail view for bewerbungen
  and fehler; retention background thread

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Moritz 2026-04-21 10:52:41 +02:00
parent e663386a19
commit c630b500ef
36 changed files with 2763 additions and 1113 deletions

66
apply/classes/profile.py Normal file
View file

@ -0,0 +1,66 @@
"""Applicant profile passed from web → apply on each request."""
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class Profile:
salutation: str = "Herr"
firstname: str = ""
lastname: str = ""
email: str = ""
telephone: str = ""
street: str = ""
house_number: str = ""
postcode: str = ""
city: str = ""
# WBS
is_possessing_wbs: bool = False
wbs_type: str = "0"
wbs_valid_till: str = "1970-01-01" # ISO date string
wbs_rooms: int = 0
wbs_adults: int = 0
wbs_children: int = 0
is_prio_wbs: bool = False
# optional: immomio login for providers that need it
immomio_email: str = ""
immomio_password: str = ""
@property
def person_count(self) -> int:
return self.wbs_adults + self.wbs_children
@property
def adult_count(self) -> int:
return self.wbs_adults
@property
def children_count(self) -> int:
return self.wbs_children
def wbs_valid_till_dt(self) -> datetime:
try:
return datetime.strptime(self.wbs_valid_till, "%Y-%m-%d")
except ValueError:
return datetime(1970, 1, 1)
@classmethod
def from_dict(cls, d: dict) -> "Profile":
safe = {k: d.get(k) for k in cls.__dataclass_fields__.keys() if k in d and d[k] is not None}
# normalise booleans + ints
for k in ("is_possessing_wbs", "is_prio_wbs"):
if k in safe:
v = safe[k]
if isinstance(v, str):
safe[k] = v.lower() in ("true", "1", "yes", "on")
else:
safe[k] = bool(v)
for k in ("wbs_rooms", "wbs_adults", "wbs_children"):
if k in safe:
try:
safe[k] = int(safe[k])
except (TypeError, ValueError):
safe[k] = 0
return cls(**safe)