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,38 +1,198 @@
"""
Playwright actions + forensic recorder.
The recorder captures everything a downstream AI agent would need to diagnose
a broken application flow:
* a structured `step_log` (one entry per `recorder.step(...)`)
* browser console logs
* browser errors
* every network request + selective response bodies
* page HTML at finalize time
* screenshots at key moments
Payloads are capped so SQLite stays healthy. Screenshots are base64 JPEGs.
"""
import asyncio
import base64
import logging
import time
from contextlib import asynccontextmanager
from playwright.async_api import async_playwright, ViewportSize
from typing import Optional
from playwright.async_api import ViewportSize, async_playwright
from reportlab.pdfgen import canvas
from settings import *
import logging
from settings import BROWSER_HEIGHT, BROWSER_LOCALE, BROWSER_WIDTH, HEADLESS, POST_SUBMISSION_SLEEP_MS
logger = logging.getLogger("flat-apply")
MAX_CONSOLE_ENTRIES = 200
MAX_NETWORK_ENTRIES = 150
MAX_BODY_SNIPPET = 2000
MAX_HTML_DUMP = 200_000 # 200 KB
SCREENSHOT_JPEG_QUALITY = 60
class Recorder:
"""Captures browser + step telemetry for one apply run."""
def __init__(self, url: str):
self.started_at = time.time()
self.url = url
self.steps: list[dict] = []
self.console: list[dict] = []
self.errors: list[dict] = []
self.network: list[dict] = []
self.screenshots: list[dict] = []
self.final_html: Optional[str] = None
self.final_url: Optional[str] = None
# --- step log -----------------------------------------------------------
def step(self, step_name: str, status: str = "ok", detail: str = "") -> None:
entry = {
"ts": round(time.time() - self.started_at, 3),
"step": step_name,
"status": status,
"detail": str(detail)[:500],
}
self.steps.append(entry)
log = logger.info if status == "ok" else logger.warning
log("step %-20s %-4s %s", step_name, status, detail)
# --- browser hooks ------------------------------------------------------
def _attach(self, page) -> None:
def on_console(msg):
try:
text = msg.text
except Exception:
text = "<unavailable>"
if len(self.console) < MAX_CONSOLE_ENTRIES:
self.console.append({
"ts": round(time.time() - self.started_at, 3),
"type": getattr(msg, "type", "?"),
"text": text[:500],
})
def on_pageerror(err):
if len(self.errors) < MAX_CONSOLE_ENTRIES:
self.errors.append({
"ts": round(time.time() - self.started_at, 3),
"message": str(err)[:1000],
})
def on_request(req):
if len(self.network) < MAX_NETWORK_ENTRIES:
self.network.append({
"ts": round(time.time() - self.started_at, 3),
"kind": "request",
"method": req.method,
"url": req.url,
"resource_type": req.resource_type,
})
async def on_response(resp):
if len(self.network) >= MAX_NETWORK_ENTRIES:
return
try:
snippet = ""
if resp.status >= 400:
try:
body = await resp.text()
snippet = body[:MAX_BODY_SNIPPET]
except Exception:
snippet = ""
self.network.append({
"ts": round(time.time() - self.started_at, 3),
"kind": "response",
"status": resp.status,
"url": resp.url,
"body_snippet": snippet,
})
except Exception:
pass
page.on("console", on_console)
page.on("pageerror", on_pageerror)
page.on("request", on_request)
page.on("response", lambda r: asyncio.create_task(on_response(r)))
# --- screenshots --------------------------------------------------------
async def snap(self, page, label: str) -> None:
try:
data = await page.screenshot(type="jpeg", quality=SCREENSHOT_JPEG_QUALITY,
full_page=False, timeout=5000)
b64 = base64.b64encode(data).decode("ascii")
self.screenshots.append({
"ts": round(time.time() - self.started_at, 3),
"label": label,
"url": page.url,
"b64_jpeg": b64,
"size": len(data),
})
except Exception as e:
logger.warning("snap failed (%s): %s", label, e)
async def finalize(self, page) -> None:
try:
self.final_url = page.url
html = await page.content()
self.final_html = html[:MAX_HTML_DUMP]
except Exception as e:
logger.warning("finalize html dump failed: %s", e)
await self.snap(page, "final")
def to_json(self) -> dict:
return {
"url": self.url,
"final_url": self.final_url,
"duration_s": round(time.time() - self.started_at, 3),
"steps": self.steps,
"console": self.console,
"errors": self.errors,
"network": self.network,
"screenshots": self.screenshots,
"final_html": self.final_html,
}
@asynccontextmanager
async def open_page(url):
async def open_page(url: str, recorder: Optional[Recorder] = None):
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=HEADLESS,
args=["--disable-blink-features=AutomationControlled"]
args=["--disable-blink-features=AutomationControlled"],
)
context = await browser.new_context(
viewport=ViewportSize({
"width": BROWSER_WIDTH,
"height": BROWSER_HEIGHT}),
locale=BROWSER_LOCALE
viewport=ViewportSize({"width": BROWSER_WIDTH, "height": BROWSER_HEIGHT}),
locale=BROWSER_LOCALE,
)
page = await context.new_page()
if recorder:
recorder._attach(page)
recorder.step("launch", detail=f"headless={HEADLESS}")
if recorder:
recorder.step("goto", detail=url)
await page.goto(url)
await page.wait_for_load_state("networkidle")
if recorder:
recorder.step("loaded", detail=page.url)
await recorder.snap(page, "loaded")
try:
yield page
finally:
if recorder:
try:
await recorder.finalize(page)
except Exception:
logger.exception("recorder.finalize failed")
await page.wait_for_timeout(POST_SUBMISSION_SLEEP_MS)
await browser.close()
def create_dummy_pdf():
logger.info("creating dummy pdf")
c = canvas.Canvas("DummyPDF.pdf")

66
apply/classes/profile.py Normal file
View file

@ -0,0 +1,66 @@
"""Applicant profile passed from web → apply on each request."""
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class Profile:
salutation: str = "Herr"
firstname: str = ""
lastname: str = ""
email: str = ""
telephone: str = ""
street: str = ""
house_number: str = ""
postcode: str = ""
city: str = ""
# WBS
is_possessing_wbs: bool = False
wbs_type: str = "0"
wbs_valid_till: str = "1970-01-01" # ISO date string
wbs_rooms: int = 0
wbs_adults: int = 0
wbs_children: int = 0
is_prio_wbs: bool = False
# optional: immomio login for providers that need it
immomio_email: str = ""
immomio_password: str = ""
@property
def person_count(self) -> int:
return self.wbs_adults + self.wbs_children
@property
def adult_count(self) -> int:
return self.wbs_adults
@property
def children_count(self) -> int:
return self.wbs_children
def wbs_valid_till_dt(self) -> datetime:
try:
return datetime.strptime(self.wbs_valid_till, "%Y-%m-%d")
except ValueError:
return datetime(1970, 1, 1)
@classmethod
def from_dict(cls, d: dict) -> "Profile":
safe = {k: d.get(k) for k in cls.__dataclass_fields__.keys() if k in d and d[k] is not None}
# normalise booleans + ints
for k in ("is_possessing_wbs", "is_prio_wbs"):
if k in safe:
v = safe[k]
if isinstance(v, str):
safe[k] = v.lower() in ("true", "1", "yes", "on")
else:
safe[k] = bool(v)
for k in ("wbs_rooms", "wbs_adults", "wbs_children"):
if k in safe:
try:
safe[k] = int(safe[k])
except (TypeError, ValueError):
safe[k] = 0
return cls(**safe)

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(),
)

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()

View file

@ -1,129 +1,101 @@
from actions import *
from language import _
from classes.application_result import ApplicationResult
from providers._provider import Provider
from settings import *
import logging
from actions import open_page
from classes.application_result import ApplicationResult
from language import _
from providers._provider import ApplyContext, Provider
logger = logging.getLogger("flat-apply")
class Degewo(Provider):
@property
def domain(self) -> str:
return "degewo.de"
async def apply_for_flat(self, url) -> ApplicationResult:
async with open_page(url) as page:
logger.info("\tSTEP 1: accepting cookies")
async def apply_for_flat(self, url: str, ctx: ApplyContext) -> ApplicationResult:
r = ctx.recorder
p = ctx.profile
async with open_page(url, recorder=r) as page:
r.step("cookies.check")
cookie_accept_btn = page.locator("#cookie-consent-submit-all")
if await cookie_accept_btn.is_visible():
await cookie_accept_btn.click()
logger.debug("\t\tcookie accept button clicked")
else:
logger.debug("\t\tno cookie accept button found")
r.step("cookies.accepted")
logger.info("\tSTEP 2: check if the page was not found")
r.step("page.404_check")
if page.url == "https://www.degewo.de/immosuche/404":
logger.debug("\t\t'page not found' message found - returning")
return ApplicationResult(
success=False,
message=_("ad_offline"))
logger.debug("\t\t'page not found' message not found")
return ApplicationResult(False, message=_("ad_offline"))
logger.info("\tSTEP 3: check if the ad is deactivated")
r.step("ad.deactivated_check")
if await page.locator("span", has_text="Inserat deaktiviert").is_visible():
logger.debug("\t\t'ad deactivated' message found - returning")
return ApplicationResult(
success=False,
message=_("ad_deactivated"))
logger.debug("\t\t'ad deactivated' message not found")
return ApplicationResult(False, message=_("ad_deactivated"))
logger.info("\tSTEP 4: check if the page moved")
r.step("page.moved_check")
if await page.locator("h1", has_text="Diese Seite ist umgezogen!").is_visible():
logger.debug("\t\t'page moved' message found - returning")
return ApplicationResult(
success=False,
message=_("ad_offline"))
logger.debug("\t\t'page moved' message not found")
return ApplicationResult(False, message=_("ad_offline"))
logger.info("\tSTEP 5: go to the application form")
await page.get_by_role("link", name="Kontakt").click()
logger.info("\tSTEP 6: find the form iframe")
r.step("form.open"); await page.get_by_role("link", name="Kontakt").click()
r.step("iframe.locate")
form_frame = page.frame_locator("iframe[src*='wohnungshelden']")
logger.info("\tSTEP 7: fill the form")
await form_frame.locator("#salutation").fill(SALUTATION)
await form_frame.get_by_role("option", name=SALUTATION, exact=True).click()
await form_frame.locator("#firstName").fill(FIRSTNAME)
await form_frame.locator("#lastName").fill(LASTNAME)
await form_frame.locator("#email").fill(EMAIL)
await form_frame.locator("input[title='Telefonnummer']").fill(TELEPHONE)
await form_frame.locator("input[title='Anzahl einziehende Personen']").fill(str(PERSON_COUNT))
r.step("form.fill_personal")
await form_frame.locator("#salutation").fill(p.salutation)
await form_frame.get_by_role("option", name=p.salutation, exact=True).click()
await form_frame.locator("#firstName").fill(p.firstname)
await form_frame.locator("#lastName").fill(p.lastname)
await form_frame.locator("#email").fill(p.email)
await form_frame.locator("input[title='Telefonnummer']").fill(p.telephone)
await form_frame.locator("input[title='Anzahl einziehende Personen']").fill(str(p.person_count))
await page.wait_for_timeout(1000)
r.step("form.wbs_section")
wbs_question = form_frame.locator("input[id*='wbs_available'][id$='Ja']")
if await wbs_question.is_visible():
if not IS_POSSESSING_WBS:
if not p.is_possessing_wbs:
r.step("wbs_required_abort", "warn")
return ApplicationResult(False, _("wbs_required"))
await wbs_question.click()
await form_frame.locator("input[title='WBS gültig bis']").fill(WBS_VALID_TILL.strftime("%d.%m.%Y"))
await form_frame.locator("input[title='WBS gültig bis']").fill(p.wbs_valid_till_dt().strftime("%d.%m.%Y"))
await page.wait_for_timeout(1000)
wbs_rooms_select = form_frame.locator("ng-select[id*='wbs_max_number_rooms']")
await wbs_rooms_select.click()
await page.wait_for_timeout(1000)
correct_wbs_room_option = form_frame.get_by_role("option", name=str(WBS_ROOMS), exact=True)
await correct_wbs_room_option.click()
await form_frame.get_by_role("option", name=str(p.wbs_rooms), exact=True).click()
await page.wait_for_timeout(1000)
await form_frame.locator("ng-select[id*='fuer_wen_ist_wohnungsanfrage']").click()
await page.wait_for_timeout(1000)
await form_frame.get_by_role("option", name="Für mich selbst").click()
logger.info("\tSTEP 8: submit the form")
if not SUBMIT_FORMS:
logger.debug(f"\t\tdry run - not submitting")
if not ctx.submit_forms:
r.step("submit.dry_run")
return ApplicationResult(success=True, message=_("application_success_dry"))
await form_frame.locator("button[data-cy*='btn-submit']").click()
r.step("submit.click"); await form_frame.locator("button[data-cy*='btn-submit']").click()
await page.wait_for_timeout(3000)
logger.info("\tSTEP 9: check the success")
r.step("success.check")
if await page.locator("h4", has_text="Vielen Dank für das Übermitteln Ihrer Informationen. Sie können dieses Fenster jetzt schließen.").is_visible():
logger.info(f"\t\tsuccess detected by heading")
return ApplicationResult(success=True)
elif await self.is_missing_fields_warning(page):
logger.warning(f"\t\tmissing fields warning detected")
elif await self._missing_fields_warning(page):
r.step("missing_fields_detected", "warn")
return ApplicationResult(success=False, message=_("missing_fields"))
elif await self.is_already_applied_warning(page):
logger.warning(f"\t\talready applied warning detected")
elif await self._already_applied_warning(page):
r.step("already_applied_detected", "warn")
return ApplicationResult(success=False, message=_("already_applied"))
logger.warning(f"\t\tsubmit conformation not found")
r.step("success.not_found", "warn")
return ApplicationResult(success=False, message=_("submit_conformation_msg_not_found"))
async def is_already_applied_warning(self, page):
async def _already_applied_warning(self, page) -> bool:
await page.wait_for_timeout(1000)
form_iframe = page.frame_locator("iframe[src*='wohnungshelden']")
already_applied_warning = form_iframe.locator("span.ant-alert-message",
has_text="Es existiert bereits eine Anfrage mit dieser E-Mail Adresse")
if await already_applied_warning.first.is_visible():
return True
return False
f = page.frame_locator("iframe[src*='wohnungshelden']")
msg = f.locator("span.ant-alert-message",
has_text="Es existiert bereits eine Anfrage mit dieser E-Mail Adresse")
return await msg.first.is_visible()
async def is_missing_fields_warning(self, page):
async def _missing_fields_warning(self, page) -> bool:
await page.wait_for_timeout(1000)
form_iframe = page.frame_locator("iframe[src*='wohnungshelden']")
already_applied_warning = form_iframe.locator("span.ant-alert-message",
has_text="Es wurden nicht alle Felder korrekt befüllt. Bitte prüfen Sie ihre Eingaben")
if await already_applied_warning.first.is_visible():
return True
return False
if __name__ == "__main__":
# url = "https://www.degewo.de/immosuche/details/neubau-mit-wbs-140-160-180-220-mit-besonderem-wohnbedarf-1" # already applied
# url = "https://www.degewo.de/immosuche/details/wohnung-sucht-neuen-mieter-1" # angebot geschlossen
# url = "https://www.degewo.de/immosuche/details/wohnung-sucht-neuen-mieter-145" # seite nicht gefunden
# url = "https://www.degewo.de/immosuche/details/1-zimmer-mit-balkon-3"
# url = "https://www.degewo.de/immosuche/details/2-zimmer-in-gropiusstadt-4"
url = "https://www.degewo.de/immosuche/details/2-zimmer-in-gropiusstadt-4"
provider = Degewo()
provider.test_apply(url)
f = page.frame_locator("iframe[src*='wohnungshelden']")
msg = f.locator("span.ant-alert-message",
has_text="Es wurden nicht alle Felder korrekt befüllt. Bitte prüfen Sie ihre Eingaben")
return await msg.first.is_visible()

View file

@ -1,81 +1,69 @@
from actions import *
from language import _
from classes.application_result import ApplicationResult
from providers._provider import Provider
from settings import *
import logging
from actions import open_page
from classes.application_result import ApplicationResult
from language import _
from providers._provider import ApplyContext, Provider
logger = logging.getLogger("flat-apply")
class Gesobau(Provider):
@property
def domain(self) -> str:
return "gesobau.de"
async def apply_for_flat(self, url) -> ApplicationResult:
async with open_page(url) as page:
logger.info("\tSTEP 1: extracting immomio link")
async def apply_for_flat(self, url: str, ctx: ApplyContext) -> ApplicationResult:
r = ctx.recorder
p = ctx.profile
if not (p.immomio_email and p.immomio_password):
r.step("immomio_creds_missing", "warn")
return ApplicationResult(False, _("missing_fields"))
async with open_page(url, recorder=r) as page:
r.step("extract.immomio_link")
immomio_link = await page.get_by_role("link", name="Jetzt bewerben").get_attribute("href")
logger.info("\tSTEP 2: going to auth page")
await page.goto("https://tenant.immomio.com/de/auth/login")
r.step("auth.goto"); await page.goto("https://tenant.immomio.com/de/auth/login")
await page.wait_for_timeout(1000)
logger.info("\tSTEP 3: logging in")
await page.locator('input[name="email"]').fill(IMMOMIO_EMAIL)
r.step("auth.login")
await page.locator('input[name="email"]').fill(p.immomio_email)
await page.get_by_role("button", name="Anmelden").click()
await page.wait_for_timeout(1000)
await page.locator("#password").fill(IMMOMIO_PASSWORD)
await page.locator("#password").fill(p.immomio_password)
await page.locator("#kc-login").click()
await page.wait_for_timeout(1000)
logger.info("\tSTEP 4: going back to immomio")
r.step("back.to_immomio", detail=immomio_link or "")
await page.goto(immomio_link)
await page.wait_for_timeout(1000)
logger.info("\tSTEP 5: accepting cookies")
r.step("cookies.check")
cookie_accept_btn = page.get_by_role("button", name="Alle erlauben")
if await cookie_accept_btn.is_visible():
await cookie_accept_btn.click()
logger.debug("\t\tcookie accept button clicked")
else:
logger.debug("\t\tno cookie accept button found")
logger.info("\tSTEP 6: click apply now")
await page.get_by_role("button", name="Jetzt bewerben").click()
r.step("apply.click"); await page.get_by_role("button", name="Jetzt bewerben").click()
await page.wait_for_timeout(3000)
logger.info("\tSTEP 7: check if already applied")
r.step("already_applied.check")
if page.url == "https://tenant.immomio.com/de/properties/applications":
return ApplicationResult(False, message=_("already_applied"))
logger.info("\tSTEP 8: clicking answer questions")
r.step("answer_questions.click")
answer_questions_btn = page.get_by_role("button", name="Fragen beantworten")
if await answer_questions_btn.is_visible():
await answer_questions_btn.click()
logger.debug("\t\tanswer questions button clicked")
await page.wait_for_timeout(2000)
else:
logger.debug("\t\tno answer questions button found")
if await answer_questions_btn.is_visible(): # sometimes this button must be clicked twice
if await answer_questions_btn.is_visible():
await answer_questions_btn.click()
logger.debug("\t\tanswer questions button clicked")
await page.wait_for_timeout(2000)
logger.info("\tSTEP 9: verifying success by answer button vanishing")
if not await answer_questions_btn.is_visible(): # TODO better verify success
logger.info("\t\tsuccess detected by answer button vanishing")
r.step("success.check")
if not await answer_questions_btn.is_visible():
return ApplicationResult(True)
logger.info("\t\tsubmit conformation not found")
r.step("success.not_found", "warn")
return ApplicationResult(False, _("submit_conformation_msg_not_found"))
if __name__ == "__main__":
# url = "https://www.gesobau.de/?immo_ref=10-03239-00007-1185" # already applied
# url = "https://www.gesobau.de/mieten/wohnungssuche/detailseite/florastrasse-10-12179-00002-1002-1d4d1a94-b555-48f8-b06d-d6fc02aecb0d/"
url = "https://www.gesobau.de/mieten/wohnungssuche/detailseite/rolandstrasse-10-03020-00007-1052-7f47d893-e659-4e4f-a7cd-5dcd53f4e6d7/"
provider = Gesobau()
provider.test_apply(url)

View file

