lazyflat/apply/main.py
Moritz c630b500ef 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>
2026-04-21 10:52:41 +02:00

127 lines
4 KiB
Python

import logging
from contextlib import asynccontextmanager
from urllib.parse import urlparse
from fastapi import Depends, FastAPI, Header, HTTPException, status
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 providers._provider import ApplyContext
from settings import INTERNAL_API_KEY
def setup_logging():
logging.basicConfig(
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=503, detail="INTERNAL_API_KEY not configured")
if x_internal_api_key != INTERNAL_API_KEY:
raise HTTPException(status_code=401, detail="invalid api key")
@asynccontextmanager
async def lifespan(_app: FastAPI):
logger.info("apply ready, providers: %s", sorted(providers.PROVIDERS))
yield
app = FastAPI(lifespan=lifespan, title="lazyflat-apply", docs_url=None, redoc_url=None)
@app.get("/health")
def health():
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("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:
recorder.step("unsupported_provider", "warn", domain)
result = ApplicationResult(False, message=_("unsupported_association"))
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:
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("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),
provider=provider.domain,
application_id=req.application_id,
forensics=recorder.to_json(),
)