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

View file

@ -1,22 +1,37 @@
"""Abstract provider interface. Concrete providers live next to this file."""
import asyncio
from abc import ABC, abstractmethod
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from actions import Recorder
from classes.application_result import ApplicationResult
from classes.profile import Profile
logger = logging.getLogger("flat-apply")
@dataclass
class ApplyContext:
"""Per-request state passed into every provider call."""
profile: Profile
submit_forms: bool
recorder: Recorder
class Provider(ABC):
@property
@abstractmethod
def domain(self) -> str:
"""every flat provider needs a domain"""
pass
...
@abstractmethod
async def apply_for_flat(self, url: str) -> ApplicationResult:
"""every flat provider needs to be able to apply for flats"""
pass
async def apply_for_flat(self, url: str, ctx: ApplyContext) -> ApplicationResult:
...
def test_apply(self, url):
print(asyncio.run(self.apply_for_flat(url)))
def test_apply(self, url, profile: Profile | None = None, submit_forms: bool = False):
rec = Recorder(url)
ctx = ApplyContext(profile or Profile(), submit_forms=submit_forms, recorder=rec)
result = asyncio.run(self.apply_for_flat(url, ctx))
print(repr(result))
return result, rec.to_json()