@ -1,168 +1,128 @@
from actions import *
from language import _
from classes.application_result import ApplicationResult
from providers._provider import Provider
from settings import *
import logging
from actions import create_dummy_pdf, open_page
from classes.application_result import ApplicationResult
from language import _
from providers._provider import ApplyContext, Provider
logger = logging.getLogger("flat-apply")
class Gewobag(Provider):
@property
def domain(self) -> str:
return "gewobag.de"
async def apply_for_flat(self, url) -> ApplicationResult:
async with open_page(url) as page:
logger.info("\tSTEP 1: accepting cookies")
cookie_accept_btn = page.get_by_text("Alle Cookies akzeptieren")
if await cookie_accept_btn.is_visible():
await cookie_accept_btn.click()
logger.debug("\t\tcookie accept button clicked")
else:
logger.debug("\t\tno cookie accept button found")
async def apply_for_flat(self, url: str, ctx: ApplyContext) -> ApplicationResult:
r = ctx.recorder
p = ctx.profile
async with open_page(url, recorder=r) as page:
r.step("cookies.check")
btn = page.get_by_text("Alle Cookies akzeptieren")
if await btn.is_visible():
await btn.click()
r.step("cookies.accepted")
logger.info("\tSTEP 2: check if the page was not found")
r.step("ad.exists_check")
if await page.get_by_text("Mietangebot nicht gefunden").first.is_visible():
logger.debug("\t\t'page not found' message found - returning")
return ApplicationResult(
success=False,
message=_("not_found"))
logger.debug("\t\t'page not found' message not found")
return ApplicationResult(False, message=_("not_found"))
logger.info("\tSTEP 3: check if ad is still open")
r.step("ad.still_open_check")
if await page.locator('#immo-mediation-notice').is_visible():
logger.debug("\t\tad closed notice found - returning")
return ApplicationResult(
success=False,
message=_("ad_deactivated"))
logger.debug("\t\tno ad closed notice found")
return ApplicationResult(False, message=_("ad_deactivated"))
logger.info("\tSTEP 4: go to the application form")
await page.get_by_role("button", name="Anfrage senden").first.click()
r.step("form.open"); await page.get_by_role("button", name="Anfrage senden").first.click()
logger.info("\tSTEP 5: check if the flat is for seniors only")
if await self.is_senior_flat(page):
logger.debug("\t\tflat is for seniors only - returning")
r.step("form.senior_check")
if await self._is_senior_flat(page):
return ApplicationResult(False, _("senior_flat"))
logger.debug("\t\tflat is not seniors only")
logger.info("\tSTEP 6: check if the flat is for special needs wbs only")
if await self.is_special_needs_wbs(page):
logger.debug("\t\tflat is for special needs wbs only - returning")
r.step("form.special_wbs_check")
if await self._is_special_needs_wbs(page):
return ApplicationResult(False, _("special_need_wbs_flat"))
logger.debug("\t\tflat is not for special needs wbs only")
logger.info("\tSTEP 7: find the form iframe")
r.step("iframe.locate")
form_iframe = page.frame_locator("#contact-iframe")
logger.info("\tSTEP 8: define helper functions")
async def fill_field(locator, filling):
logger.debug(f"\t\tfill_field('{locator}', '{filling}')")
field = form_iframe.locator(locator)
if await field.is_visible():
await field.fill(filling)
await page.wait_for_timeout(100)
else:
logger.debug(f"\t\t\tfield was not found")
async def select_field(locator, selection):
logger.debug(f"\t\tselect_field('{locator}', '{selection}')")
field = form_iframe.locator(locator)
if await field.is_visible():
await field.click()
await page.wait_for_timeout(100)
await form_iframe.get_by_role("option", name=selection, exact=True).click()
await page.wait_for_timeout(100)
else:
logger.debug(f"\t\t\tfield was not found")
async def check_checkbox(locator):
logger.debug(f"\t\tcheck_checkbox('{locator}')")
field = form_iframe.locator(locator)
if await field.first.is_visible():
await field.evaluate_all("elements => elements.forEach(el => el.click())")
await page.wait_for_timeout(100)
else:
logger.debug(f"\t\t\tfield was not found")
async def upload_files(locator, files=None):
if not files:
create_dummy_pdf()
files = ["DummyPDF.pdf"]
logger.debug(f"\t\tupload_files('{locator}', {str(files)})")
wbs_upload_section = form_iframe.locator(locator)
if await wbs_upload_section.count() > 0:
await wbs_upload_section.locator("input[type='file']").set_input_files(files)
sec = form_iframe.locator(locator)
if await sec.count() > 0:
await sec.locator("input[type='file']").set_input_files(files)
await page.wait_for_timeout(2000)
else:
logger.debug(f"\t\t\tfield was not found")
r.step("form.fill_personal")
await select_field("#salutation-dropdown", p.salutation)
await fill_field("#firstName", p.firstname)
await fill_field("#lastName", p.lastname)
await fill_field("#email", p.email)
await fill_field("#phone-number", p.telephone)
await fill_field("#street", p.street)
await fill_field("#house-number", p.house_number)
await fill_field("#zip-code", p.postcode)
await fill_field("#city", p.city)
await fill_field("input[id*='anzahl_erwachsene']", str(p.adult_count))
await fill_field("input[id*='anzahl_kinder']", str(p.children_count))
await fill_field("input[id*='gesamtzahl_der_einziehenden_personen']", str(p.person_count))
logger.info("\tSTEP 9: fill the form")
await select_field("#salutation-dropdown", SALUTATION)
await fill_field("#firstName", FIRSTNAME)
await fill_field("#lastName", LASTNAME)
await fill_field("#email", EMAIL)
await fill_field("#phone-number", TELEPHONE)
await fill_field("#street", STREET)
await fill_field("#house-number", HOUSE_NUMBER)
await fill_field("#zip-code", POSTCODE)
await fill_field("#city", CITY)
await fill_field("input[id*='anzahl_erwachsene']", str(ADULT_COUNT))
await fill_field("input[id*='anzahl_kinder']", str(CHILDREN_COUNT))
await fill_field("input[id*='gesamtzahl_der_einziehenden_personen']", str(PERSON_COUNT))
r.step("form.wbs_fill")
await check_checkbox("[data-cy*='wbs_available'][data-cy*='-Ja']")
await fill_field("input[id*='wbs_valid_until']", WBS_VALID_TILL.strftime("%d.%m.%Y"))
await select_field("input[id*='wbs_max_number_rooms']", f"{WBS_ROOMS} Räume")
await select_field("input[id*='art_bezeichnung_des_wbs']", f"WBS {WBS_TYPE}")
await fill_field("input[id*='wbs_valid_until']", p.wbs_valid_till_dt().strftime("%d.%m.%Y"))
await select_field("input[id*='wbs_max_number_rooms']", f"{p.wbs_rooms} Räume")
await select_field("input[id*='art_bezeichnung_des_wbs']", f"WBS {p.wbs_type}")
await select_field("input[id*='fuer_wen']", "Für mich selbst")
await fill_field("input[id*='telephone_number']", TELEPHONE)
await fill_field("input[id*='telephone_number']", p.telephone)
await check_checkbox("input[id*='datenschutzhinweis']")
await upload_files("el-application-form-document-upload", files=None)
logger.info("\tSTEP 10: submit the form")
if not SUBMIT_FORMS:
logger.debug(f"\t\tdry run - not submitting")
if not ctx.submit_forms:
r.step("submit.dry_run")
return ApplicationResult(True, _("application_success_dry"))
await form_iframe.get_by_role("button", name="Anfrage versenden").click()
r.step("submit.click"); await form_iframe.get_by_role("button", name="Anfrage versenden").click()
await page.wait_for_timeout(5000)
logger.info("\tSTEP 11: check the success")
r.step("success.check", detail=page.url)
if page.url.startswith("https://www.gewobag.de/daten-uebermittelt/"):
logger.info(f"\t\tsuccess detected by page url")
return ApplicationResult(True)
elif self.is_missing_fields_warning(page):
logger.warning(f"\t\tmissing fields warning detected")
elif await self._is_missing_fields_warning(page):
r.step("missing_fields_detected", "warn")
return ApplicationResult(False, _("missing_fields"))
else:
logger.warning(f"\t\tneither missing fields nor success detected")
return ApplicationResult(False, _("submit_conformation_msg_not_found"))
r.step("success.not_found", "warn")
return ApplicationResult(False, _("submit_conformation_msg_not_found"))
async def is_senior_flat(self, page):
form_iframe = page.frame_locator("#contact-iframe")
return await form_iframe.locator("label[for*='mindestalter_seniorenwohnhaus_erreicht']").first.is_visible()
async def _is_senior_flat(self, page):
f = page.frame_locator("#contact-iframe")
return await f.locator("label[for*='mindestalter_seniorenwohnhaus_erreicht']").first.is_visible()
async def is_special_needs_wbs(self, page):
form_iframe = page.frame_locator("#contact-iframe")
return await form_iframe.locator("label[for*='wbs_mit_besonderem_wohnbedarf_vorhanden']").first.is_visible()
async def _is_special_needs_wbs(self, page):
f = page.frame_locator("#contact-iframe")
return await f.locator("label[for*='wbs_mit_besonderem_wohnbedarf_vorhanden']").first.is_visible()
async def is_missing_fields_warning(self, page):
form_iframe = page.frame_locator("#contact-iframe")
missing_field_msg = form_iframe.locator("span.ant-alert-message",
has_text="Es wurden nicht alle Felder korrekt befüllt.")
if await missing_field_msg.first.is_visible():
return True
return False
if __name__ == "__main__":
#url = "https://www.gewobag.de/fuer-mietinteressentinnen/mietangebote/0100-01036-0601-0286-vms1/" # wbs
#url = "https://www.gewobag.de/fuer-mietinteressentinnen/mietangebote/7100-72401-0101-0011/" # senior
url = "https://www.gewobag.de/fuer-mietinteressentinnen/mietangebote/6011-31046-0105-0045/" # more wbs fields
#url = "https://www.gewobag.de/fuer-mietinteressentinnen/mietangebote/0100-01036-0401-0191/" # special need wbs
#url = "https://www.gewobag.de/fuer-mietinteressentinnen/mietangebote/0100-02571-0103-0169/"
# url = "https://www.gewobag.de/fuer-mietinteressentinnen/mietangebote/0100-02571-0103-169/" # page not found
provider = Gewobag()
provider.test_apply(url)
async def _is_missing_fields_warning(self, page):
f = page.frame_locator("#contact-iframe")
msg = f.locator("span.ant-alert-message",
has_text="Es wurden nicht alle Felder korrekt befüllt.")
return await msg.first.is_visible()

View file

@ -1,62 +1,55 @@
from actions import *
from language import _
from classes.application_result import ApplicationResult
from providers._provider import Provider
from settings import *
import logging
from actions import open_page
from classes.application_result import ApplicationResult
from language import _
from providers._provider import ApplyContext, Provider
logger = logging.getLogger("flat-apply")
class Howoge(Provider):
@property
def domain(self) -> str:
return "howoge.de"
async def apply_for_flat(self, url) -> ApplicationResult:
async with open_page(url) as page:
logger.info("\tSTEP 1: accepting cookies")
async def apply_for_flat(self, url: str, ctx: ApplyContext) -> ApplicationResult:
r = ctx.recorder
p = ctx.profile
async with open_page(url, recorder=r) as page:
r.step("cookies.check")
cookie_accept_btn = page.get_by_role("button", name="Alles akzeptieren")
if await cookie_accept_btn.is_visible():
await cookie_accept_btn.click()
logger.debug("\t\tcookie accept button clicked")
r.step("cookies.accepted")
else:
logger.debug("\t\tno cookie accept button found")
r.step("cookies.absent")
logger.info("\tSTEP 2: check if the page was not found")
r.step("page.404_check", detail=page.url)
if page.url == "https://www.howoge.de/404":
logger.debug("\t\t'page not found' url found - returning")
return ApplicationResult(
success=False,
message=_("not_found"))
logger.debug("\t\t'page not found' url not found")
return ApplicationResult(False, message=_("not_found"))
logger.info("\tSTEP 3: go to the application form")
r.step("form.open")
await page.get_by_role("link", name="Besichtigung anfragen").click()
logger.info("\tSTEP 4: fill the form")
await page.get_by_text("Ja, ich habe die Hinweise zum WBS zur Kenntnis genommen.").click()
r.step("form.wbs_hint"); await page.get_by_text("Ja, ich habe die Hinweise zum WBS zur Kenntnis genommen.").click()
await page.get_by_role("button", name="Weiter").click()
await page.get_by_text("Ja, ich habe den Hinweis zum Haushaltsnettoeinkommen zur Kenntnis genommen.").click()
r.step("form.income_hint"); await page.get_by_text("Ja, ich habe den Hinweis zum Haushaltsnettoeinkommen zur Kenntnis genommen.").click()
await page.get_by_role("button", name="Weiter").click()
await page.get_by_text("Ja, ich habe den Hinweis zur Bonitätsauskunft zur Kenntnis genommen.").click()
r.step("form.bonitaet_hint"); await page.get_by_text("Ja, ich habe den Hinweis zur Bonitätsauskunft zur Kenntnis genommen.").click()
await page.get_by_role("button", name="Weiter").click()
await page.locator("#immo-form-firstname").fill(FIRSTNAME)
await page.locator("#immo-form-lastname").fill(LASTNAME)
await page.locator("#immo-form-email").fill(EMAIL)
r.step("form.name"); await page.locator("#immo-form-firstname").fill(p.firstname)
await page.locator("#immo-form-lastname").fill(p.lastname)
await page.locator("#immo-form-email").fill(p.email)
logger.info("\tSTEP 5: submit the form")
if not SUBMIT_FORMS:
logger.debug(f"\t\tdry run - not submitting")
if not ctx.submit_forms:
r.step("submit.dry_run")
return ApplicationResult(True, _("application_success_dry"))
await page.get_by_role("button", name="Anfrage senden").click()
logger.info("\tSTEP 6: check the success")
r.step("submit.click"); await page.get_by_role("button", name="Anfrage senden").click()
r.step("success.check")
if await page.get_by_role("heading", name="Vielen Dank.").is_visible():
return ApplicationResult(True)
r.step("success.not_found", "warn")
return ApplicationResult(False, _("submit_conformation_msg_not_found"))
if __name__ == "__main__":
# url = "https://www.howoge.de/wohnungen-gewerbe/wohnungssuche/detail/1770-26279-6.html" # not found
url = "https://www.howoge.de/immobiliensuche/wohnungssuche/detail/1770-27695-194.html"
provider = Howoge()
provider.test_apply(url)

View file

@ -1,65 +1,56 @@
from actions import *
from language import _
from classes.application_result import ApplicationResult
from providers._provider import Provider
from settings import *
import logging
from actions import open_page
from classes.application_result import ApplicationResult
from language import _
from providers._provider import ApplyContext, Provider
logger = logging.getLogger("flat-apply")
class Stadtundland(Provider):
@property
def domain(self) -> str:
return "stadtundland.de"
async def apply_for_flat(self, url) -> ApplicationResult:
async with open_page(url) as page:
logger.info("\tSTEP 1: accepting cookies")
async def apply_for_flat(self, url: str, ctx: ApplyContext) -> ApplicationResult:
r = ctx.recorder
p = ctx.profile
async with open_page(url, recorder=r) as page:
r.step("cookies.check")
cookie_accept_btn = page.get_by_text("Alle akzeptieren")
if await cookie_accept_btn.is_visible():
await cookie_accept_btn.click()
logger.debug("\t\tcookie accept button clicked")
r.step("cookies.accepted")
else:
logger.debug("\t\tno cookie accept button found")
r.step("cookies.absent")
logger.info("\tSTEP 2: check if ad is still open")
r.step("page.offline_check")
if await page.get_by_role("heading", name="Hier ist etwas schief gelaufen").is_visible():
logger.debug("\t\tsomething went wrong notice found - returning")
return ApplicationResult(
success=False,
message=_("ad_offline"))
logger.debug("\t\tsomething went wrong notice not found")
return ApplicationResult(False, message=_("ad_offline"))
logger.info("\tSTEP 3: fill the form")
await page.locator("#name").fill(FIRSTNAME)
await page.locator("#surname").fill(LASTNAME)
await page.locator("#street").fill(STREET)
await page.locator("#houseNo").fill(HOUSE_NUMBER)
await page.locator("#postalCode").fill(POSTCODE)
await page.locator("#city").fill(CITY)
await page.locator("#phone").fill(TELEPHONE)
await page.locator("#email").fill(EMAIL)
r.step("form.fill")
await page.locator("#name").fill(p.firstname)
await page.locator("#surname").fill(p.lastname)
await page.locator("#street").fill(p.street)
await page.locator("#houseNo").fill(p.house_number)
await page.locator("#postalCode").fill(p.postcode)
await page.locator("#city").fill(p.city)
await page.locator("#phone").fill(p.telephone)
await page.locator("#email").fill(p.email)
await page.locator("#privacy").check()
await page.locator("#provision").check()
logger.info("\tSTEP 4: submit the form")
if not SUBMIT_FORMS:
logger.debug(f"\t\tdry run - not submitting")
return ApplicationResult(False, _("application_success_dry"))
await page.get_by_role("button", name="Eingaben prüfen").click()
await page.get_by_role("button", name="Absenden").click()
if not ctx.submit_forms:
r.step("submit.dry_run")
return ApplicationResult(True, _("application_success_dry"))
r.step("submit.review"); await page.get_by_role("button", name="Eingaben prüfen").click()
r.step("submit.send"); await page.get_by_role("button", name="Absenden").click()
await page.wait_for_timeout(2000)
logger.info("\tSTEP 5: check the success")
r.step("success.check")
if await page.locator("p").filter(has_text="Vielen Dank!").is_visible():
logger.info(f"\t\tsuccess detected by paragraph text")
return ApplicationResult(True)
logger.warning(f"\t\tsuccess message not found")
r.step("success.not_found", "warn")
return ApplicationResult(success=False, message=_("submit_conformation_msg_not_found"))
if __name__ == "__main__":
# url = "https://stadtundland.de/wohnungssuche/1001%2F0203%2F00310" # offline
url = "https://stadtundland.de/wohnungssuche/1050%2F8222%2F00091" # wbs
provider = Stadtundland()
provider.test_apply(url)

View file

@ -1,10 +1,10 @@
from actions import *
from language import _
from classes.application_result import ApplicationResult
from providers._provider import Provider
from settings import *
import logging
from actions import open_page
from classes.application_result import ApplicationResult
from language import _
from providers._provider import ApplyContext, Provider
logger = logging.getLogger("flat-apply")
@ -13,88 +13,71 @@ class Wbm(Provider):
def domain(self) -> str:
return "wbm.de"
async def apply_for_flat(self, url) -> ApplicationResult:
async with open_page(url) as page:
logger.info("\tSTEP 1: checking if page not found")
async def apply_for_flat(self, url: str, ctx: ApplyContext) -> ApplicationResult:
r = ctx.recorder
p = ctx.profile
async with open_page(url, recorder=r) as page:
r.step("page.404_check")
if await page.get_by_role("heading", name="Page Not Found").is_visible():
logger.debug("\t\t'page not found' message found - returning")
return ApplicationResult(
success=False,
message=_("not_found"))
logger.debug("\t\t'page not found' message not found")
return ApplicationResult(False, message=_("not_found"))
logger.info("\tSTEP 2: accepting cookies")
r.step("cookies.check")
cookie_accept_btn = page.get_by_text("Alle zulassen")
if await cookie_accept_btn.is_visible():
await cookie_accept_btn.click()
logger.debug("\t\tcookie accept button clicked")
r.step("cookies.accepted")
else:
logger.debug("\t\tno cookie accept button found")
r.step("cookies.absent")
logger.info("\tSTEP 3: removing chatbot help icon")
await page.locator('#removeConvaiseChat').click()
r.step("chatbot.remove")
try:
await page.locator('#removeConvaiseChat').click()
except Exception as e:
r.step("chatbot.remove_failed", "warn", str(e))
logger.info("\tSTEP 4: checking if ad is offline")
r.step("ad.offline_check")
if page.url == "https://www.wbm.de/wohnungen-berlin/angebote/":
logger.debug("\t\t'page not found' url found - returning")
return ApplicationResult(
success=False,
message=_("ad_offline"))
logger.debug("\t\t'page not found' url not found")
return ApplicationResult(False, message=_("ad_offline"))
r.step("form.open"); await page.locator('.openimmo-detail__contact-box-button').click()
logger.info("\tSTEP 5: go to the application form")
await page.locator('.openimmo-detail__contact-box-button').click()
logger.info("\tSTEP 6: filling the application form")
if IS_POSSESSING_WBS:
r.step("form.wbs_section")
if p.is_possessing_wbs:
await page.locator('label[for="powermail_field_wbsvorhanden_1"]').click()
await page.locator("input[name*='[wbsgueltigbis]']").fill(WBS_VALID_TILL.strftime("%Y-%m-%d"))
await page.locator("select[name*='[wbszimmeranzahl]']").select_option(str(WBS_ROOMS))
await page.locator("#powermail_field_einkommensgrenzenacheinkommensbescheinigung9").select_option(WBS_TYPE)
if IS_PRIO_WBS:
await page.locator("input[name*='[wbsgueltigbis]']").fill(p.wbs_valid_till_dt().strftime("%Y-%m-%d"))
await page.locator("select[name*='[wbszimmeranzahl]']").select_option(str(p.wbs_rooms))
await page.locator("#powermail_field_einkommensgrenzenacheinkommensbescheinigung9").select_option(p.wbs_type)
if p.is_prio_wbs:
await page.locator("#powermail_field_wbsmitbesonderemwohnbedarf_1").check(force=True)
else:
await page.locator('label[for="powermail_field_wbsvorhanden_2"]').click()
await page.locator("#powermail_field_anrede").select_option(SALUTATION)
await page.locator("#powermail_field_name").fill(LASTNAME)
await page.locator("#powermail_field_vorname").fill(FIRSTNAME)
await page.locator("#powermail_field_strasse").fill(STREET)
await page.locator("#powermail_field_plz").fill(POSTCODE)
await page.locator("#powermail_field_ort").fill(CITY)
await page.locator("#powermail_field_e_mail").fill(EMAIL)
await page.locator("#powermail_field_telefon").fill(TELEPHONE)
r.step("form.personal")
await page.locator("#powermail_field_anrede").select_option(p.salutation)
await page.locator("#powermail_field_name").fill(p.lastname)
await page.locator("#powermail_field_vorname").fill(p.firstname)
await page.locator("#powermail_field_strasse").fill(p.street)
await page.locator("#powermail_field_plz").fill(p.postcode)
await page.locator("#powermail_field_ort").fill(p.city)
await page.locator("#powermail_field_e_mail").fill(p.email)
await page.locator("#powermail_field_telefon").fill(p.telephone)
await page.locator("#powermail_field_datenschutzhinweis_1").check(force=True)
logger.info("\tSTEP 7: submit the form")
if not SUBMIT_FORMS:
logger.debug(f"\t\tdry run - not submitting")
if not ctx.submit_forms:
r.step("submit.dry_run")
return ApplicationResult(success=True, message=_("application_success_dry"))
await page.get_by_role("button", name="Anfrage absenden").click()
logger.info("\tSTEP 8: check the success")
r.step("submit.click"); await page.get_by_role("button", name="Anfrage absenden").click()
r.step("success.check")
if await page.get_by_text("Wir haben Ihre Anfrage für das Wohnungsangebot erhalten.").is_visible():
logger.info(f"\t\tsuccess detected by text")
return ApplicationResult(True)
elif await self.is_missing_fields_warning(page):
logger.warning(f"\t\tmissing fields warning detected")
elif await self._missing_fields_warning(page):
r.step("missing_fields_detected", "warn")
return ApplicationResult(False, _("missing_fields"))
else:
logger.warning(f"\t\tneither missing fields nor success detected")
return ApplicationResult(success=False, message=_("submit_conformation_msg_not_found"))
r.step("success.not_found", "warn")
return ApplicationResult(success=False, message=_("submit_conformation_msg_not_found"))
async def is_missing_fields_warning(self, page):
async def _missing_fields_warning(self, page) -> bool:
missing_field_msg = page.get_by_text("Dieses Feld muss ausgefüllt werden!").first
if await missing_field_msg.first.is_visible():
return True
return False
if __name__ == "__main__":
# url = "https://www.wbm.de/wohnungen-berlin/angebote/details/4-zimmer-wohnung-in-spandau-1/" # not found
url = "https://www.wbm.de/wohnungen-berlin/angebote/details/wbs-160-180-220-perfekt-fuer-kleine-familien-3-zimmer-wohnung-mit-balkon/"
provider = Wbm()
provider.test_apply(url)
return await missing_field_msg.first.is_visible()

View file

@ -1,6 +1,12 @@
"""
apply service config.
Personal info and WBS fields used to live here as env vars; they now come in
with every /apply request as a Profile. The only remaining settings are
browser + service-level knobs.
"""
import logging
import sys
from datetime import datetime as dt
from os import getenv
from dotenv import load_dotenv
@ -8,103 +14,43 @@ logger = logging.getLogger("flat-apply")
load_dotenv()
def get_env_or_fail(key: str, default: str = None, required: bool = True) -> str:
def get_env_or_fail(key: str, default: str | None = None, required: bool = True) -> str:
value = getenv(key, default)
if required and value is None:
logger.error(f"Missing required environment variable: {key}")
sys.exit(1)
return value
def get_bool_env(key: str, default: str = "False", required: bool = True) -> bool:
return get_env_or_fail(key, default, required).lower() in ("true", "1", "yes", "on")
def get_int_env(key: str, default: str = None, required: bool = True) -> int:
value_str = get_env_or_fail(key, default, required)
def get_bool_env(key: str, default: str = "False") -> bool:
return (getenv(key, default) or "").lower() in ("true", "1", "yes", "on")
def get_int_env(key: str, default: str) -> int:
v = getenv(key, default) or default
try:
return int(value_str)
return int(v)
except ValueError:
logger.error(f"Environment variable {key} must be an integer. Got: {value_str}")
logger.error(f"Env var {key} must be int, got {v!r}")
sys.exit(1)
def get_date_env(key: str, fmt: str = "%Y-%m-%d", default: str = None, required: bool = True) -> dt:
value_str = get_env_or_fail(key, default, required)
try:
return dt.strptime(value_str, fmt)
except ValueError:
logger.error(f"Environment variable {key} must be a date in format {fmt}. Got: {value_str}")
sys.exit(1)
# --- General Settings ---
LANGUAGE: str = get_env_or_fail("LANGUAGE", "de", False)
# --- Browser -----------------------------------------------------------------
HEADLESS: bool = get_bool_env("HEADLESS", "True")
BROWSER_WIDTH: int = get_int_env("BROWSER_WIDTH", "600")
BROWSER_HEIGHT: int = get_int_env("BROWSER_HEIGHT", "800")
BROWSER_LOCALE: str = getenv("BROWSER_LOCALE", "de-DE")
POST_SUBMISSION_SLEEP_MS: int = get_int_env("POST_SUBMISSION_SLEEP_MS", "0")
# --- Browser Settings ---
HEADLESS: bool = get_bool_env("HEADLESS", "True", True)
BROWSER_WIDTH: int = get_int_env("BROWSER_WIDTH", "600", False)
BROWSER_HEIGHT: int = get_int_env("BROWSER_HEIGHT", "800", False)
BROWSER_LOCALE: str = get_env_or_fail("BROWSER_LOCALE", "de-DE", False)
POST_SUBMISSION_SLEEP_MS: int = get_int_env("POST_SUBMISSION_SLEEP_MS", "0", False)
# --- Automation Mode ---
SUBMIT_FORMS: bool = get_bool_env("SUBMIT_FORMS", "False")
# --- HTTP Server ---
HTTP_HOST: str = get_env_or_fail("HTTP_HOST", "0.0.0.0", False)
HTTP_PORT: int = get_int_env("HTTP_PORT", "8000", False)
# --- Personal Information ---
SALUTATION: str = get_env_or_fail("SALUTATION")
LASTNAME: str = get_env_or_fail("LASTNAME")
FIRSTNAME: str = get_env_or_fail("FIRSTNAME")
EMAIL: str = get_env_or_fail("EMAIL")
TELEPHONE: str = get_env_or_fail("TELEPHONE")
STREET: str = get_env_or_fail("STREET")
HOUSE_NUMBER: str = get_env_or_fail("HOUSE_NUMBER")
POSTCODE: str = get_env_or_fail("POSTCODE")
CITY: str = get_env_or_fail("CITY")
# --- WBS Information ---
IS_POSSESSING_WBS: bool = get_bool_env("IS_POSSESSING_WBS", "False")
WBS_TYPE: str = get_env_or_fail("WBS_TYPE", "0", False)
WBS_VALID_TILL: dt = get_date_env("WBS_VALID_TILL", default="1970-01-01", required=False)
WBS_ROOMS: int = get_int_env("WBS_ROOMS", "0", False)
ADULT_COUNT: int = get_int_env("WBS_ADULTS", "0", False)
CHILDREN_COUNT: int = get_int_env("WBS_CHILDREN", "0", False)
PERSON_COUNT: int = ADULT_COUNT + CHILDREN_COUNT
IS_PRIO_WBS: bool = get_bool_env("IS_PRIO_WBS", "False")
# --- Secrets ---
IMMOMIO_EMAIL: str = get_env_or_fail("IMMOMIO_EMAIL", required=False)
IMMOMIO_PASSWORD: str = get_env_or_fail("IMMOMIO_PASSWORD", required=False)
# --- Service -----------------------------------------------------------------
INTERNAL_API_KEY: str = get_env_or_fail("INTERNAL_API_KEY")
def log_settings():
def log_settings() -> None:
logger.debug("--- Settings ---")
logger.debug(f"LANGUAGE: {LANGUAGE}")
logger.debug(f"HEADLESS: {HEADLESS}")
logger.debug(f"BROWSER_WIDTH: {BROWSER_WIDTH}")
logger.debug(f"BROWSER_HEIGHT: {BROWSER_HEIGHT}")
logger.debug(f"BROWSER_LOCALE: {BROWSER_LOCALE}")
logger.debug(f"POST_SUBMISSION_SLEEP_MS: {POST_SUBMISSION_SLEEP_MS}")
logger.debug(f"HEADLESS: {HEADLESS}")
logger.debug(f"SUBMIT_FORMS: {SUBMIT_FORMS}")
logger.debug(f"HTTP_HOST: {HTTP_HOST}")
logger.debug(f"HTTP_PORT: {HTTP_PORT}")
logger.debug(f"SALUTATION: {SALUTATION}")
logger.debug(f"LASTNAME: {LASTNAME}")
logger.debug(f"FIRSTNAME: {FIRSTNAME}")
logger.debug(f"EMAIL: {EMAIL}")
logger.debug(f"TELEPHONE: {TELEPHONE}")
logger.debug(f"STREET: {STREET}")
logger.debug(f"HOUSE_NUMBER: {HOUSE_NUMBER}")
logger.debug(f"POSTCODE: {POSTCODE}")
logger.debug(f"CITY: {CITY}")
logger.debug(f"IS_POSSESSING_WBS: {IS_POSSESSING_WBS}")
logger.debug(f"WBS_TYPE: {WBS_TYPE}")
logger.debug(f"WBS_VALID_TILL: {WBS_VALID_TILL}")
logger.debug(f"WBS_ROOMS: {WBS_ROOMS}")
logger.debug(f"WBS_ADULTS: {ADULT_COUNT}")
logger.debug(f"WBS_CHILDREN: {CHILDREN_COUNT}")
logger.debug(f"PERSON_COUNT: {PERSON_COUNT}")
logger.debug(f"IS_PRIO_WBS: {IS_PRIO_WBS}")
logger.debug(f"IMMOMIO_EMAIL: {IMMOMIO_EMAIL}")
masked_password = "***" if IMMOMIO_PASSWORD else "None"
logger.debug(f"IMMOMIO_PASSWORD: {masked_password}")

View file

