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:
parent
e663386a19
commit
c630b500ef
36 changed files with 2763 additions and 1113 deletions
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue