* 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>
127 lines
4 KiB
Python
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(),
|
|
)
|