@ -19,12 +19,17 @@ services:
- SESSION_MAX_AGE_SECONDS=${SESSION_MAX_AGE_SECONDS:-604800}
- LOGIN_RATE_LIMIT=${LOGIN_RATE_LIMIT:-5}
- LOGIN_RATE_WINDOW_SECONDS=${LOGIN_RATE_WINDOW_SECONDS:-900}
- FILTER_ROOMS=${FILTER_ROOMS:-2.0,2.5}
- FILTER_MAX_RENT=${FILTER_MAX_RENT:-1500}
- FILTER_MAX_MORNING_COMMUTE=${FILTER_MAX_MORNING_COMMUTE:-50}
- RETENTION_DAYS=${RETENTION_DAYS:-14}
- RETENTION_RUN_INTERVAL_SECONDS=${RETENTION_RUN_INTERVAL_SECONDS:-3600}
- PUBLIC_URL=${PUBLIC_URL:-https://flat.lab.moritz.run}
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USERNAME=${SMTP_USERNAME:-}
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
- SMTP_FROM=${SMTP_FROM:-lazyflat@localhost}
- SMTP_STARTTLS=${SMTP_STARTTLS:-true}
volumes:
- lazyflat_data:/data
# Coolify assigns the public port/domain via labels — no host port needed.
expose:
- "8000"
@ -32,36 +37,15 @@ services:
build: ./apply
container_name: lazyflat-apply
restart: unless-stopped
# Intentionally NOT exposed to the internet. Reachable only on the compose network.
expose:
- "8000"
environment:
- INTERNAL_API_KEY=${INTERNAL_API_KEY}
- HEADLESS=true
- SUBMIT_FORMS=${SUBMIT_FORMS:-False}
- LANGUAGE=${LANGUAGE:-de}
- BROWSER_WIDTH=${BROWSER_WIDTH:-600}
- BROWSER_HEIGHT=${BROWSER_HEIGHT:-800}
- BROWSER_LOCALE=${BROWSER_LOCALE:-de-DE}
- POST_SUBMISSION_SLEEP_MS=${POST_SUBMISSION_SLEEP_MS:-0}
- SALUTATION=${SALUTATION}
- LASTNAME=${LASTNAME}
- FIRSTNAME=${FIRSTNAME}
- EMAIL=${EMAIL}
- TELEPHONE=${TELEPHONE}
- STREET=${STREET}
- HOUSE_NUMBER=${HOUSE_NUMBER}
- POSTCODE=${POSTCODE}
- CITY=${CITY}
- IS_POSSESSING_WBS=${IS_POSSESSING_WBS:-False}
- WBS_TYPE=${WBS_TYPE:-0}
- WBS_VALID_TILL=${WBS_VALID_TILL:-1970-01-01}
- WBS_ROOMS=${WBS_ROOMS:-0}
- WBS_ADULTS=${WBS_ADULTS:-0}
- WBS_CHILDREN=${WBS_CHILDREN:-0}
- IS_PRIO_WBS=${IS_PRIO_WBS:-False}
- IMMOMIO_EMAIL=${IMMOMIO_EMAIL:-}
- IMMOMIO_PASSWORD=${IMMOMIO_PASSWORD:-}
alert:
build: ./alert

View file

@ -1,48 +1,71 @@
"""
lazyflat web app.
Five tabs:
- / Wohnungen (all flats, per-user match highlighting, filter block, auto-apply switch)
- /bewerbungen Bewerbungen (user's application history, forensics drill-in)
- /logs Logs (user-scoped audit log)
- /fehler Fehler (user-scoped error records + admin-global)
- /einstellungen/<section> Einstellungen: profile, filter, notifications, account, admin users
All state-changing POSTs require CSRF. Internal endpoints require INTERNAL_API_KEY.
"""
import asyncio
import hmac
import json
import logging
import sqlite3
import threading
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import Depends, FastAPI, Form, HTTPException, Header, Request, Response, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi import Depends, FastAPI, Form, Header, HTTPException, Request, Response, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import db
from apply_client import ApplyClient
import notifications
import retention
from apply_client import ApplyClient, _row_to_profile
from auth import (
bootstrap_admin,
clear_session_cookie,
current_user,
hash_password,
issue_csrf_token,
issue_session_cookie,
rate_limit_login,
require_admin,
require_csrf,
require_user,
verify_password,
)
from settings import (
APPLY_FAILURE_THRESHOLD,
AUTH_USERNAME,
FILTER_MAX_MORNING_COMMUTE,
FILTER_MAX_RENT,
FILTER_ROOMS,
INTERNAL_API_KEY,
verify_login,
)
from matching import flat_matches_filter, row_to_dict
from settings import APPLY_FAILURE_THRESHOLD, INTERNAL_API_KEY, PUBLIC_URL
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-5s %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
logging.getLogger("urllib3").setLevel(logging.WARNING)
logger = logging.getLogger("web")
apply_client = ApplyClient()
_apply_lock = threading.Lock()
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(_app: FastAPI):
db.init_db()
logger.info("web service started")
bootstrap_admin()
retention.start()
logger.info("web service ready")
yield
@ -51,31 +74,26 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# -----------------------------------------------------------------------------
# Security headers middleware
# -----------------------------------------------------------------------------
@app.middleware("http")
async def security_headers(request: Request, call_next):
response: Response = await call_next(request)
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault("Permissions-Policy", "geolocation=(), camera=(), microphone=()")
response.headers.setdefault(
resp: Response = await call_next(request)
resp.headers.setdefault("X-Frame-Options", "DENY")
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
resp.headers.setdefault("Permissions-Policy", "geolocation=(), camera=(), microphone=()")
resp.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; "
"script-src 'self' https://cdn.tailwindcss.com https://unpkg.com; "
"style-src 'self' https://cdn.tailwindcss.com 'unsafe-inline'; "
"img-src 'self' data:; "
"connect-src 'self'; "
"frame-ancestors 'none';"
"img-src 'self' data:; connect-src 'self'; frame-ancestors 'none';"
)
return response
return resp
# -----------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
# ---------------------------------------------------------------------------
def client_ip(request: Request) -> str:
xff = request.headers.get("x-forwarded-for")
@ -86,55 +104,95 @@ def client_ip(request: Request) -> str:
def require_internal(x_internal_api_key: str | None = Header(default=None)) -> None:
if not x_internal_api_key or not hmac.compare_digest(x_internal_api_key, INTERNAL_API_KEY):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid internal key")
raise HTTPException(status_code=401, detail="invalid internal key")
def matches_criteria(payload: dict) -> bool:
rooms = payload.get("rooms") or 0.0
rent = payload.get("total_rent") or 0.0
commute = (payload.get("connectivity") or {}).get("morning_time") or 0.0
if FILTER_ROOMS and rooms not in FILTER_ROOMS:
return False
if rent > FILTER_MAX_RENT:
return False
if commute > FILTER_MAX_MORNING_COMMUTE:
return False
return True
def base_context(request: Request, user, active_tab: str) -> dict:
return {
"request": request,
"user": user,
"csrf": issue_csrf_token(user["id"]),
"active_tab": active_tab,
"is_admin": bool(user["is_admin"]),
}
def apply_allowed() -> tuple[bool, str]:
if db.get_state("kill_switch") == "1":
def _get_apply_gate(user_id: int) -> tuple[bool, str]:
prefs = db.get_preferences(user_id)
if prefs["kill_switch"]:
return False, "kill switch aktiv"
if db.get_state("apply_circuit_open") == "1":
return False, "circuit breaker offen (zu viele Fehler)"
if prefs["apply_circuit_open"]:
return False, "circuit breaker offen (zu viele fehlgeschlagene Bewerbungen)"
if not apply_client.health():
return False, "apply-service nicht erreichbar"
return True, ""
def run_apply(flat_id: str, url: str, triggered_by: str) -> None:
"""Run synchronously inside a worker thread via asyncio.to_thread."""
app_id = db.start_application(flat_id, url, triggered_by)
try:
result = apply_client.apply(url)
except Exception as e:
result = {"success": False, "message": f"client error: {e}"}
def _run_apply_background(user_id: int, flat_id: str, url: str, triggered_by: str) -> None:
prefs = db.get_preferences(user_id)
profile_row = db.get_profile(user_id)
profile = _row_to_profile(profile_row)
submit_forms = bool(prefs["submit_forms"])
app_id = db.start_application(
user_id=user_id, flat_id=flat_id, url=url,
triggered_by=triggered_by, submit_forms=submit_forms,
profile_snapshot=profile,
)
logger.info("apply.start user=%s flat=%s application=%s submit=%s",
user_id, flat_id, app_id, submit_forms)
result = apply_client.apply(url=url, profile=profile,
submit_forms=submit_forms, application_id=app_id)
success = bool(result.get("success"))
db.finish_application(app_id, success, result.get("message", ""))
message = result.get("message", "")
provider = result.get("provider", "")
forensics = result.get("forensics") or {}
# Circuit breaker accounting
db.finish_application(app_id, success=success, message=message,
provider=provider, forensics=forensics)
# Circuit breaker (per user)
if success:
db.set_state("apply_recent_failures", "0")
db.update_preferences(user_id, {"apply_recent_failures": 0})
else:
fails = int(db.get_state("apply_recent_failures") or "0") + 1
db.set_state("apply_recent_failures", str(fails))
if fails >= APPLY_FAILURE_THRESHOLD:
db.set_state("apply_circuit_open", "1")
db.log_audit("system", "circuit_open", f"{fails} consecutive failures")
failures = int(prefs["apply_recent_failures"] or 0) + 1
updates = {"apply_recent_failures": failures}
if failures >= APPLY_FAILURE_THRESHOLD:
updates["apply_circuit_open"] = 1
db.log_error(source="apply", kind="circuit_open", user_id=user_id,
summary=f"{failures} aufeinanderfolgende Fehler",
application_id=app_id)
db.update_preferences(user_id, updates)
# record forensic error row
db.log_error(source="apply", kind="apply_failure", user_id=user_id,
summary=message or "Bewerbung fehlgeschlagen",
application_id=app_id,
context={"provider": provider, "url": url})
# Notify user
flat = db.get_flat(flat_id)
flat_dict = {"address": flat["address"] if flat else "", "link": url,
"rooms": flat["rooms"] if flat else None,
"total_rent": flat["total_rent"] if flat else None}
if success:
notifications.on_apply_ok(user_id, flat_dict, message)
else:
notifications.on_apply_fail(user_id, flat_dict, message)
db.log_audit("system", "apply_finished", f"app={app_id} success={success}", user_id=user_id)
# -----------------------------------------------------------------------------
def _kick_apply(user_id: int, flat_id: str, url: str, triggered_by: str) -> None:
asyncio.create_task(asyncio.to_thread(
_run_apply_background, user_id, flat_id, url, triggered_by,
))
# ---------------------------------------------------------------------------
# Public routes
# -----------------------------------------------------------------------------
# ---------------------------------------------------------------------------
@app.get("/health")
def health():
@ -153,104 +211,134 @@ def login_submit(request: Request, username: str = Form(...), password: str = Fo
ip = client_ip(request)
if not rate_limit_login(ip):
db.log_audit(username or "?", "login_rate_limited", ip=ip)
db.log_error(source="web", kind="rate_limit", summary=f"login throttled for {ip}",
context={"username": username or ""})
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "Zu viele Versuche. Bitte später erneut versuchen."},
{"request": request, "error": "Zu viele Versuche. Bitte später erneut."},
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
)
if not verify_password(username, password):
user = verify_login(username, password)
if not user:
db.log_audit(username or "?", "login_failed", ip=ip)
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "Login fehlgeschlagen."},
status_code=status.HTTP_401_UNAUTHORIZED,
)
response = RedirectResponse("/", status_code=303)
issue_session_cookie(response, username)
db.log_audit(username, "login_success", ip=ip)
issue_session_cookie(response, user["id"])
db.log_audit(user["username"], "login_success", user_id=user["id"], ip=ip)
return response
@app.post("/logout")
def logout(request: Request):
user = current_user(request) or "?"
u = current_user(request)
response = RedirectResponse("/login", status_code=303)
clear_session_cookie(response)
db.log_audit(user, "logout", ip=client_ip(request))
if u:
db.log_audit(u["username"], "logout", user_id=u["id"], ip=client_ip(request))
return response
# -----------------------------------------------------------------------------
# Authenticated dashboard
# -----------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Tab: Wohnungen
# ---------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
def dashboard(request: Request):
user = current_user(request)
if not user:
def tab_wohnungen(request: Request):
u = current_user(request)
if not u:
return RedirectResponse("/login", status_code=303)
allowed, reason = apply_allowed()
ctx = {
"request": request,
"user": user,
"csrf": issue_csrf_token(user),
"mode": db.get_state("mode") or "manual",
"kill_switch": db.get_state("kill_switch") == "1",
"circuit_open": db.get_state("apply_circuit_open") == "1",
"apply_failures": int(db.get_state("apply_recent_failures") or "0"),
"last_alert_heartbeat": db.get_state("last_alert_heartbeat") or "",
"apply_reachable": apply_client.health(),
ctx = base_context(request, u, "wohnungen")
ctx.update(_wohnungen_context(u))
return templates.TemplateResponse("wohnungen.html", ctx)
def _wohnungen_context(user) -> dict:
uid = user["id"]
filters_row = db.get_filters(uid)
prefs = db.get_preferences(uid)
filters = row_to_dict(filters_row)
flats = db.recent_flats(100)
flats_view = []
for f in flats:
try:
payload = json.loads(f["payload_json"])
except Exception:
payload = {}
last = db.last_application_for_flat(uid, f["id"])
flats_view.append({
"row": f,
"payload": payload,
"matched": flat_matches_filter({
"rooms": f["rooms"], "total_rent": f["total_rent"], "size": f["size"],
"wbs": f["wbs"], "connectivity": {"morning_time": f["connectivity_morning_time"]},
}, filters),
"last": last,
})
allowed, reason = _get_apply_gate(uid)
return {
"flats": flats_view,
"filters": filters,
"auto_apply_enabled": bool(prefs["auto_apply_enabled"]),
"submit_forms": bool(prefs["submit_forms"]),
"kill_switch": bool(prefs["kill_switch"]),
"circuit_open": bool(prefs["apply_circuit_open"]),
"apply_failures": int(prefs["apply_recent_failures"] or 0),
"apply_allowed": allowed,
"apply_block_reason": reason,
"flats": db.recent_flats(50),
"applications": db.recent_applications(20),
"audit": db.recent_audit(15),
}
return templates.TemplateResponse("dashboard.html", ctx)
@app.get("/partials/dashboard", response_class=HTMLResponse)
def dashboard_partial(request: Request, user: str = Depends(require_user)):
"""HTMX partial refresh — avoids leaking data to unauthenticated clients."""
allowed, reason = apply_allowed()
ctx = {
"request": request,
"user": user,
"csrf": issue_csrf_token(user),
"mode": db.get_state("mode") or "manual",
"kill_switch": db.get_state("kill_switch") == "1",
"circuit_open": db.get_state("apply_circuit_open") == "1",
"apply_failures": int(db.get_state("apply_recent_failures") or "0"),
"last_alert_heartbeat": db.get_state("last_alert_heartbeat") or "",
"apply_reachable": apply_client.health(),
"apply_allowed": allowed,
"apply_block_reason": reason,
"flats": db.recent_flats(50),
"applications": db.recent_applications(20),
"audit": db.recent_audit(15),
"last_alert_heartbeat": db.get_state("last_alert_heartbeat") or "",
}
return templates.TemplateResponse("_dashboard_body.html", ctx)
# -----------------------------------------------------------------------------
# State-changing actions (require auth + CSRF)
# -----------------------------------------------------------------------------
@app.post("/actions/mode")
async def action_mode(
@app.post("/actions/filters")
async def action_save_filters(
request: Request,
mode: str = Form(...),
csrf: str = Form(...),
user: str = Depends(require_user),
rooms_min: str = Form(""),
rooms_max: str = Form(""),
max_rent: str = Form(""),
min_size: str = Form(""),
max_morning_commute: str = Form(""),
wbs_required: str = Form(""),
user=Depends(require_user),
):
require_csrf(request, csrf)
if mode not in ("manual", "auto"):
raise HTTPException(400, "invalid mode")
db.set_state("mode", mode)
db.log_audit(user, "set_mode", mode, client_ip(request))
require_csrf(user["id"], csrf)
def _f(v):
v = (v or "").strip().replace(",", ".")
return float(v) if v else None
db.update_filters(user["id"], {
"rooms_min": _f(rooms_min),
"rooms_max": _f(rooms_max),
"max_rent": _f(max_rent),
"min_size": _f(min_size),
"max_morning_commute": _f(max_morning_commute),
"wbs_required": (wbs_required or "").strip(),
})
db.log_audit(user["username"], "filters.updated", user_id=user["id"], ip=client_ip(request))
return RedirectResponse("/", status_code=303)
@app.post("/actions/auto-apply")
async def action_auto_apply(
request: Request,
value: str = Form(...),
csrf: str = Form(...),
user=Depends(require_user),
):
require_csrf(user["id"], csrf)
new = 1 if value == "on" else 0
db.update_preferences(user["id"], {"auto_apply_enabled": new})
db.log_audit(user["username"], "auto_apply", "on" if new else "off",
user_id=user["id"], ip=client_ip(request))
return RedirectResponse("/", status_code=303)
@ -259,12 +347,13 @@ async def action_kill_switch(
request: Request,
value: str = Form(...),
csrf: str = Form(...),
user: str = Depends(require_user),
user=Depends(require_user),
):
require_csrf(request, csrf)
new = "1" if value == "on" else "0"
db.set_state("kill_switch", new)
db.log_audit(user, "set_kill_switch", new, client_ip(request))
require_csrf(user["id"], csrf)
new = 1 if value == "on" else 0
db.update_preferences(user["id"], {"kill_switch": new})
db.log_audit(user["username"], "kill_switch", "on" if new else "off",
user_id=user["id"], ip=client_ip(request))
return RedirectResponse("/", status_code=303)
@ -272,12 +361,11 @@ async def action_kill_switch(
async def action_reset_circuit(
request: Request,
csrf: str = Form(...),
user: str = Depends(require_user),
user=Depends(require_user),
):
require_csrf(request, csrf)
db.set_state("apply_circuit_open", "0")
db.set_state("apply_recent_failures", "0")
db.log_audit(user, "reset_circuit", "", client_ip(request))
require_csrf(user["id"], csrf)
db.update_preferences(user["id"], {"apply_circuit_open": 0, "apply_recent_failures": 0})
db.log_audit(user["username"], "reset_circuit", user_id=user["id"], ip=client_ip(request))
return RedirectResponse("/", status_code=303)
@ -286,26 +374,273 @@ async def action_apply(
request: Request,
flat_id: str = Form(...),
csrf: str = Form(...),
user: str = Depends(require_user),
user=Depends(require_user),
):
require_csrf(request, csrf)
allowed, reason = apply_allowed()
require_csrf(user["id"], csrf)
allowed, reason = _get_apply_gate(user["id"])
if not allowed:
raise HTTPException(409, f"apply disabled: {reason}")
flat = db.get_flat(flat_id)
if not flat:
raise HTTPException(404, "flat not found")
db.log_audit(user, "trigger_apply", f"flat_id={flat_id}", client_ip(request))
# Run apply in background so the UI returns fast
asyncio.create_task(asyncio.to_thread(run_apply, flat_id, flat["link"], "user"))
db.log_audit(user["username"], "trigger_apply", f"flat_id={flat_id}",
user_id=user["id"], ip=client_ip(request))
_kick_apply(user["id"], flat_id, flat["link"], "user")
return RedirectResponse("/", status_code=303)
# -----------------------------------------------------------------------------
# Internal endpoints (called by alert/apply services)
# -----------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Tab: Bewerbungen
# ---------------------------------------------------------------------------
@app.get("/bewerbungen", response_class=HTMLResponse)
def tab_bewerbungen(request: Request):
u = current_user(request)
if not u:
return RedirectResponse("/login", status_code=303)
ctx = base_context(request, u, "bewerbungen")
ctx["applications"] = db.recent_applications(u["id"], limit=100)
return templates.TemplateResponse("bewerbungen.html", ctx)
@app.get("/bewerbungen/{app_id}", response_class=HTMLResponse)
def bewerbung_detail(request: Request, app_id: int):
u = current_user(request)
if not u:
return RedirectResponse("/login", status_code=303)
a = db.get_application(app_id)
if not a or (a["user_id"] != u["id"] and not u["is_admin"]):
raise HTTPException(404, "not found")
forensics = json.loads(a["forensics_json"]) if a["forensics_json"] else None
profile = json.loads(a["profile_snapshot_json"]) if a["profile_snapshot_json"] else {}
ctx = base_context(request, u, "bewerbungen")
ctx.update({"application": a, "forensics": forensics, "profile_snapshot": profile})
return templates.TemplateResponse("bewerbung_detail.html", ctx)
# ---------------------------------------------------------------------------
# Tab: Logs
# ---------------------------------------------------------------------------
@app.get("/logs", response_class=HTMLResponse)
def tab_logs(request: Request):
u = current_user(request)
if not u:
return RedirectResponse("/login", status_code=303)
ctx = base_context(request, u, "logs")
ctx["events"] = db.recent_audit(u["id"], limit=200)
return templates.TemplateResponse("logs.html", ctx)
# ---------------------------------------------------------------------------
# Tab: Fehler
# ---------------------------------------------------------------------------
@app.get("/fehler", response_class=HTMLResponse)
def tab_fehler(request: Request):
u = current_user(request)
if not u:
return RedirectResponse("/login", status_code=303)
ctx = base_context(request, u, "fehler")
ctx["errors"] = db.recent_errors(u["id"], limit=200, include_global=bool(u["is_admin"]))
return templates.TemplateResponse("fehler.html", ctx)
@app.get("/fehler/{err_id}", response_class=HTMLResponse)
def fehler_detail(request: Request, err_id: int):
u = current_user(request)
if not u:
return RedirectResponse("/login", status_code=303)
e = db.get_error(err_id)
if not e or (e["user_id"] is not None and e["user_id"] != u["id"] and not u["is_admin"]):
raise HTTPException(404, "not found")
app_row = db.get_application(e["application_id"]) if e["application_id"] else None
forensics = None
if app_row and app_row["forensics_json"]:
try:
forensics = json.loads(app_row["forensics_json"])
except Exception:
forensics = None
context = json.loads(e["context_json"]) if e["context_json"] else None
ctx = base_context(request, u, "fehler")
ctx.update({"error": e, "application": app_row, "forensics": forensics, "context": context})
return templates.TemplateResponse("fehler_detail.html", ctx)
# ---------------------------------------------------------------------------
# Tab: Einstellungen (sub-tabs)
# ---------------------------------------------------------------------------
VALID_SECTIONS = ("profil", "filter", "benachrichtigungen", "account", "benutzer")
@app.get("/einstellungen", response_class=HTMLResponse)
def tab_settings_root(request: Request):
return RedirectResponse("/einstellungen/profil", status_code=303)
@app.get("/einstellungen/{section}", response_class=HTMLResponse)
def tab_settings(request: Request, section: str):
u = current_user(request)
if not u:
return RedirectResponse("/login", status_code=303)
if section not in VALID_SECTIONS:
raise HTTPException(404)
if section == "benutzer" and not u["is_admin"]:
raise HTTPException(403)
ctx = base_context(request, u, "einstellungen")
ctx["section"] = section
if section == "profil":
ctx["profile"] = db.get_profile(u["id"])
elif section == "filter":
ctx["filters"] = row_to_dict(db.get_filters(u["id"]))
elif section == "benachrichtigungen":
ctx["notifications"] = db.get_notifications(u["id"])
elif section == "account":
pass
elif section == "benutzer":
ctx["users"] = db.list_users()
return templates.TemplateResponse("einstellungen.html", ctx)
@app.post("/actions/profile")
async def action_profile(request: Request, user=Depends(require_user)):
form = await request.form()
require_csrf(user["id"], form.get("csrf", ""))
def _b(name): return form.get(name, "").lower() in ("true", "on", "yes", "1")
def _i(name):
try: return int(form.get(name) or 0)
except ValueError: return 0
db.update_profile(user["id"], {
"salutation": form.get("salutation", ""),
"firstname": form.get("firstname", ""),
"lastname": form.get("lastname", ""),
"email": form.get("email", ""),
"telephone": form.get("telephone", ""),
"street": form.get("street", ""),
"house_number": form.get("house_number", ""),
"postcode": form.get("postcode", ""),
"city": form.get("city", ""),
"is_possessing_wbs": 1 if _b("is_possessing_wbs") else 0,
"wbs_type": form.get("wbs_type", "0"),
"wbs_valid_till": form.get("wbs_valid_till", "1970-01-01"),
"wbs_rooms": _i("wbs_rooms"),
"wbs_adults": _i("wbs_adults"),
"wbs_children": _i("wbs_children"),
"is_prio_wbs": 1 if _b("is_prio_wbs") else 0,
"immomio_email": form.get("immomio_email", ""),
"immomio_password": form.get("immomio_password", ""),
})
db.log_audit(user["username"], "profile.updated", user_id=user["id"], ip=client_ip(request))
return RedirectResponse("/einstellungen/profil", status_code=303)
@app.post("/actions/notifications")
async def action_notifications(request: Request, user=Depends(require_user)):
form = await request.form()
require_csrf(user["id"], form.get("csrf", ""))
def _b(n): return 1 if form.get(n, "").lower() in ("on", "true", "1", "yes") else 0
db.update_notifications(user["id"], {
"channel": form.get("channel", "ui"),
"telegram_bot_token": form.get("telegram_bot_token", ""),
"telegram_chat_id": form.get("telegram_chat_id", ""),
"email_address": form.get("email_address", ""),
"notify_on_match": _b("notify_on_match"),
"notify_on_apply_success": _b("notify_on_apply_success"),
"notify_on_apply_fail": _b("notify_on_apply_fail"),
})
db.log_audit(user["username"], "notifications.updated", user_id=user["id"], ip=client_ip(request))
return RedirectResponse("/einstellungen/benachrichtigungen", status_code=303)
@app.post("/actions/account/password")
async def action_password(
request: Request,
old_password: str = Form(""),
new_password: str = Form(""),
new_password_repeat: str = Form(""),
csrf: str = Form(...),
user=Depends(require_user),
):
require_csrf(user["id"], csrf)
if not new_password or new_password != new_password_repeat:
return RedirectResponse("/einstellungen/account?err=mismatch", status_code=303)
if len(new_password) < 10:
return RedirectResponse("/einstellungen/account?err=tooshort", status_code=303)
row = db.get_user_by_username(user["username"])
from auth import verify_hash
if not row or not verify_hash(row["password_hash"], old_password):
return RedirectResponse("/einstellungen/account?err=wrongold", status_code=303)
db.set_user_password(user["id"], hash_password(new_password))
db.log_audit(user["username"], "password.changed", user_id=user["id"], ip=client_ip(request))
return RedirectResponse("/einstellungen/account?ok=1", status_code=303)
@app.post("/actions/submit-forms")
async def action_submit_forms(
request: Request,
value: str = Form(...),
csrf: str = Form(...),
user=Depends(require_user),
):
require_csrf(user["id"], csrf)
new = 1 if value == "on" else 0
db.update_preferences(user["id"], {"submit_forms": new})
db.log_audit(user["username"], "submit_forms", "on" if new else "off",
user_id=user["id"], ip=client_ip(request))
return RedirectResponse("/einstellungen/profil", status_code=303)
# --- Admin: Benutzer ---------------------------------------------------------
@app.post("/actions/users/create")
async def action_users_create(
request: Request,
username: str = Form(...),
password: str = Form(...),
is_admin: str = Form(""),
csrf: str = Form(...),
admin=Depends(require_admin),
):
require_csrf(admin["id"], csrf)
username = (username or "").strip()
if not username or len(password) < 10:
raise HTTPException(400, "username required, password >= 10 chars")
try:
uid = db.create_user(username, hash_password(password),
is_admin=(is_admin.lower() in ("on", "true", "yes", "1")))
except sqlite3.IntegrityError:
return RedirectResponse("/einstellungen/benutzer?err=exists", status_code=303)
db.log_audit(admin["username"], "user.created", f"new_user={username} id={uid}",
user_id=admin["id"], ip=client_ip(request))
return RedirectResponse("/einstellungen/benutzer?ok=1", status_code=303)
@app.post("/actions/users/disable")
async def action_users_disable(
request: Request,
target_id: int = Form(...),
value: str = Form(...),
csrf: str = Form(...),
admin=Depends(require_admin),
):
require_csrf(admin["id"], csrf)
if target_id == admin["id"]:
raise HTTPException(400, "refusing to disable self")
db.set_user_disabled(target_id, value == "on")
db.log_audit(admin["username"], "user.toggle_disable",
f"target={target_id} disabled={value=='on'}",
user_id=admin["id"], ip=client_ip(request))
return RedirectResponse("/einstellungen/benutzer", status_code=303)
# ---------------------------------------------------------------------------
# Internal endpoints
# ---------------------------------------------------------------------------
@app.post("/internal/flats")
async def internal_submit_flat(
@ -314,28 +649,51 @@ async def internal_submit_flat(
):
if not payload.get("id") or not payload.get("link"):
raise HTTPException(400, "id and link required")
matched = matches_criteria(payload)
is_new = db.upsert_flat(payload, matched)
is_new = db.upsert_flat(payload)
if not is_new:
return {"status": "duplicate"}
if matched:
db.log_audit("alert", "flat_matched", f"id={payload['id']} rent={payload.get('total_rent')}")
if db.get_state("mode") == "auto":
allowed, reason = apply_allowed()
if allowed:
db.log_audit("system", "auto_apply", f"flat_id={payload['id']}")
asyncio.create_task(asyncio.to_thread(run_apply, str(payload["id"]), payload["link"], "auto"))
else:
db.log_audit("system", "auto_apply_blocked", reason)
return {"status": "ok", "matched": matched}
# per-user matching + auto-apply + notifications
for u in db.list_users():
if u["disabled"]:
continue
filters = row_to_dict(db.get_filters(u["id"]))
if not flat_matches_filter(payload, filters):
continue
db.log_audit("alert", "flat_matched",
f"user={u['username']} flat={payload['id']}",
user_id=u["id"])
notifications.on_match(u["id"], payload)
prefs = db.get_preferences(u["id"])
if prefs["auto_apply_enabled"] and not prefs["kill_switch"] and not prefs["apply_circuit_open"]:
_kick_apply(u["id"], str(payload["id"]), payload["link"], "auto")
db.log_audit("system", "auto_apply_kick",
f"user={u['username']} flat={payload['id']}",
user_id=u["id"])
return {"status": "ok"}
@app.post("/internal/heartbeat")
async def internal_heartbeat(
payload: dict,
_guard: None = Depends(require_internal),
):
async def internal_heartbeat(payload: dict, _g: None = Depends(require_internal)):
service = payload.get("service", "unknown")
db.set_state(f"last_{service}_heartbeat", db.now_iso())
return {"status": "ok"}
@app.post("/internal/error")
async def internal_report_error(
payload: dict,
_g: None = Depends(require_internal),
):
"""Alert/other services can push errors here."""
db.log_error(
source=payload.get("source", "unknown"),
kind=payload.get("kind", "error"),
summary=payload.get("summary", ""),
context=payload.get("context"),
)
return {"status": "ok"}

View file

@ -1,9 +1,31 @@
import logging
import requests
from settings import APPLY_URL, APPLY_TIMEOUT, INTERNAL_API_KEY
from settings import APPLY_TIMEOUT, APPLY_URL, INTERNAL_API_KEY
logger = logging.getLogger("web")
logger = logging.getLogger("web.apply_client")
def _row_to_profile(profile_row) -> dict:
"""Convert a user_profiles row to the apply service Profile dict."""
if profile_row is None:
return {}
keys = [
"salutation", "firstname", "lastname", "email", "telephone",
"street", "house_number", "postcode", "city",
"is_possessing_wbs", "wbs_type", "wbs_valid_till",
"wbs_rooms", "wbs_adults", "wbs_children", "is_prio_wbs",
"immomio_email", "immomio_password",
]
d = {}
for k in keys:
try:
d[k] = profile_row[k]
except (KeyError, IndexError):
pass
for k in ("is_possessing_wbs", "is_prio_wbs"):
d[k] = bool(d.get(k) or 0)
return d
class ApplyClient:
@ -19,16 +41,24 @@ class ApplyClient:
except requests.RequestException:
return False
def apply(self, url: str) -> dict:
def apply(self, url: str, profile: dict, submit_forms: bool,
application_id: int | None = None) -> dict:
body = {
"url": url,
"profile": profile,
"submit_forms": bool(submit_forms),
"application_id": application_id,
}
try:
r = requests.post(
f"{self.base}/apply",
json={"url": url},
headers=self.headers,
timeout=self.timeout,
f"{self.base}/apply", json=body,
headers=self.headers, timeout=self.timeout,
)
if r.status_code >= 400:
return {"success": False, "message": f"apply HTTP {r.status_code}: {r.text[:300]}"}
return {"success": False,
"message": f"apply HTTP {r.status_code}: {r.text[:300]}",
"provider": "", "forensics": {}}
return r.json()
except requests.RequestException as e:
return {"success": False, "message": f"apply unreachable: {e}"}
return {"success": False, "message": f"apply unreachable: {e}",
"provider": "", "forensics": {}}

View file

@ -1,14 +1,24 @@
"""
Authentication and session handling.
- Users live in the DB (users table).
- On boot we seed an admin from env if no user exists yet.
- Sessions are signed cookies (itsdangerous); CSRF is a separate signed token.
- Login is rate-limited per IP.
"""
import hmac
import secrets
import logging
import sqlite3
import threading
import time
from typing import Optional
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHash
from argon2.exceptions import InvalidHash, VerifyMismatchError
from fastapi import HTTPException, Request, Response, status
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
import db
from settings import (
AUTH_PASSWORD_HASH,
AUTH_USERNAME,
@ -20,30 +30,63 @@ from settings import (
SESSION_SECRET,
)
logger = logging.getLogger("web.auth")
_hasher = PasswordHasher()
_serializer = URLSafeTimedSerializer(SESSION_SECRET, salt="session")
_csrf_serializer = URLSafeTimedSerializer(SESSION_SECRET, salt="csrf")
# ---------- Password & session ----------
# ---------- Password hashing --------------------------------------------------
def verify_password(username: str, password: str) -> bool:
if not hmac.compare_digest(username or "", AUTH_USERNAME):
# run hasher anyway to keep timing similar (and not leak whether user exists)
try:
_hasher.verify(AUTH_PASSWORD_HASH, password)
except Exception:
pass
return False
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_hash(password_hash: str, password: str) -> bool:
try:
_hasher.verify(AUTH_PASSWORD_HASH, password)
_hasher.verify(password_hash, password)
return True
except (VerifyMismatchError, InvalidHash):
return False
def issue_session_cookie(response: Response, username: str) -> None:
token = _serializer.dumps({"u": username, "iat": int(time.time())})
# ---------- Admin bootstrap ---------------------------------------------------
def bootstrap_admin() -> None:
"""If no users exist, create one from env vars as admin."""
existing = db.list_users()
if existing:
logger.info("users already present (%d), skipping admin bootstrap", len(existing))
return
try:
uid = db.create_user(AUTH_USERNAME, AUTH_PASSWORD_HASH, is_admin=True)
logger.info("bootstrapped admin user '%s' (id=%s) from env", AUTH_USERNAME, uid)
except sqlite3.IntegrityError as e:
logger.warning("bootstrap admin failed: %s", e)
# ---------- Login -------------------------------------------------------------
def verify_login(username: str, password: str) -> Optional[sqlite3.Row]:
"""Return user row on success, None otherwise. Timing-safe path runs hash check either way."""
user = db.get_user_by_username(username or "")
if user is None:
# Run the hasher anyway so timing doesn't leak existence
try:
_hasher.verify(AUTH_PASSWORD_HASH, password or "")
except Exception:
pass
return None
if not verify_hash(user["password_hash"], password or ""):
return None
return user
# ---------- Session cookies ---------------------------------------------------
def issue_session_cookie(response: Response, user_id: int) -> None:
token = _serializer.dumps({"uid": user_id, "iat": int(time.time())})
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
@ -57,15 +100,12 @@ def issue_session_cookie(response: Response, username: str) -> None:
def clear_session_cookie(response: Response) -> None:
response.delete_cookie(
SESSION_COOKIE_NAME,
path="/",
secure=COOKIE_SECURE,
httponly=True,
samesite="strict",
SESSION_COOKIE_NAME, path="/",
secure=COOKIE_SECURE, httponly=True, samesite="strict",
)
def current_user(request: Request) -> Optional[str]:
def current_user(request: Request) -> Optional[sqlite3.Row]:
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
return None
@ -73,46 +113,57 @@ def current_user(request: Request) -> Optional[str]:
data = _serializer.loads(token, max_age=SESSION_MAX_AGE_SECONDS)
except (BadSignature, SignatureExpired):
return None
return data.get("u")
uid = data.get("uid")
if not uid:
return None
u = db.get_user(uid)
if u is None or u["disabled"]:
return None
return u
def require_user(request: Request) -> str:
user = current_user(request)
if not user:
def require_user(request: Request) -> sqlite3.Row:
u = current_user(request)
if u is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="login required")
return user
return u
# ---------- CSRF (synchronizer token bound to session) ----------
def issue_csrf_token(username: str) -> str:
return _csrf_serializer.dumps({"u": username})
def require_admin(request: Request) -> sqlite3.Row:
u = require_user(request)
if not u["is_admin"]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin only")
return u
def verify_csrf(request: Request, submitted: str) -> bool:
user = current_user(request)
if not user or not submitted:
# ---------- CSRF --------------------------------------------------------------
def issue_csrf_token(user_id: int) -> str:
return _csrf_serializer.dumps({"uid": user_id})
def verify_csrf(user_id: int, submitted: str) -> bool:
if not submitted:
return False
try:
data = _csrf_serializer.loads(submitted, max_age=SESSION_MAX_AGE_SECONDS)
except (BadSignature, SignatureExpired):
return False
return hmac.compare_digest(str(data.get("u", "")), user)
return int(data.get("uid", -1)) == int(user_id)
def require_csrf(request: Request, token: str) -> None:
if not verify_csrf(request, token):
def require_csrf(user_id: int, token: str) -> None:
if not verify_csrf(user_id, token):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad csrf")
# ---------- Login rate limiting (in-memory, per IP) ----------
# ---------- Login rate limiting ----------------------------------------------
_rate_lock = threading.Lock()
_rate_log: dict[str, list[float]] = {}
def rate_limit_login(ip: str) -> bool:
"""Returns True if the request is allowed."""
now = time.time()
cutoff = now - LOGIN_RATE_WINDOW_SECONDS
with _rate_lock:
@ -122,13 +173,8 @@ def rate_limit_login(ip: str) -> bool:
return False
attempts.append(now)
_rate_log[ip] = attempts
# opportunistic cleanup
if len(_rate_log) > 1024:
for k in list(_rate_log.keys()):
if not _rate_log[k] or _rate_log[k][-1] < cutoff:
_rate_log.pop(k, None)
return True
def constant_time_compare(a: str, b: str) -> bool:
return hmac.compare_digest(a or "", b or "")

602
web/db.py
View file

@ -1,10 +1,20 @@
"""
SQLite data layer for lazyflat.
Multi-user: users, per-user profiles/filters/notifications/preferences.
All per-user rows are 1:1 with users. Errors and forensics are retained
for 14 days and cleaned up periodically.
"""
import json
import logging
import sqlite3
import threading
from datetime import datetime, timezone
from typing import Any, Iterable
from datetime import datetime, timedelta, timezone
from typing import Any, Iterable, Optional
from settings import DB_PATH
from settings import DB_PATH, RETENTION_DAYS
logger = logging.getLogger("web.db")
_lock = threading.Lock()
@ -20,126 +30,374 @@ def _connect() -> sqlite3.Connection:
_conn: sqlite3.Connection = _connect()
SCHEMA = """
CREATE TABLE IF NOT EXISTS flats (
id TEXT PRIMARY KEY,
link TEXT NOT NULL,
address TEXT,
rooms REAL,
size REAL,
total_rent REAL,
sqm_price REAL,
year_built TEXT,
wbs TEXT,
connectivity_morning_time REAL,
connectivity_night_time REAL,
address_link_gmaps TEXT,
payload_json TEXT NOT NULL,
matched_criteria INTEGER NOT NULL DEFAULT 0,
discovered_at TEXT NOT NULL
);
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS idx_flats_discovered ON flats(discovered_at DESC);
CREATE INDEX IF NOT EXISTS idx_flats_matched ON flats(matched_criteria);
MIGRATIONS: list[str] = [
# 0001: drop legacy single-user tables if present (only matters on upgrade
# from the pre-multi-user commit; no production data yet). Safe for fresh DBs.
"""
DROP TABLE IF EXISTS applications;
DROP TABLE IF EXISTS flats;
DROP TABLE IF EXISTS state;
DROP TABLE IF EXISTS audit_log;
""",
# 0002: base + users
"""
CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);
CREATE TABLE IF NOT EXISTS applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
flat_id TEXT NOT NULL,
url TEXT NOT NULL,
triggered_by TEXT NOT NULL, -- 'user' | 'auto'
started_at TEXT NOT NULL,
finished_at TEXT,
success INTEGER,
message TEXT,
FOREIGN KEY (flat_id) REFERENCES flats(id)
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_applications_flat ON applications(flat_id);
CREATE INDEX IF NOT EXISTS idx_applications_started ON applications(started_at DESC);
CREATE TABLE IF NOT EXISTS user_profiles (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
salutation TEXT DEFAULT '',
firstname TEXT DEFAULT '',
lastname TEXT DEFAULT '',
email TEXT DEFAULT '',
telephone TEXT DEFAULT '',
street TEXT DEFAULT '',
house_number TEXT DEFAULT '',
postcode TEXT DEFAULT '',
city TEXT DEFAULT '',
is_possessing_wbs INTEGER NOT NULL DEFAULT 0,
wbs_type TEXT DEFAULT '0',
wbs_valid_till TEXT DEFAULT '1970-01-01',
wbs_rooms INTEGER NOT NULL DEFAULT 0,
wbs_adults INTEGER NOT NULL DEFAULT 0,
wbs_children INTEGER NOT NULL DEFAULT 0,
is_prio_wbs INTEGER NOT NULL DEFAULT 0,
immomio_email TEXT DEFAULT '',
immomio_password TEXT DEFAULT '',
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_filters (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
rooms_min REAL,
rooms_max REAL,
max_rent REAL,
min_size REAL,
max_morning_commute REAL,
wbs_required TEXT DEFAULT '', -- '', 'yes', 'no'
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
actor TEXT NOT NULL,
action TEXT NOT NULL,
details TEXT,
ip TEXT
);
CREATE TABLE IF NOT EXISTS user_notifications (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
channel TEXT NOT NULL DEFAULT 'ui', -- 'ui' | 'telegram' | 'email'
telegram_bot_token TEXT DEFAULT '',
telegram_chat_id TEXT DEFAULT '',
email_address TEXT DEFAULT '',
notify_on_match INTEGER NOT NULL DEFAULT 1,
notify_on_apply_success INTEGER NOT NULL DEFAULT 1,
notify_on_apply_fail INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp DESC);
"""
CREATE TABLE IF NOT EXISTS user_preferences (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
auto_apply_enabled INTEGER NOT NULL DEFAULT 0,
submit_forms INTEGER NOT NULL DEFAULT 0,
kill_switch INTEGER NOT NULL DEFAULT 0,
apply_circuit_open INTEGER NOT NULL DEFAULT 0,
apply_recent_failures INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
DEFAULTS = {
"mode": "manual", # 'manual' | 'auto'
"kill_switch": "0", # '1' = apply disabled
"apply_circuit_open": "0", # '1' = opened by circuit breaker
"apply_recent_failures": "0",
"last_alert_heartbeat": "",
"last_apply_heartbeat": "",
}
CREATE TABLE IF NOT EXISTS flats (
id TEXT PRIMARY KEY,
link TEXT NOT NULL,
address TEXT,
rooms REAL,
size REAL,
total_rent REAL,
sqm_price REAL,
year_built TEXT,
wbs TEXT,
connectivity_morning_time REAL,
connectivity_night_time REAL,
address_link_gmaps TEXT,
payload_json TEXT NOT NULL,
discovered_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_flats_discovered ON flats(discovered_at DESC);
CREATE TABLE IF NOT EXISTS applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
flat_id TEXT NOT NULL REFERENCES flats(id),
url TEXT NOT NULL,
triggered_by TEXT NOT NULL, -- 'user' | 'auto'
submit_forms_used INTEGER NOT NULL DEFAULT 0,
provider TEXT DEFAULT '',
started_at TEXT NOT NULL,
finished_at TEXT,
success INTEGER,
message TEXT,
profile_snapshot_json TEXT,
forensics_json TEXT -- structured payload from apply service
);
CREATE INDEX IF NOT EXISTS idx_applications_user ON applications(user_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_applications_started ON applications(started_at DESC);
CREATE INDEX IF NOT EXISTS idx_applications_flat ON applications(flat_id);
CREATE TABLE IF NOT EXISTS errors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
user_id INTEGER,
source TEXT NOT NULL, -- 'apply'|'alert'|'web'|'system'
kind TEXT NOT NULL, -- 'apply_failure'|'scraper_error'|...
summary TEXT NOT NULL,
application_id INTEGER,
context_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_errors_timestamp ON errors(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_errors_user ON errors(user_id, timestamp DESC);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
user_id INTEGER,
actor TEXT NOT NULL,
action TEXT NOT NULL,
details TEXT,
ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id, timestamp DESC);
CREATE TABLE IF NOT EXISTS system_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
""",
]
def _current_version() -> int:
try:
row = _conn.execute("SELECT COALESCE(MAX(version), 0) AS v FROM schema_version").fetchone()
return int(row["v"]) if row else 0
except sqlite3.Error:
return 0
def init_db() -> None:
with _lock:
_conn.executescript(SCHEMA)
for k, v in DEFAULTS.items():
_conn.execute("INSERT OR IGNORE INTO state(key, value) VALUES (?, ?)", (k, v))
_conn.execute("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)")
current = _current_version()
for i, script in enumerate(MIGRATIONS, start=1):
if i <= current:
continue
logger.info("applying migration v%d", i)
_conn.executescript(script)
_conn.execute("INSERT OR IGNORE INTO schema_version(version) VALUES (?)", (i,))
_conn.execute(
"INSERT OR IGNORE INTO system_state(key, value) VALUES ('last_alert_heartbeat', '')"
)
logger.info("DB initialized (schema v%d)", _current_version())
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def get_state(key: str) -> str | None:
row = _conn.execute("SELECT value FROM state WHERE key = ?", (key,)).fetchone()
# ---------------------------------------------------------------------------
# System state
# ---------------------------------------------------------------------------
def get_state(key: str) -> Optional[str]:
row = _conn.execute("SELECT value FROM system_state WHERE key = ?", (key,)).fetchone()
return row["value"] if row else None
def set_state(key: str, value: str) -> None:
with _lock:
_conn.execute(
"INSERT INTO state(key, value) VALUES (?, ?) "
"INSERT INTO system_state(key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, value),
)
def upsert_flat(payload: dict, matched: bool) -> bool:
"""Returns True if this flat is new."""
# ---------------------------------------------------------------------------
# Users
# ---------------------------------------------------------------------------
def _ensure_user_rows(user_id: int) -> None:
ts = now_iso()
with _lock:
for q in (
"INSERT OR IGNORE INTO user_profiles(user_id, updated_at) VALUES (?, ?)",
"INSERT OR IGNORE INTO user_filters(user_id, updated_at) VALUES (?, ?)",
"INSERT OR IGNORE INTO user_notifications(user_id, updated_at) VALUES (?, ?)",
"INSERT OR IGNORE INTO user_preferences(user_id, updated_at) VALUES (?, ?)",
):
_conn.execute(q, (user_id, ts))
def create_user(username: str, password_hash: str, is_admin: bool = False) -> int:
ts = now_iso()
with _lock:
cur = _conn.execute(
"INSERT INTO users(username, password_hash, is_admin, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?)",
(username, password_hash, 1 if is_admin else 0, ts, ts),
)
uid = cur.lastrowid
_ensure_user_rows(uid)
logger.info("created user id=%s username=%s admin=%s", uid, username, is_admin)
return uid
def get_user_by_username(username: str) -> Optional[sqlite3.Row]:
return _conn.execute(
"SELECT * FROM users WHERE username = ? COLLATE NOCASE AND disabled = 0", (username,)
).fetchone()
def get_user(user_id: int) -> Optional[sqlite3.Row]:
return _conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
def list_users() -> list[sqlite3.Row]:
return list(_conn.execute("SELECT * FROM users ORDER BY username").fetchall())
def set_user_password(user_id: int, password_hash: str) -> None:
with _lock:
_conn.execute(
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?",
(password_hash, now_iso(), user_id),
)
def set_user_disabled(user_id: int, disabled: bool) -> None:
with _lock:
_conn.execute(
"UPDATE users SET disabled = ?, updated_at = ? WHERE id = ?",
(1 if disabled else 0, now_iso(), user_id),
)
# ---------------------------------------------------------------------------
# User profile / filters / notifications / preferences
# ---------------------------------------------------------------------------
def get_profile(user_id: int) -> sqlite3.Row:
_ensure_user_rows(user_id)
return _conn.execute("SELECT * FROM user_profiles WHERE user_id = ?", (user_id,)).fetchone()
def update_profile(user_id: int, data: dict) -> None:
_ensure_user_rows(user_id)
allowed = {
"salutation", "firstname", "lastname", "email", "telephone",
"street", "house_number", "postcode", "city",
"is_possessing_wbs", "wbs_type", "wbs_valid_till",
"wbs_rooms", "wbs_adults", "wbs_children", "is_prio_wbs",
"immomio_email", "immomio_password",
}
clean = {k: v for k, v in data.items() if k in allowed}
if not clean:
return
cols = ", ".join(f"{k} = ?" for k in clean)
vals = list(clean.values()) + [now_iso(), user_id]
with _lock:
_conn.execute(f"UPDATE user_profiles SET {cols}, updated_at = ? WHERE user_id = ?", vals)
def get_filters(user_id: int) -> sqlite3.Row:
_ensure_user_rows(user_id)
return _conn.execute("SELECT * FROM user_filters WHERE user_id = ?", (user_id,)).fetchone()
def update_filters(user_id: int, data: dict) -> None:
_ensure_user_rows(user_id)
allowed = {"rooms_min", "rooms_max", "max_rent", "min_size", "max_morning_commute", "wbs_required"}
clean = {k: data.get(k) for k in allowed if k in data}
if not clean:
return
cols = ", ".join(f"{k} = ?" for k in clean)
vals = list(clean.values()) + [now_iso(), user_id]
with _lock:
_conn.execute(f"UPDATE user_filters SET {cols}, updated_at = ? WHERE user_id = ?", vals)
def get_notifications(user_id: int) -> sqlite3.Row:
_ensure_user_rows(user_id)
return _conn.execute("SELECT * FROM user_notifications WHERE user_id = ?", (user_id,)).fetchone()
def update_notifications(user_id: int, data: dict) -> None:
_ensure_user_rows(user_id)
allowed = {
"channel", "telegram_bot_token", "telegram_chat_id", "email_address",
"notify_on_match", "notify_on_apply_success", "notify_on_apply_fail",
}
clean = {k: v for k, v in data.items() if k in allowed}
if not clean:
return
cols = ", ".join(f"{k} = ?" for k in clean)
vals = list(clean.values()) + [now_iso(), user_id]
with _lock:
_conn.execute(f"UPDATE user_notifications SET {cols}, updated_at = ? WHERE user_id = ?", vals)
def get_preferences(user_id: int) -> sqlite3.Row:
_ensure_user_rows(user_id)
return _conn.execute("SELECT * FROM user_preferences WHERE user_id = ?", (user_id,)).fetchone()
def update_preferences(user_id: int, data: dict) -> None:
_ensure_user_rows(user_id)
allowed = {
"auto_apply_enabled", "submit_forms", "kill_switch",
"apply_circuit_open", "apply_recent_failures",
}
clean = {k: v for k, v in data.items() if k in allowed}
if not clean:
return
cols = ", ".join(f"{k} = ?" for k in clean)
vals = list(clean.values()) + [now_iso(), user_id]
with _lock:
_conn.execute(f"UPDATE user_preferences SET {cols}, updated_at = ? WHERE user_id = ?", vals)
# ---------------------------------------------------------------------------
# Flats
# ---------------------------------------------------------------------------
def upsert_flat(payload: dict) -> bool:
flat_id = str(payload["id"])
conn_info = payload.get("connectivity") or {}
with _lock:
existing = _conn.execute("SELECT id FROM flats WHERE id = ?", (flat_id,)).fetchone()
if existing:
return False
c = payload.get("connectivity") or {}
_conn.execute(
"""
INSERT INTO flats(
"""INSERT INTO flats(
id, link, address, rooms, size, total_rent, sqm_price, year_built, wbs,
connectivity_morning_time, connectivity_night_time, address_link_gmaps,
payload_json, matched_criteria, discovered_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
payload_json, discovered_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
flat_id,
payload.get("link", ""),
payload.get("address", ""),
payload.get("rooms"),
payload.get("size"),
payload.get("total_rent"),
payload.get("sqm_price"),
str(payload.get("year_built", "")),
flat_id, payload.get("link", ""), payload.get("address", ""),
payload.get("rooms"), payload.get("size"), payload.get("total_rent"),
payload.get("sqm_price"), str(payload.get("year_built", "")),
str(payload.get("wbs", "")),
conn_info.get("morning_time"),
conn_info.get("night_time"),
c.get("morning_time"), c.get("night_time"),
payload.get("address_link_gmaps"),
json.dumps(payload, default=str),
1 if matched else 0,
now_iso(),
),
)
@ -147,73 +405,157 @@ def upsert_flat(payload: dict, matched: bool) -> bool:
def recent_flats(limit: int = 50) -> list[sqlite3.Row]:
return list(
_conn.execute(
"""
SELECT f.*,
(SELECT success FROM applications a WHERE a.flat_id = f.id
ORDER BY a.started_at DESC LIMIT 1) AS last_application_success,
(SELECT message FROM applications a WHERE a.flat_id = f.id
ORDER BY a.started_at DESC LIMIT 1) AS last_application_message,
(SELECT started_at FROM applications a WHERE a.flat_id = f.id
ORDER BY a.started_at DESC LIMIT 1) AS last_application_at
FROM flats f
ORDER BY f.discovered_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
)
return list(_conn.execute(
"SELECT * FROM flats ORDER BY discovered_at DESC LIMIT ?", (limit,)
).fetchall())
def get_flat(flat_id: str) -> sqlite3.Row | None:
def get_flat(flat_id: str) -> Optional[sqlite3.Row]:
return _conn.execute("SELECT * FROM flats WHERE id = ?", (flat_id,)).fetchone()
def start_application(flat_id: str, url: str, triggered_by: str) -> int:
# ---------------------------------------------------------------------------
# Applications
# ---------------------------------------------------------------------------
def start_application(user_id: int, flat_id: str, url: str, triggered_by: str,
submit_forms: bool, profile_snapshot: dict) -> int:
with _lock:
cur = _conn.execute(
"INSERT INTO applications(flat_id, url, triggered_by, started_at) VALUES (?, ?, ?, ?)",
(flat_id, url, triggered_by, now_iso()),
"""INSERT INTO applications(
user_id, flat_id, url, triggered_by, submit_forms_used,
started_at, profile_snapshot_json
) VALUES (?, ?, ?, ?, ?, ?, ?)""",
(user_id, flat_id, url, triggered_by, 1 if submit_forms else 0,
now_iso(), json.dumps(profile_snapshot)),
)
return cur.lastrowid
def finish_application(app_id: int, success: bool, message: str) -> None:
def finish_application(app_id: int, success: bool, message: str,
provider: str = "", forensics: Optional[dict] = None) -> None:
with _lock:
_conn.execute(
"UPDATE applications SET finished_at = ?, success = ?, message = ? WHERE id = ?",
(now_iso(), 1 if success else 0, message, app_id),
"""UPDATE applications SET finished_at = ?, success = ?, message = ?,
provider = ?, forensics_json = ?
WHERE id = ?""",
(now_iso(), 1 if success else 0, message, provider,
json.dumps(forensics) if forensics is not None else None,
app_id),
)
def recent_applications(limit: int = 20) -> list[sqlite3.Row]:
return list(
_conn.execute(
"""
SELECT a.*, f.address, f.link
FROM applications a
JOIN flats f ON f.id = a.flat_id
ORDER BY a.started_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
)
def get_application(app_id: int) -> Optional[sqlite3.Row]:
return _conn.execute("SELECT * FROM applications WHERE id = ?", (app_id,)).fetchone()
def log_audit(actor: str, action: str, details: str = "", ip: str = "") -> None:
def recent_applications(user_id: Optional[int], limit: int = 50) -> list[sqlite3.Row]:
if user_id is None:
return list(_conn.execute(
"""SELECT a.*, f.address, f.link
FROM applications a LEFT JOIN flats f ON f.id = a.flat_id
ORDER BY a.started_at DESC LIMIT ?""", (limit,)
).fetchall())
return list(_conn.execute(
"""SELECT a.*, f.address, f.link
FROM applications a LEFT JOIN flats f ON f.id = a.flat_id
WHERE a.user_id = ?
ORDER BY a.started_at DESC LIMIT ?""",
(user_id, limit),
).fetchall())
def last_application_for_flat(user_id: int, flat_id: str) -> Optional[sqlite3.Row]:
return _conn.execute(
"""SELECT * FROM applications
WHERE user_id = ? AND flat_id = ?
ORDER BY started_at DESC LIMIT 1""",
(user_id, flat_id),
).fetchone()
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
def log_error(source: str, kind: str, summary: str,
user_id: Optional[int] = None, application_id: Optional[int] = None,
context: Optional[dict] = None) -> int:
with _lock:
cur = _conn.execute(
"""INSERT INTO errors(timestamp, user_id, source, kind, summary, application_id, context_json)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(now_iso(), user_id, source, kind, summary, application_id,
json.dumps(context) if context else None),
)
return cur.lastrowid
def recent_errors(user_id: Optional[int], limit: int = 100,
include_global: bool = False) -> list[sqlite3.Row]:
if user_id is None:
return list(_conn.execute(
"SELECT * FROM errors ORDER BY timestamp DESC LIMIT ?", (limit,)
).fetchall())
if include_global:
return list(_conn.execute(
"""SELECT * FROM errors
WHERE user_id = ? OR user_id IS NULL
ORDER BY timestamp DESC LIMIT ?""",
(user_id, limit),
).fetchall())
return list(_conn.execute(
"SELECT * FROM errors WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?",
(user_id, limit),
).fetchall())
def get_error(error_id: int) -> Optional[sqlite3.Row]:
return _conn.execute("SELECT * FROM errors WHERE id = ?", (error_id,)).fetchone()
# ---------------------------------------------------------------------------
# Audit log
# ---------------------------------------------------------------------------
def log_audit(actor: str, action: str, details: str = "",
user_id: Optional[int] = None, ip: str = "") -> None:
with _lock:
_conn.execute(
"INSERT INTO audit_log(timestamp, actor, action, details, ip) VALUES (?, ?, ?, ?, ?)",
(now_iso(), actor, action, details, ip),
"INSERT INTO audit_log(timestamp, user_id, actor, action, details, ip) "
"VALUES (?, ?, ?, ?, ?, ?)",
(now_iso(), user_id, actor, action, details, ip),
)
def recent_audit(limit: int = 30) -> list[sqlite3.Row]:
return list(
_conn.execute(
"SELECT * FROM audit_log ORDER BY timestamp DESC LIMIT ?",
(limit,),
).fetchall()
)
def recent_audit(user_id: Optional[int], limit: int = 100) -> list[sqlite3.Row]:
if user_id is None:
return list(_conn.execute(
"SELECT * FROM audit_log ORDER BY timestamp DESC LIMIT ?", (limit,)
).fetchall())
return list(_conn.execute(
"SELECT * FROM audit_log WHERE user_id = ? OR user_id IS NULL ORDER BY timestamp DESC LIMIT ?",
(user_id, limit),
).fetchall())
# ---------------------------------------------------------------------------
# Retention cleanup
# ---------------------------------------------------------------------------
def cleanup_retention() -> dict:
cutoff = (datetime.now(timezone.utc) - timedelta(days=RETENTION_DAYS)).isoformat(timespec="seconds")
stats = {}
with _lock:
for table in ("errors", "audit_log"):
cur = _conn.execute(f"DELETE FROM {table} WHERE timestamp < ?", (cutoff,))
stats[table] = cur.rowcount
# Drop forensics from old applications (but keep the row itself for history)
cur = _conn.execute(
"UPDATE applications SET forensics_json = NULL WHERE started_at < ? AND forensics_json IS NOT NULL",
(cutoff,),
)
stats["applications_forensics_wiped"] = cur.rowcount
if any(v for v in stats.values()):
logger.info("retention cleanup: %s", stats)
return stats

52
web/matching.py Normal file
View file

@ -0,0 +1,52 @@
"""
Per-user filter matching.
Each user has one row in user_filters. A flat matches the user if all
of the user's non-null constraints are satisfied. Empty filters = matches all.
"""
import logging
from typing import Iterable
logger = logging.getLogger("web.matching")
def flat_matches_filter(flat: dict, f: dict | None) -> bool:
"""f is a user_filters row converted to dict (or None = no filter set)."""
if not f:
return True
rooms = flat.get("rooms") or 0.0
rent = flat.get("total_rent") or 0.0
size = flat.get("size") or 0.0
commute = (flat.get("connectivity") or {}).get("morning_time") or 0.0
wbs_str = str(flat.get("wbs", "")).strip().lower()
if f.get("rooms_min") is not None and rooms < float(f["rooms_min"]):
return False
if f.get("rooms_max") is not None and rooms > float(f["rooms_max"]):
return False
if f.get("max_rent") is not None and rent > float(f["max_rent"]):
return False
if f.get("min_size") is not None and size < float(f["min_size"]):
return False
if f.get("max_morning_commute") is not None and commute > float(f["max_morning_commute"]):
return False
wbs_req = (f.get("wbs_required") or "").strip().lower()
if wbs_req == "yes":
if not wbs_str or wbs_str in ("kein", "nein", "no", "ohne", "-"):
return False
elif wbs_req == "no":
if wbs_str and wbs_str not in ("kein", "nein", "no", "ohne", "-"):
return False
return True
def row_to_dict(row) -> dict:
if row is None:
return {}
try:
return {k: row[k] for k in row.keys()}
except Exception:
return dict(row)

130
web/notifications.py Normal file
View file

@ -0,0 +1,130 @@
"""
User-level notification dispatcher.
Channels:
- 'ui' no-op (dashboard shows the events anyway)
- 'telegram' per-user bot token + chat id
- 'email' system SMTP (one outbox, per-user recipient)
"""
import logging
import smtplib
from email.mime.text import MIMEText
from typing import Optional
import requests
import db
from settings import (
PUBLIC_URL,
SMTP_FROM,
SMTP_HOST,
SMTP_PASSWORD,
SMTP_PORT,
SMTP_STARTTLS,
SMTP_USERNAME,
)
logger = logging.getLogger("web.notifications")
EventType = str # 'match' | 'apply_ok' | 'apply_fail'
def _telegram_send(token: str, chat_id: str, text: str) -> bool:
try:
r = requests.post(
f"https://api.telegram.org/bot{token}/sendMessage",
json={"chat_id": chat_id, "text": text, "parse_mode": "Markdown",
"disable_web_page_preview": True},
timeout=10,
)
if not r.ok:
logger.warning("telegram send failed: %s %s", r.status_code, r.text[:200])
return False
return True
except requests.RequestException as e:
logger.warning("telegram unreachable: %s", e)
return False
def _email_send(recipient: str, subject: str, body: str) -> bool:
if not SMTP_HOST or not recipient:
logger.info("email skipped (SMTP_HOST=%r recipient=%r)", SMTP_HOST, recipient)
return False
try:
msg = MIMEText(body, _charset="utf-8")
msg["Subject"] = subject
msg["From"] = SMTP_FROM
msg["To"] = recipient
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=15) as s:
if SMTP_STARTTLS:
s.starttls()
if SMTP_USERNAME:
s.login(SMTP_USERNAME, SMTP_PASSWORD)
s.send_message(msg)
logger.info("email sent to %s", recipient)
return True
except Exception as e:
logger.warning("email send failed: %s", e)
return False
def _should_notify(notif, event: EventType) -> bool:
if event == "match":
return bool(notif["notify_on_match"])
if event == "apply_ok":
return bool(notif["notify_on_apply_success"])
if event == "apply_fail":
return bool(notif["notify_on_apply_fail"])
return False
def notify_user(user_id: int, event: EventType, *,
subject: str, body_plain: str, body_markdown: Optional[str] = None) -> None:
"""Fire a notification for one user on one event. Best-effort, non-raising."""
try:
notif = db.get_notifications(user_id)
if not notif or not _should_notify(notif, event):
return
channel = notif["channel"] or "ui"
if channel == "ui":
return
if channel == "telegram":
token = notif["telegram_bot_token"]
chat = notif["telegram_chat_id"]
if token and chat:
_telegram_send(token, chat, body_markdown or body_plain)
elif channel == "email":
addr = notif["email_address"]
if addr:
_email_send(addr, subject, body_plain)
except Exception:
logger.exception("notify_user failed for user=%s event=%s", user_id, event)
# -- Convenience builders -----------------------------------------------------
def on_match(user_id: int, flat: dict) -> None:
addr = flat.get("address") or flat.get("link")
rent = flat.get("total_rent")
rooms = flat.get("rooms")
link = flat.get("link", "")
body = f"Neue passende Wohnung: {addr}\nMiete: {rent}\nZimmer: {rooms}\n{link}"
md = (f"*Neue passende Wohnung*\n[{addr}]({link})\n"
f"Miete: {rent} € · Zimmer: {rooms}\n{PUBLIC_URL}")
notify_user(user_id, "match", subject="[lazyflat] passende Wohnung", body_plain=body, body_markdown=md)
def on_apply_ok(user_id: int, flat: dict, message: str) -> None:
addr = flat.get("address") or flat.get("link")
body = f"Bewerbung erfolgreich: {addr}\n{message}"
md = f"*Bewerbung erfolgreich*\n{addr}\n{message}"
notify_user(user_id, "apply_ok", subject="[lazyflat] Bewerbung OK", body_plain=body, body_markdown=md)
def on_apply_fail(user_id: int, flat: dict, message: str) -> None:
addr = flat.get("address") or flat.get("link")
body = f"Bewerbung fehlgeschlagen: {addr}\n{message}\n{PUBLIC_URL}/fehler"
md = (f"*Bewerbung fehlgeschlagen*\n{addr}\n{message}\n"
f"[Fehler ansehen]({PUBLIC_URL}/fehler)")
notify_user(user_id, "apply_fail", subject="[lazyflat] Bewerbung fehlgeschlagen",
body_plain=body, body_markdown=md)

24
web/retention.py Normal file
View file

@ -0,0 +1,24 @@
"""Background thread that periodically prunes old logs / errors / forensics."""
import logging
import threading
import time
import db
from settings import RETENTION_RUN_INTERVAL_SECONDS
logger = logging.getLogger("web.retention")
def _loop():
while True:
try:
db.cleanup_retention()
except Exception:
logger.exception("retention cleanup failed")
time.sleep(RETENTION_RUN_INTERVAL_SECONDS)
def start() -> None:
t = threading.Thread(target=_loop, name="retention", daemon=True)
t.start()
logger.info("retention thread started (interval=%ss)", RETENTION_RUN_INTERVAL_SECONDS)

View file

@ -16,39 +16,47 @@ def _required(key: str) -> str:
return val
# --- Auth ---
# --- Admin bootstrap ----------------------------------------------------------
# On first boot the web service seeds this user as an admin in the database.
# Afterwards the user record in SQLite is authoritative: changing the hash in
# env does NOT rotate the DB password — use the /einstellungen UI.
AUTH_USERNAME: str = _required("AUTH_USERNAME")
# argon2 hash of the password. Generate via:
# python -c "from argon2 import PasswordHasher; print(PasswordHasher().hash('<password>'))"
AUTH_PASSWORD_HASH: str = _required("AUTH_PASSWORD_HASH")
# Signs session cookies. If missing -> ephemeral random secret (invalidates sessions on restart).
# --- Session cookie -----------------------------------------------------------
SESSION_SECRET: str = getenv("SESSION_SECRET") or secrets.token_urlsafe(48)
SESSION_COOKIE_NAME: str = "lazyflat_session"
SESSION_MAX_AGE_SECONDS: int = int(getenv("SESSION_MAX_AGE_SECONDS", str(60 * 60 * 24 * 7)))
# When behind an HTTPS proxy (Coolify/Traefik) this MUST be true so cookies are Secure.
COOKIE_SECURE: bool = getenv("COOKIE_SECURE", "true").lower() in ("true", "1", "yes", "on")
# --- Internal service auth ---
# --- Internal service auth ----------------------------------------------------
INTERNAL_API_KEY: str = _required("INTERNAL_API_KEY")
# --- Apply service ---
# --- Apply service ------------------------------------------------------------
APPLY_URL: str = getenv("APPLY_URL", "http://apply:8000")
APPLY_TIMEOUT: int = int(getenv("APPLY_TIMEOUT", "600"))
# Circuit breaker: disable auto-apply after N consecutive apply failures.
APPLY_FAILURE_THRESHOLD: int = int(getenv("APPLY_FAILURE_THRESHOLD", "3"))
# --- Storage ---
# --- Storage ------------------------------------------------------------------
DATA_DIR: Path = Path(getenv("DATA_DIR", "/data"))
DATA_DIR.mkdir(parents=True, exist_ok=True)
DB_PATH: Path = DATA_DIR / "lazyflat.sqlite"
# --- Rate limiting ---
# Retention (errors / audit / application forensics). Default 14 days.
RETENTION_DAYS: int = int(getenv("RETENTION_DAYS", "14"))
RETENTION_RUN_INTERVAL_SECONDS: int = int(getenv("RETENTION_RUN_INTERVAL_SECONDS", str(60 * 60)))
# --- Rate limiting ------------------------------------------------------------
LOGIN_RATE_LIMIT: int = int(getenv("LOGIN_RATE_LIMIT", "5"))
LOGIN_RATE_WINDOW_SECONDS: int = int(getenv("LOGIN_RATE_WINDOW_SECONDS", "900"))
# --- Filter criteria (mirrored from original flat-alert) ---
FILTER_ROOMS: list[float] = [float(r) for r in getenv("FILTER_ROOMS", "2.0,2.5").split(",") if r.strip()]
FILTER_MAX_RENT: float = float(getenv("FILTER_MAX_RENT", "1500"))
FILTER_MAX_MORNING_COMMUTE: float = float(getenv("FILTER_MAX_MORNING_COMMUTE", "50"))
# --- Email (system-wide SMTP for notifications) -------------------------------
SMTP_HOST: str = getenv("SMTP_HOST", "")
SMTP_PORT: int = int(getenv("SMTP_PORT", "587"))
SMTP_USERNAME: str = getenv("SMTP_USERNAME", "")
SMTP_PASSWORD: str = getenv("SMTP_PASSWORD", "")
SMTP_FROM: str = getenv("SMTP_FROM", "lazyflat@localhost")
SMTP_STARTTLS: bool = getenv("SMTP_STARTTLS", "true").lower() in ("true", "1", "yes", "on")
# --- App URL (used to build links in notifications) ---------------------------
PUBLIC_URL: str = getenv("PUBLIC_URL", "https://flat.lab.moritz.run")

View file

@ -1,176 +0,0 @@
<section class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="card p-4">
<div class="text-xs uppercase tracking-wide text-slate-400">alert</div>
<div class="mt-2 text-lg">
{% if last_alert_heartbeat %}
<span class="chip chip-ok">live</span>
{% else %}
<span class="chip chip-warn">kein Heartbeat</span>
{% endif %}
</div>
<div class="text-xs text-slate-400 mt-1">letzter Heartbeat: {{ last_alert_heartbeat or "—" }}</div>
</div>
<div class="card p-4">
<div class="text-xs uppercase tracking-wide text-slate-400">apply</div>
<div class="mt-2 text-lg">
{% if apply_reachable %}
<span class="chip chip-ok">reachable</span>
{% else %}
<span class="chip chip-bad">down</span>
{% endif %}
</div>
<div class="text-xs text-slate-400 mt-1">
{% if circuit_open %}
<span class="chip chip-bad">circuit open</span>
{% elif apply_failures > 0 %}
{{ apply_failures }} recent failure(s)
{% else %}
healthy
{% endif %}
</div>
</div>
<div class="card p-4">
<div class="text-xs uppercase tracking-wide text-slate-400">Modus</div>
<div class="mt-2 text-lg">
{% if mode == "auto" %}
<span class="chip chip-warn">full-auto</span>
{% else %}
<span class="chip chip-info">manuell</span>
{% endif %}
</div>
<form method="post" action="/actions/mode" class="mt-2 flex gap-2">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="mode" value="{% if mode == 'auto' %}manual{% else %}auto{% endif %}">
<button class="btn btn-ghost text-sm" type="submit">
→ zu {% if mode == 'auto' %}manuell{% else %}full-auto{% endif %}
</button>
</form>
</div>
<div class="card p-4">
<div class="text-xs uppercase tracking-wide text-slate-400">KillSwitch</div>
<div class="mt-2 text-lg">
{% if kill_switch %}
<span class="chip chip-bad">apply gestoppt</span>
{% else %}
<span class="chip chip-ok">aktiv</span>
{% endif %}
</div>
<form method="post" action="/actions/kill-switch" class="mt-2 flex gap-2">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="{% if kill_switch %}off{% else %}on{% endif %}">
<button class="btn {% if kill_switch %}btn-ghost{% else %}btn-danger{% endif %} text-sm" type="submit">
{% if kill_switch %}Freigeben{% else %}Alles stoppen{% endif %}
</button>
</form>
{% if circuit_open %}
<form method="post" action="/actions/reset-circuit" class="mt-2">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button class="btn btn-ghost text-sm" type="submit">Circuit zurücksetzen</button>
</form>
{% endif %}
</div>
</section>
{% if not apply_allowed %}
<div class="card p-4 border-red-900/50">
<span class="chip chip-bad">apply blockiert</span>
<span class="ml-2 text-sm text-slate-600">{{ apply_block_reason }}</span>
</div>
{% endif %}
<section class="card">
<div class="flex items-center justify-between px-4 py-3 border-b border-soft">
<h2 class="font-semibold">Wohnungen</h2>
<span class="text-xs text-slate-400">{{ flats|length }} zuletzt gesehen</span>
</div>
<div class="divide-y divide-soft">
{% for flat in flats %}
<div class="px-4 py-3 flex flex-col md:flex-row md:items-center gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<a class="font-medium truncate hover:underline" href="{{ flat.link }}" target="_blank" rel="noopener noreferrer">
{{ flat.address or flat.link }}
</a>
{% if flat.matched_criteria %}
<span class="chip chip-ok">match</span>
{% else %}
<span class="chip chip-info">info</span>
{% endif %}
{% if flat.last_application_success == 1 %}
<span class="chip chip-ok">beworben</span>
{% elif flat.last_application_success == 0 %}
<span class="chip chip-bad">apply fehlgeschlagen</span>
{% endif %}
</div>
<div class="text-xs text-slate-400 mt-0.5">
{% if flat.rooms %}{{ "%.1f"|format(flat.rooms) }} Z{% endif %}
{% if flat.size %} · {{ "%.0f"|format(flat.size) }} m²{% endif %}
{% if flat.total_rent %} · {{ "%.0f"|format(flat.total_rent) }} €{% endif %}
{% if flat.sqm_price %} ({{ "%.2f"|format(flat.sqm_price) }} €/m²){% endif %}
{% if flat.connectivity_morning_time %} · {{ "%.0f"|format(flat.connectivity_morning_time) }} min morgens{% endif %}
· entdeckt {{ flat.discovered_at }}
</div>
{% if flat.last_application_message %}
<div class="text-xs text-slate-500 mt-1 truncate">↳ {{ flat.last_application_message }}</div>
{% endif %}
</div>
<div class="flex gap-2">
{% if apply_allowed and not flat.last_application_success %}
<form method="post" action="/actions/apply">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="flat_id" value="{{ flat.id }}">
<button class="btn btn-primary text-sm" type="submit"
onclick="return confirm('Bewerbung für {{ (flat.address or flat.link)|e }} ausführen?');">
Bewerben
</button>
</form>
{% endif %}
</div>
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">Noch keine Wohnungen gesehen.</div>
{% endfor %}
</div>
</section>
<section class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card">
<div class="px-4 py-3 border-b border-soft"><h2 class="font-semibold">Letzte Bewerbungen</h2></div>
<div class="divide-y divide-soft">
{% for a in applications %}
<div class="px-4 py-3 text-sm">
<div class="flex items-center gap-2">
{% if a.success == 1 %}<span class="chip chip-ok">ok</span>
{% elif a.success == 0 %}<span class="chip chip-bad">fail</span>
{% else %}<span class="chip chip-warn">läuft</span>{% endif %}
<span class="chip chip-info">{{ a.triggered_by }}</span>
<span class="text-slate-400 text-xs">{{ a.started_at }}</span>
</div>
<div class="mt-1 truncate">{{ a.address or a.url }}</div>
{% if a.message %}<div class="text-xs text-slate-500 mt-0.5">{{ a.message }}</div>{% endif %}
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">Keine Bewerbungen bisher.</div>
{% endfor %}
</div>
</div>
<div class="card">
<div class="px-4 py-3 border-b border-soft"><h2 class="font-semibold">Audit-Log</h2></div>
<div class="divide-y divide-soft">
{% for e in audit %}
<div class="px-4 py-2 text-xs font-mono">
<span class="text-slate-500">{{ e.timestamp }}</span>
<span class="text-slate-400">{{ e.actor }}</span>
<span class="text-slate-700">{{ e.action }}</span>
{% if e.details %}<span class="text-slate-500">— {{ e.details }}</span>{% endif %}
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">leer</div>
{% endfor %}
</div>
</div>
</section>

View file

@ -0,0 +1,31 @@
{#
Shared layout: top bar with brand + user + logout, tab nav, body container.
Used by every authenticated view via `{% extends "_layout.html" %}`.
#}
{% extends "base.html" %}
{% block body %}
<header class="border-b border-soft bg-white/70 backdrop-blur sticky top-0 z-10">
<div class="max-w-6xl mx-auto px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="brand-dot"></div>
<h1 class="text-xl font-semibold">lazyflat</h1>
</div>
<div class="flex items-center gap-4 text-sm">
<span class="text-slate-500">{{ user.username }}{% if is_admin %} · <span class="chip chip-info">admin</span>{% endif %}</span>
<form method="post" action="/logout">
<button class="btn btn-ghost text-sm" type="submit">Logout</button>
</form>
</div>
</div>
<nav class="max-w-6xl mx-auto px-6 flex border-b border-soft -mb-px">
<a class="tab {% if active_tab=='wohnungen' %}active{% endif %}" href="/">Wohnungen</a>
<a class="tab {% if active_tab=='bewerbungen' %}active{% endif %}" href="/bewerbungen">Bewerbungen</a>
<a class="tab {% if active_tab=='logs' %}active{% endif %}" href="/logs">Logs</a>
<a class="tab {% if active_tab=='fehler' %}active{% endif %}" href="/fehler">Fehler</a>
<a class="tab {% if active_tab=='einstellungen' %}active{% endif %}" href="/einstellungen">Einstellungen</a>
</nav>
</header>
<main class="max-w-6xl mx-auto px-6 py-6 space-y-6">
{% block content %}{% endblock %}
</main>
{% endblock %}

View file

@ -0,0 +1,27 @@
<h2 class="font-semibold mb-4">Account</h2>
<div class="text-sm text-slate-600 mb-4">
Angemeldet als <b>{{ user.username }}</b>{% if is_admin %} (Administrator){% endif %}.
</div>
{% if request.query_params.get('ok') %}<div class="chip chip-ok mb-4">Passwort geändert.</div>{% endif %}
{% if request.query_params.get('err') == 'wrongold' %}<div class="chip chip-bad mb-4">Altes Passwort falsch.</div>{% endif %}
{% if request.query_params.get('err') == 'mismatch' %}<div class="chip chip-bad mb-4">Neue Passwörter stimmen nicht überein.</div>{% endif %}
{% if request.query_params.get('err') == 'tooshort' %}<div class="chip chip-bad mb-4">Passwort zu kurz (min. 10 Zeichen).</div>{% endif %}
<form method="post" action="/actions/account/password" class="space-y-3 max-w-md">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Altes Passwort</label>
<input class="input" type="password" name="old_password" autocomplete="current-password" required>
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Neues Passwort (≥ 10 Zeichen)</label>
<input class="input" type="password" name="new_password" autocomplete="new-password" required>
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Neues Passwort wiederholen</label>
<input class="input" type="password" name="new_password_repeat" autocomplete="new-password" required>
</div>
<button class="btn btn-primary" type="submit">Passwort ändern</button>
</form>

View file

@ -0,0 +1,40 @@
<h2 class="font-semibold mb-2">Filter</h2>
<p class="text-sm text-slate-600 mb-4">
Die Filter bestimmen, bei welchen Wohnungen du eine Benachrichtigung bekommst und worauf Auto-Bewerben greift.
Leer lassen = kein Limit.
</p>
<form method="post" action="/actions/filters" class="grid grid-cols-2 md:grid-cols-3 gap-4">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">Zimmer min</label>
<input class="input" name="rooms_min" value="{{ filters.rooms_min if filters.rooms_min is not none else '' }}">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">Zimmer max</label>
<input class="input" name="rooms_max" value="{{ filters.rooms_max if filters.rooms_max is not none else '' }}">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">max Miete (€)</label>
<input class="input" name="max_rent" value="{{ filters.max_rent if filters.max_rent is not none else '' }}">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">min Größe (m²)</label>
<input class="input" name="min_size" value="{{ filters.min_size if filters.min_size is not none else '' }}">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">max Anfahrt morgens (min)</label>
<input class="input" name="max_morning_commute" value="{{ filters.max_morning_commute if filters.max_morning_commute is not none else '' }}">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">WBS benötigt</label>
<select class="input" name="wbs_required">
<option value="" {% if not filters.wbs_required %}selected{% endif %}>egal</option>
<option value="yes" {% if filters.wbs_required == 'yes' %}selected{% endif %}>ja</option>
<option value="no" {% if filters.wbs_required == 'no' %}selected{% endif %}>nein</option>
</select>
</div>
<div class="col-span-2 md:col-span-3">
<button class="btn btn-primary" type="submit">Filter speichern</button>
</div>
</form>

View file

@ -0,0 +1,51 @@
<h2 class="font-semibold mb-2">Benachrichtigungen</h2>
<p class="text-sm text-slate-600 mb-4">
Wähle einen Kanal und entscheide, welche Events dich erreichen sollen.
</p>
<form method="post" action="/actions/notifications" class="space-y-4 max-w-xl">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Kanal</label>
<select class="input" name="channel">
<option value="ui" {% if notifications.channel == 'ui' %}selected{% endif %}>Nur im Dashboard (kein Push)</option>
<option value="telegram" {% if notifications.channel == 'telegram' %}selected{% endif %}>Telegram</option>
<option value="email" {% if notifications.channel == 'email' %}selected{% endif %}>E-Mail</option>
</select>
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Telegram Bot-Token</label>
<input class="input" name="telegram_bot_token" value="{{ notifications.telegram_bot_token }}"
placeholder="123456:ABC...">
<p class="text-xs text-slate-500 mt-1">Bot bei @BotFather anlegen, Token hier eintragen.</p>
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Telegram Chat-ID</label>
<input class="input" name="telegram_chat_id" value="{{ notifications.telegram_chat_id }}" placeholder="987654321">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">E-Mail Adresse</label>
<input class="input" type="email" name="email_address" value="{{ notifications.email_address }}"
placeholder="du@example.com">
</div>
<div class="border-t border-soft pt-4 space-y-2">
<label class="flex items-center gap-2">
<input type="checkbox" name="notify_on_match" {% if notifications.notify_on_match %}checked{% endif %}>
<span>Bei passender Wohnung</span>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" name="notify_on_apply_success" {% if notifications.notify_on_apply_success %}checked{% endif %}>
<span>Bei erfolgreicher Bewerbung</span>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" name="notify_on_apply_fail" {% if notifications.notify_on_apply_fail %}checked{% endif %}>
<span>Bei fehlgeschlagener Bewerbung</span>
</label>
</div>
<button class="btn btn-primary" type="submit">Speichern</button>
</form>

View file

@ -0,0 +1,123 @@
<h2 class="font-semibold mb-4">Bewerbungsdaten</h2>
<p class="text-sm text-slate-600 mb-4">
Diese Angaben werden beim Bewerben an die jeweilige Website gesendet.
<span class="chip chip-warn">sensibel</span> — werden nur in der DB gespeichert und pro Bewerbung als Snapshot protokolliert.
</p>
<form method="post" action="/actions/profile" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Anrede</label>
<select class="input" name="salutation">
{% for s in ['Herr', 'Frau', 'Divers'] %}
<option value="{{ s }}" {% if profile.salutation == s %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</div>
<div></div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Vorname</label>
<input class="input" name="firstname" value="{{ profile.firstname }}">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Nachname</label>
<input class="input" name="lastname" value="{{ profile.lastname }}">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">E-Mail</label>
<input class="input" type="email" name="email" value="{{ profile.email }}">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Telefon</label>
<input class="input" name="telephone" value="{{ profile.telephone }}">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Straße</label>
<input class="input" name="street" value="{{ profile.street }}">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Hausnummer</label>
<input class="input" name="house_number" value="{{ profile.house_number }}">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">PLZ</label>
<input class="input" name="postcode" value="{{ profile.postcode }}">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Stadt</label>
<input class="input" name="city" value="{{ profile.city }}">
</div>
<div class="col-span-1 md:col-span-2 mt-4 border-t border-soft pt-4">
<h3 class="font-semibold mb-2">WBS</h3>
</div>
<label class="col-span-1 md:col-span-2 inline-flex items-center gap-2">
<input type="checkbox" name="is_possessing_wbs" {% if profile.is_possessing_wbs %}checked{% endif %}>
<span class="text-sm">WBS vorhanden</span>
</label>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">WBS-Typ</label>
<input class="input" name="wbs_type" value="{{ profile.wbs_type }}" placeholder="z.B. 180">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">gültig bis</label>
<input class="input" type="date" name="wbs_valid_till" value="{{ profile.wbs_valid_till }}">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Räume</label>
<input class="input" type="number" name="wbs_rooms" value="{{ profile.wbs_rooms }}" min="0">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Erwachsene</label>
<input class="input" type="number" name="wbs_adults" value="{{ profile.wbs_adults }}" min="0">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Kinder</label>
<input class="input" type="number" name="wbs_children" value="{{ profile.wbs_children }}" min="0">
</div>
<label class="inline-flex items-center gap-2 mt-6">
<input type="checkbox" name="is_prio_wbs" {% if profile.is_prio_wbs %}checked{% endif %}>
<span class="text-sm">Prio-WBS (besonderer Wohnbedarf)</span>
</label>
<div class="col-span-1 md:col-span-2 mt-4 border-t border-soft pt-4">
<h3 class="font-semibold mb-2">Immomio-Login (optional)</h3>
<p class="text-xs text-slate-500 mb-2">
Wird von Anbietern benötigt, die über immomio/tenant vermitteln (z.B. gesobau.de).
</p>
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Immomio-Email</label>
<input class="input" type="email" name="immomio_email" value="{{ profile.immomio_email }}">
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Immomio-Passwort</label>
<input class="input" type="password" name="immomio_password" value="{{ profile.immomio_password }}" placeholder="(unverändert lassen = leer)">
</div>
<div class="col-span-1 md:col-span-2">
<button class="btn btn-primary" type="submit">Speichern</button>
</div>
</form>
<hr class="my-6 border-soft">
<h3 class="font-semibold mb-2">Formulare wirklich absenden?</h3>
<p class="text-sm text-slate-600 mb-3">
<span class="chip chip-warn">experimentell</span>
Im Dry-Run-Modus füllt apply das Formular aus und stoppt vor „Senden". Nur einschalten, wenn du jeden Anbieter einmal im Dry-Run verifiziert hast.
</p>
<form method="post" action="/actions/submit-forms" class="inline-flex gap-3 items-center">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="on">
<button class="btn btn-ghost text-sm" type="submit">Echt senden einschalten</button>
</form>
<form method="post" action="/actions/submit-forms" class="inline-flex gap-3 items-center ml-2">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="off">
<button class="btn btn-ghost text-sm" type="submit">Dry-Run</button>
</form>

View file

@ -0,0 +1,50 @@
<h2 class="font-semibold mb-4">Benutzer verwalten</h2>
{% if request.query_params.get('ok') %}<div class="chip chip-ok mb-4">Benutzer angelegt.</div>{% endif %}
{% if request.query_params.get('err') == 'exists' %}<div class="chip chip-bad mb-4">Benutzername existiert bereits.</div>{% endif %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 class="font-semibold mb-2">Neuen Benutzer anlegen</h3>
<form method="post" action="/actions/users/create" class="space-y-3 max-w-md">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Benutzername</label>
<input class="input" name="username" required>
</div>
<div>
<label class="block text-xs uppercase text-slate-500 mb-1">Passwort (≥ 10 Zeichen)</label>
<input class="input" type="password" name="password" required>
</div>
<label class="inline-flex items-center gap-2">
<input type="checkbox" name="is_admin">
<span>Admin-Rechte</span>
</label>
<button class="btn btn-primary" type="submit">Anlegen</button>
</form>
</div>
<div>
<h3 class="font-semibold mb-2">Alle Benutzer</h3>
<div class="card divide-y divide-soft">
{% for u in users %}
<div class="px-3 py-2 flex items-center gap-2 text-sm">
<span class="flex-1">{{ u.username }}</span>
{% if u.is_admin %}<span class="chip chip-info">admin</span>{% endif %}
{% if u.disabled %}<span class="chip chip-bad">deaktiviert</span>
{% else %}<span class="chip chip-ok">aktiv</span>{% endif %}
{% if u.id != user.id %}
<form method="post" action="/actions/users/disable">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="target_id" value="{{ u.id }}">
<input type="hidden" name="value" value="{% if u.disabled %}off{% else %}on{% endif %}">
<button class="btn btn-ghost text-xs" type="submit">
{% if u.disabled %}aktivieren{% else %}deaktivieren{% endif %}
</button>
</form>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>

View file

@ -9,19 +9,13 @@
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
<style>
:root {
--bg-from: #e4f0fb;
--bg-to: #f7fbfe;
--surface: #ffffff;
--border: #d8e6f3;
--text: #10253f;
--muted: #667d98;
--primary: #2f8ae0;
--primary-hover: #1f74c8;
--danger: #e05a6a;
--danger-hover: #c44a59;
--ghost: #eaf2fb;
--ghost-hover: #d5e5f4;
--accent: #fbd76b;
--bg-from: #e4f0fb; --bg-to: #f7fbfe;
--surface: #ffffff; --border: #d8e6f3;
--text: #10253f; --muted: #667d98;
--primary: #2f8ae0; --primary-hover: #1f74c8;
--danger: #e05a6a; --danger-hover: #c44a59;
--ghost: #eaf2fb; --ghost-hover: #d5e5f4;
--accent: #fbd76b;
}
html { color-scheme: light; }
body {
@ -30,33 +24,24 @@
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Inter, sans-serif;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 1px 2px rgba(16, 37, 63, 0.04);
}
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
box-shadow: 0 1px 2px rgba(16, 37, 63, 0.04); }
.border-soft { border-color: var(--border) !important; }
.divide-soft > :not([hidden]) ~ :not([hidden]) { border-color: var(--border) !important; }
.btn { border-radius: 9px; padding: 0.45rem 0.95rem; font-weight: 500; transition: background 0.15s, box-shadow 0.15s, transform 0.05s; }
.btn { border-radius: 9px; padding: 0.45rem 0.95rem; font-weight: 500;
transition: background 0.15s, box-shadow 0.15s, transform 0.05s; display: inline-block; }
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--primary); color: white; box-shadow: 0 1px 2px rgba(47, 138, 224, 0.25); }
.btn-primary { background: var(--primary); color: white; box-shadow: 0 1px 2px rgba(47,138,224,.25); }
.btn-primary:hover { background: var(--primary-hover); }
.btn-danger { background: var(--danger); color: white; }
.btn-danger:hover { background: var(--danger-hover); }
.btn-ghost { background: var(--ghost); color: var(--text); border: 1px solid var(--border); }
.btn-ghost:hover { background: var(--ghost-hover); }
.input {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.55rem 0.8rem;
width: 100%;
color: var(--text);
transition: border-color 0.15s, box-shadow 0.15s;
}
.input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47, 138, 224, 0.18); }
.chip { padding: 0.2rem 0.7rem; border-radius: 999px; font-size: 0.75rem; font-weight: 500; display: inline-block; }
.input { background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 0.55rem 0.8rem; width: 100%; color: var(--text);
transition: border-color .15s, box-shadow .15s; }
.input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47,138,224,.18); }
.chip { padding: .2rem .7rem; border-radius: 999px; font-size: .75rem; font-weight: 500; display: inline-block; }
.chip-ok { background: #e4f6ec; color: #1f8a4a; border: 1px solid #b7e4c7; }
.chip-warn { background: #fff4dd; color: #a36a1f; border: 1px solid #f5d48b; }
.chip-bad { background: #fde6e9; color: #b8404e; border: 1px solid #f5b5bf; }
@ -68,9 +53,27 @@
}
a { color: var(--primary); }
a:hover { text-decoration: underline; }
/* tab nav */
.tab { padding: 0.7rem 0.2rem; color: var(--muted); border-bottom: 2px solid transparent;
margin-right: 1.5rem; font-weight: 500; }
.tab.active { color: var(--text); border-color: var(--primary); }
.tab:hover { color: var(--text); text-decoration: none; }
/* auto-apply hot button */
.btn-hot { background: linear-gradient(135deg, #ff7a85 0%, #e14a56 100%); color: white;
box-shadow: 0 2px 6px rgba(225, 74, 86, 0.35); font-weight: 600; }
.btn-hot:hover { filter: brightness(1.05); }
.btn-hot.off { background: linear-gradient(135deg, #cfd9e6 0%, #99abc2 100%);
box-shadow: 0 1px 2px rgba(16, 37, 63, 0.15); }
/* forensic JSON tree */
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; }
details > summary { cursor: pointer; user-select: none; }
details > summary::marker { color: var(--muted); }
</style>
</head>
<body class="min-h-screen">
{% block body %}{% endblock %}
{% block body %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,113 @@
{% extends "_layout.html" %}
{% block title %}Bewerbung #{{ application.id }} — lazyflat{% endblock %}
{% block content %}
<a href="/bewerbungen" class="text-sm">← zurück zu den Bewerbungen</a>
<section class="card p-5">
<div class="flex items-center gap-2 flex-wrap mb-3">
<h2 class="font-semibold text-lg">Bewerbung #{{ application.id }}</h2>
{% if application.success == 1 %}<span class="chip chip-ok">erfolgreich</span>
{% elif application.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
{% else %}<span class="chip chip-warn">läuft</span>{% endif %}
<span class="chip chip-info">{{ application.triggered_by }}</span>
{% if application.provider %}<span class="chip chip-info">{{ application.provider }}</span>{% endif %}
{% if application.submit_forms_used %}<span class="chip chip-warn">echt gesendet</span>
{% else %}<span class="chip chip-info">dry-run</span>{% endif %}
</div>
<div class="text-sm text-slate-600 space-y-1">
<div><span class="text-slate-500">URL:</span> <a href="{{ application.url }}" target="_blank" rel="noopener">{{ application.url }}</a></div>
<div><span class="text-slate-500">gestartet:</span> {{ application.started_at }}</div>
<div><span class="text-slate-500">beendet:</span> {{ application.finished_at or "—" }}</div>
{% if application.message %}<div><span class="text-slate-500">Meldung:</span> {{ application.message }}</div>{% endif %}
</div>
</section>
<section class="card p-5">
<details>
<summary class="font-semibold">Profil-Snapshot zum Bewerbungszeitpunkt</summary>
<pre class="mono whitespace-pre-wrap break-all mt-3 p-3 bg-[#f6fafd] rounded-lg border border-soft">{{ profile_snapshot | tojson(indent=2) }}</pre>
</details>
</section>
{% if forensics %}
<section class="card p-5 space-y-4">
<h3 class="font-semibold">Forensik (für KI-Debug)</h3>
<details open>
<summary class="font-medium">Step-Log ({{ forensics.steps|length }} Einträge, {{ forensics.duration_s }} s)</summary>
<div class="mono mt-2 space-y-0.5">
{% for s in forensics.steps %}
<div class="{% if s.status != 'ok' %}text-[#b8404e]{% else %}text-slate-700{% endif %}">
[{{ "%.2f"|format(s.ts) }}s] {{ s.step }} {% if s.status != 'ok' %}({{ s.status }}){% endif %}
{% if s.detail %}— {{ s.detail }}{% endif %}
</div>
{% endfor %}
</div>
</details>
{% if forensics.screenshots %}
<details>
<summary class="font-medium">Screenshots ({{ forensics.screenshots|length }})</summary>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
{% for s in forensics.screenshots %}
<div class="border border-soft rounded-lg p-2">
<div class="text-xs text-slate-500 mb-1">{{ s.label }} @ {{ "%.2f"|format(s.ts) }}s — {{ s.url }}</div>
<img src="data:image/jpeg;base64,{{ s.b64_jpeg }}" class="w-full rounded" alt="{{ s.label }}">
</div>
{% endfor %}
</div>
</details>
{% endif %}
{% if forensics.console %}
<details>
<summary class="font-medium">Browser-Konsole ({{ forensics.console|length }})</summary>
<div class="mono mt-2 space-y-0.5">
{% for c in forensics.console %}
<div class="text-slate-700">[{{ "%.2f"|format(c.ts) }}s] [{{ c.type }}] {{ c.text }}</div>
{% endfor %}
</div>
</details>
{% endif %}
{% if forensics.errors %}
<details open>
<summary class="font-medium text-[#b8404e]">Browser-Errors ({{ forensics.errors|length }})</summary>
<div class="mono mt-2 space-y-0.5 text-[#b8404e]">
{% for e in forensics.errors %}
<div>[{{ "%.2f"|format(e.ts) }}s] {{ e.message }}</div>
{% endfor %}
</div>
</details>
{% endif %}
{% if forensics.network %}
<details>
<summary class="font-medium">Netzwerk ({{ forensics.network|length }})</summary>
<div class="mono mt-2 space-y-0.5">
{% for n in forensics.network %}
<div class="text-slate-700 break-all">
[{{ "%.2f"|format(n.ts) }}s]
{% if n.kind == 'response' %}← {{ n.status }} {{ n.url }}
{% if n.body_snippet %}<div class="pl-4 text-slate-500">{{ n.body_snippet[:500] }}</div>{% endif %}
{% else %}→ {{ n.method }} {{ n.url }} [{{ n.resource_type }}]
{% endif %}
</div>
{% endfor %}
</div>
</details>
{% endif %}
{% if forensics.final_html %}
<details>
<summary class="font-medium">Page HTML ({{ forensics.final_html|length }} B)</summary>
<pre class="mono whitespace-pre-wrap break-all mt-3 p-3 bg-[#f6fafd] rounded-lg border border-soft max-h-96 overflow-auto">{{ forensics.final_html }}</pre>
</details>
{% endif %}
</section>
{% elif application.finished_at %}
<section class="card p-5 text-sm text-slate-500">
Forensik für diese Bewerbung ist nicht mehr verfügbar (älter als Retention-Zeitraum).
</section>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,32 @@
{% extends "_layout.html" %}
{% block title %}Bewerbungen — lazyflat{% endblock %}
{% block content %}
<section class="card">
<div class="flex items-center justify-between px-4 py-3 border-b border-soft">
<h2 class="font-semibold">Meine Bewerbungen</h2>
<span class="text-xs text-slate-500">{{ applications|length }}</span>
</div>
<div class="divide-y divide-soft">
{% for a in applications %}
<div class="px-4 py-3 text-sm">
<div class="flex items-center gap-2 flex-wrap">
{% if a.success == 1 %}<span class="chip chip-ok">ok</span>
{% elif a.success == 0 %}<span class="chip chip-bad">fail</span>
{% else %}<span class="chip chip-warn">läuft</span>{% endif %}
<span class="chip chip-info">{{ a.triggered_by }}</span>
{% if a.provider %}<span class="chip chip-info">{{ a.provider }}</span>{% endif %}
{% if a.submit_forms_used %}<span class="chip chip-warn">echt gesendet</span>
{% else %}<span class="chip chip-info">dry-run</span>{% endif %}
<span class="text-slate-500 text-xs ml-auto">{{ a.started_at }}</span>
</div>
<div class="mt-1 truncate">
<a href="/bewerbungen/{{ a.id }}">#{{ a.id }} — {{ a.address or a.url }}</a>
</div>
{% if a.message %}<div class="text-xs text-slate-500 mt-0.5 truncate">{{ a.message }}</div>{% endif %}
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">Noch keine Bewerbungen.</div>
{% endfor %}
</div>
</section>
{% endblock %}

View file

@ -1,25 +0,0 @@
{% extends "base.html" %}
{% block title %}lazyflat dashboard{% endblock %}
{% block body %}
<header class="border-b border-soft bg-white/70 backdrop-blur">
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="brand-dot"></div>
<h1 class="text-xl font-semibold">lazyflat</h1>
</div>
<div class="flex items-center gap-3 text-sm">
<span class="text-slate-500">{{ user }}</span>
<form method="post" action="/logout">
<button class="btn btn-ghost text-sm" type="submit">Logout</button>
</form>
</div>
</div>
</header>
<main class="max-w-6xl mx-auto px-6 py-6 space-y-6"
hx-get="/partials/dashboard" hx-trigger="every 15s" hx-target="#dashboard-body" hx-swap="innerHTML">
<div id="dashboard-body">
{% include "_dashboard_body.html" %}
</div>
</main>
{% endblock %}

View file

@ -0,0 +1,23 @@
{% extends "_layout.html" %}
{% block title %}Einstellungen — lazyflat{% endblock %}
{% block content %}
<section class="card">
<nav class="flex flex-wrap border-b border-soft px-4">
{% set sections = [('profil','Profil'),('filter','Filter'),('benachrichtigungen','Benachrichtigungen'),('account','Account')] %}
{% if is_admin %}{% set sections = sections + [('benutzer','Benutzer')] %}{% endif %}
{% for key, label in sections %}
<a href="/einstellungen/{{ key }}"
class="tab {% if section == key %}active{% endif %}">{{ label }}</a>
{% endfor %}
</nav>
<div class="p-5">
{% if section == 'profil' %}{% include "_settings_profil.html" %}
{% elif section == 'filter' %}{% include "_settings_filter.html" %}
{% elif section == 'benachrichtigungen' %}{% include "_settings_notifications.html" %}
{% elif section == 'account' %}{% include "_settings_account.html" %}
{% elif section == 'benutzer' %}{% include "_settings_users.html" %}
{% endif %}
</div>
</section>
{% endblock %}

29
web/templates/fehler.html Normal file
View file

@ -0,0 +1,29 @@
{% extends "_layout.html" %}
{% block title %}Fehler — lazyflat{% endblock %}
{% block content %}
<section class="card">
<div class="px-4 py-3 border-b border-soft flex items-center justify-between">
<h2 class="font-semibold">Fehler {% if is_admin %}<span class="chip chip-info ml-2">inkl. globaler</span>{% endif %}</h2>
<span class="text-xs text-slate-500">{{ errors|length }}</span>
</div>
<div class="divide-y divide-soft">
{% for e in errors %}
<a class="block px-4 py-3 hover:bg-[#f6fafd]" href="/fehler/{{ e.id }}">
<div class="flex items-center gap-2 flex-wrap">
<span class="chip chip-bad">{{ e.kind }}</span>
<span class="chip chip-info">{{ e.source }}</span>
{% if e.application_id %}<span class="chip chip-info">#{{ e.application_id }}</span>{% endif %}
<span class="text-xs text-slate-500 ml-auto">{{ e.timestamp }}</span>
</div>
<div class="text-sm mt-1 truncate">{{ e.summary or "(kein Text)" }}</div>
</a>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">Keine Fehler — läuft rund.</div>
{% endfor %}
</div>
<div class="px-4 py-3 border-t border-soft text-xs text-slate-500">
Fehler werden 14 Tage aufbewahrt. Bei fehlgeschlagenen Bewerbungen enthält die Detailseite Screenshots,
Step-Log, Browser-Konsole + Netzwerk-Trace für KI-gestützte Fehleranalyse.
</div>
</section>
{% endblock %}

View file

@ -0,0 +1,32 @@
{% extends "_layout.html" %}
{% block title %}Fehler #{{ error.id }} — lazyflat{% endblock %}
{% block content %}
<a href="/fehler" class="text-sm">← zurück zu den Fehlern</a>
<section class="card p-5 space-y-2">
<div class="flex items-center gap-2 flex-wrap">
<h2 class="font-semibold text-lg">Fehler #{{ error.id }}</h2>
<span class="chip chip-bad">{{ error.kind }}</span>
<span class="chip chip-info">{{ error.source }}</span>
</div>
<div class="text-sm text-slate-600">
{{ error.timestamp }} · {{ error.summary or "(kein Text)" }}
</div>
{% if context %}
<details class="mt-2">
<summary class="font-medium">Kontext</summary>
<pre class="mono whitespace-pre-wrap break-all mt-2 p-3 bg-[#f6fafd] rounded-lg border border-soft">{{ context | tojson(indent=2) }}</pre>
</details>
{% endif %}
</section>
{% if application %}
<section class="card p-5">
<h3 class="font-semibold mb-2">Zugehörige Bewerbung</h3>
<div class="text-sm">
<a href="/bewerbungen/{{ application.id }}">Bewerbung #{{ application.id }} öffnen</a>
— vollständige Forensik dort.
</div>
</section>
{% endif %}
{% endblock %}

23
web/templates/logs.html Normal file
View file

@ -0,0 +1,23 @@
{% extends "_layout.html" %}
{% block title %}Logs — lazyflat{% endblock %}
{% block content %}
<section class="card">
<div class="px-4 py-3 border-b border-soft"><h2 class="font-semibold">Meine Aktionen (Audit-Log)</h2></div>
<div class="divide-y divide-soft">
{% for e in events %}
<div class="px-4 py-2 mono">
<span class="text-slate-500">{{ e.timestamp }}</span>
<span class="text-slate-400">{{ e.actor }}</span>
<span class="text-slate-700">{{ e.action }}</span>
{% if e.details %}<span class="text-slate-500">— {{ e.details }}</span>{% endif %}
{% if e.ip %}<span class="text-slate-400"> [{{ e.ip }}]</span>{% endif %}
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">Keine Log-Einträge.</div>
{% endfor %}
</div>
<div class="px-4 py-3 border-t border-soft text-xs text-slate-500">
Einträge werden nach 14 Tagen automatisch gelöscht.
</div>
</section>
{% endblock %}

View file

@ -0,0 +1,182 @@
{% extends "_layout.html" %}
{% block title %}Wohnungen — lazyflat{% endblock %}
{% block content %}
<!-- Auto-Bewerben + Status-Leiste -->
<section class="card p-5 flex flex-col md:flex-row md:items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<div class="text-xs uppercase tracking-wide text-slate-500">Auto-Bewerben</div>
<div class="flex items-center gap-2">
{% if auto_apply_enabled %}
<span class="chip chip-warn">an</span>
<span class="text-sm text-slate-600">bei Match wird automatisch beworben</span>
{% else %}
<span class="chip chip-info">aus</span>
<span class="text-sm text-slate-600">Matches werden nur angezeigt</span>
{% endif %}
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<!-- der rote Knopf -->
<form method="post" action="/actions/auto-apply">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="{% if auto_apply_enabled %}off{% else %}on{% endif %}">
<button class="btn btn-hot {% if not auto_apply_enabled %}off{% endif %}"
onclick="return confirm('{% if auto_apply_enabled %}Auto-Bewerben deaktivieren?{% else %}Auto-Bewerben aktivieren? Das System bewirbt dann automatisch bei jedem Match bitte Profil und Filter prüfen.{% endif %}');"
type="submit">
{% if auto_apply_enabled %}AUTO-BEWERBEN: AN{% else %}AUTO-BEWERBEN AKTIVIEREN{% endif %}
</button>
</form>
<!-- kill switch -->
<form method="post" action="/actions/kill-switch">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="value" value="{% if kill_switch %}off{% else %}on{% endif %}">
<button class="btn {% if kill_switch %}btn-ghost{% else %}btn-danger{% endif %} text-sm" type="submit">
{% if kill_switch %}Kill-Switch deaktivieren{% else %}Kill-Switch{% endif %}
</button>
</form>
{% if circuit_open %}
<form method="post" action="/actions/reset-circuit">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button class="btn btn-ghost text-sm" type="submit">Circuit zurücksetzen</button>
</form>
{% endif %}
</div>
</section>
{% if not apply_allowed %}
<div class="card p-4">
<span class="chip chip-bad">apply blockiert</span>
<span class="ml-2 text-sm text-slate-600">{{ apply_block_reason }}</span>
</div>
{% endif %}
<!-- Status zeile -->
<section class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="card p-3">
<div class="text-xs text-slate-500">alert</div>
<div class="mt-1">
{% if last_alert_heartbeat %}<span class="chip chip-ok">live</span>
{% else %}<span class="chip chip-warn">kein Heartbeat</span>{% endif %}
</div>
</div>
<div class="card p-3">
<div class="text-xs text-slate-500">apply</div>
<div class="mt-1">
{% if apply_reachable %}<span class="chip chip-ok">ok</span>
{% else %}<span class="chip chip-bad">down</span>{% endif %}
</div>
</div>
<div class="card p-3">
<div class="text-xs text-slate-500">submit_forms</div>
<div class="mt-1">
{% if submit_forms %}<span class="chip chip-warn">echt senden</span>
{% else %}<span class="chip chip-info">dry-run</span>{% endif %}
</div>
</div>
<div class="card p-3">
<div class="text-xs text-slate-500">Fehler in Serie</div>
<div class="mt-1">
{% if circuit_open %}<span class="chip chip-bad">circuit open</span>
{% elif apply_failures > 0 %}<span class="chip chip-warn">{{ apply_failures }}</span>
{% else %}<span class="chip chip-ok">0</span>{% endif %}
</div>
</div>
</section>
<!-- Filter Panel -->
<details class="card" {% if not filters.rooms_min and not filters.max_rent %}open{% endif %}>
<summary class="px-5 py-3 font-semibold select-none">Eigene Filter</summary>
<form method="post" action="/actions/filters" class="p-5 grid grid-cols-2 md:grid-cols-3 gap-4">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">Zimmer min</label>
<input class="input" name="rooms_min" value="{{ filters.rooms_min if filters.rooms_min is not none else '' }}" placeholder="z.B. 2">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">Zimmer max</label>
<input class="input" name="rooms_max" value="{{ filters.rooms_max if filters.rooms_max is not none else '' }}" placeholder="z.B. 3">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">max Miete (€)</label>
<input class="input" name="max_rent" value="{{ filters.max_rent if filters.max_rent is not none else '' }}" placeholder="1500">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">min Größe (m²)</label>
<input class="input" name="min_size" value="{{ filters.min_size if filters.min_size is not none else '' }}" placeholder="40">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">max Anfahrt morgens (min)</label>
<input class="input" name="max_morning_commute" value="{{ filters.max_morning_commute if filters.max_morning_commute is not none else '' }}" placeholder="50">
</div>
<div>
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">WBS benötigt</label>
<select class="input" name="wbs_required">
<option value="" {% if not filters.wbs_required %}selected{% endif %}>egal</option>
<option value="yes" {% if filters.wbs_required == 'yes' %}selected{% endif %}>ja</option>
<option value="no" {% if filters.wbs_required == 'no' %}selected{% endif %}>nein</option>
</select>
</div>
<div class="col-span-2 md:col-span-3 flex gap-2 pt-2">
<button class="btn btn-primary" type="submit">Filter speichern</button>
<span class="text-xs text-slate-500 self-center">Leer lassen = kein Limit. Filter bestimmen Match-Hervorhebung + Auto-Bewerben.</span>
</div>
</form>
</details>
<!-- Liste aller Wohnungen -->
<section class="card">
<div class="flex items-center justify-between px-4 py-3 border-b border-soft">
<h2 class="font-semibold">Neueste Wohnungen auf inberlinwohnen.de</h2>
<span class="text-xs text-slate-500">{{ flats|length }} gesamt</span>
</div>
<div class="divide-y divide-soft">
{% for item in flats %}
{% set f = item.row %}
<div class="px-4 py-3 flex flex-col md:flex-row md:items-center gap-3 {% if item.matched %}bg-[#f2f8ff]{% endif %}">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<a class="font-medium truncate" href="{{ f.link }}" target="_blank" rel="noopener noreferrer">
{{ f.address or f.link }}
</a>
{% if item.matched %}<span class="chip chip-ok">match</span>{% endif %}
{% if item.last and item.last.success == 1 %}<span class="chip chip-ok">beworben</span>
{% elif item.last and item.last.success == 0 %}<span class="chip chip-bad">apply fehlgeschlagen</span>
{% elif item.last %}<span class="chip chip-warn">läuft</span>{% endif %}
</div>
<div class="text-xs text-slate-500 mt-0.5">
{% if f.rooms %}{{ "%.1f"|format(f.rooms) }} Z{% endif %}
{% if f.size %} · {{ "%.0f"|format(f.size) }} m²{% endif %}
{% if f.total_rent %} · {{ "%.0f"|format(f.total_rent) }} €{% endif %}
{% if f.sqm_price %} ({{ "%.2f"|format(f.sqm_price) }} €/m²){% endif %}
{% if f.connectivity_morning_time %} · {{ "%.0f"|format(f.connectivity_morning_time) }} min morgens{% endif %}
{% if f.wbs %} · WBS: {{ f.wbs }}{% endif %}
· entdeckt {{ f.discovered_at }}
</div>
{% if item.last and item.last.message %}
<div class="text-xs text-slate-500 mt-1 truncate">↳ {{ item.last.message }}</div>
{% endif %}
</div>
<div class="flex gap-2">
{% if apply_allowed and not (item.last and item.last.success == 1) %}
<form method="post" action="/actions/apply">
<input type="hidden" name="csrf" value="{{ csrf }}">
<input type="hidden" name="flat_id" value="{{ f.id }}">
<button class="btn btn-primary text-sm" type="submit"
onclick="return confirm('Bewerbung für {{ (f.address or f.link)|e }} ausführen?');">
Bewerben
</button>
</form>
{% endif %}
</div>
</div>
{% else %}
<div class="px-4 py-8 text-center text-slate-500">Noch keine Wohnungen entdeckt.</div>
{% endfor %}
</div>
</section>
{% endblock %}