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

@ -3,81 +3,125 @@ from contextlib import asynccontextmanager
from urllib.parse import urlparse
from fastapi import Depends, FastAPI, Header, HTTPException, status
from pydantic import BaseModel
from pydantic import BaseModel, Field
from rich.console import Console
from rich.logging import RichHandler
import providers
from actions import Recorder
from classes.application_result import ApplicationResult
from classes.profile import Profile
from language import _
from settings import INTERNAL_API_KEY, log_settings
from providers._provider import ApplyContext
from settings import INTERNAL_API_KEY
def setup_logging():
logging.basicConfig(
level=logging.WARNING,
format="%(message)s",
datefmt="[%X]",
handlers=[RichHandler(markup=True, console=Console(width=110))],
level=logging.INFO,
format="%(asctime)s %(levelname)-5s %(name)s: %(message)s",
datefmt="%H:%M:%S",
handlers=[RichHandler(markup=False, console=Console(width=140), show_time=False, show_path=False)],
)
logging.getLogger("flat-apply").setLevel(logging.DEBUG)
logging.getLogger("playwright").setLevel(logging.INFO)
logger = logging.getLogger("flat-apply")
setup_logging()
class ProfileModel(BaseModel):
salutation: str = "Herr"
firstname: str = ""
lastname: str = ""
email: str = ""
telephone: str = ""
street: str = ""
house_number: str = ""
postcode: str = ""
city: str = ""
is_possessing_wbs: bool = False
wbs_type: str = "0"
wbs_valid_till: str = "1970-01-01"
wbs_rooms: int = 0
wbs_adults: int = 0
wbs_children: int = 0
is_prio_wbs: bool = False
immomio_email: str = ""
immomio_password: str = ""
class ApplyRequest(BaseModel):
url: str
profile: ProfileModel
submit_forms: bool = False
application_id: int | None = None # echoed back in logs
class ApplyResponse(BaseModel):
success: bool
message: str
provider: str
application_id: int | None = None
forensics: dict
def require_api_key(x_internal_api_key: str | None = Header(default=None)) -> None:
if not INTERNAL_API_KEY:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="apply service has no INTERNAL_API_KEY configured",
)
raise HTTPException(status_code=503, detail="INTERNAL_API_KEY not configured")
if x_internal_api_key != INTERNAL_API_KEY:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid api key")
raise HTTPException(status_code=401, detail="invalid api key")
@asynccontextmanager
async def lifespan(_app: FastAPI):
log_settings()
logger.info(f"apply ready, providers: {sorted(providers.PROVIDERS)}")
logger.info("apply ready, providers: %s", sorted(providers.PROVIDERS))
yield
app = FastAPI(lifespan=lifespan, title="lazyflat-apply")
app = FastAPI(lifespan=lifespan, title="lazyflat-apply", docs_url=None, redoc_url=None)
@app.get("/health")
def health():
return {"status": "ok"}
return {"status": "ok", "providers": sorted(providers.PROVIDERS)}
@app.post("/apply", response_model=ApplyResponse, dependencies=[Depends(require_api_key)])
async def apply(req: ApplyRequest):
url = req.url.strip()
domain = urlparse(url).netloc.lower().removeprefix("www.")
logger.info(f"apply request for domain={domain} url={url}")
logger.info("apply request application_id=%s domain=%s submit=%s",
req.application_id, domain, req.submit_forms)
recorder = Recorder(url)
recorder.step("request.received", detail=f"application_id={req.application_id} domain={domain} submit={req.submit_forms}")
if domain not in providers.PROVIDERS:
logger.warning(f"unsupported provider: {domain}")
recorder.step("unsupported_provider", "warn", domain)
result = ApplicationResult(False, message=_("unsupported_association"))
return ApplyResponse(success=result.success, message=str(result))
return ApplyResponse(
success=False, message=str(result), provider="",
application_id=req.application_id, forensics=recorder.to_json(),
)
provider = providers.PROVIDERS[domain]
profile = Profile.from_dict(req.profile.model_dump())
ctx = ApplyContext(profile=profile, submit_forms=req.submit_forms, recorder=recorder)
try:
provider = providers.PROVIDERS[domain]
result = await provider.apply_for_flat(url)
logger.info(f"application result: {repr(result)}")
result = await provider.apply_for_flat(url, ctx)
logger.info("apply outcome application_id=%s: %r", req.application_id, result)
except Exception as e:
logger.exception("error while applying")
logger.exception("apply crashed application_id=%s", req.application_id)
recorder.step("exception", "error", f"{type(e).__name__}: {e}")
result = ApplicationResult(False, f"Script Error:\n{e}")
return ApplyResponse(success=result.success, message=str(result))
return ApplyResponse(
success=result.success,
message=str(result),
provider=provider.domain,
application_id=req.application_id,
forensics=recorder.to_json(),
)