multi-user: users, per-user profiles/filters/notifications, tab UI, apply forensics
* DB: users + user_profiles/filters/notifications/preferences; applications gets user_id + forensics_json + profile_snapshot_json; new errors table with 14d retention; schema versioning via MIGRATIONS list * auth: password hashes in DB (argon2); env vars seed first admin; per-user sessions; CSRF bound to user id * apply: personal info/WBS moved out of env into the request body; providers take an ApplyContext with Profile + submit_forms; full Playwright recorder (step log, console, page errors, network, screenshots, final HTML) * web: five top-level tabs (Wohnungen/Bewerbungen/Logs/Fehler/Einstellungen); settings sub-tabs profil/filter/benachrichtigungen/account/benutzer; per-user matching, auto-apply and notifications (UI/Telegram/SMTP); red auto-apply switch on Wohnungen tab; forensics detail view for bewerbungen and fehler; retention background thread Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e663386a19
commit
c630b500ef
36 changed files with 2763 additions and 1113 deletions
182
apply/actions.py
182
apply/actions.py
|
|
@ -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
66
apply/classes/profile.py
Normal 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)
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
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")
|
||||
if await already_applied_warning.first.is_visible():
|
||||
return True
|
||||
return False
|
||||
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",
|
||||
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")
|
||||
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)
|
||||
|
||||
return await msg.first.is_visible()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
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",
|
||||
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.")
|
||||
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)
|
||||
return await msg.first.is_visible()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
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")
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
690
web/app.py
690
web/app.py
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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": {}}
|
||||
|
|
|
|||
130
web/auth.py
130
web/auth.py
|
|
@ -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)
|
||||
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)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
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 "")
|
||||
|
|
|
|||
530
web/db.py
530
web/db.py
|
|
@ -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,7 +30,89 @@ def _connect() -> sqlite3.Connection:
|
|||
_conn: sqlite3.Connection = _connect()
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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 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 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 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 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 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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flats (
|
||||
id TEXT PRIMARY KEY,
|
||||
link TEXT NOT NULL,
|
||||
|
|
@ -35,111 +127,277 @@ CREATE TABLE IF NOT EXISTS flats (
|
|||
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
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS applications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
flat_id TEXT NOT NULL,
|
||||
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,
|
||||
FOREIGN KEY (flat_id) REFERENCES flats(id)
|
||||
profile_snapshot_json TEXT,
|
||||
forensics_json TEXT -- structured payload from apply service
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_applications_flat ON applications(flat_id);
|
||||
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 state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
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);
|
||||
|
||||
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 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 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 log_audit(actor: str, action: str, details: str = "", ip: str = "") -> None:
|
||||
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
52
web/matching.py
Normal 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
130
web/notifications.py
Normal 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
24
web/retention.py
Normal 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)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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">Kill‑Switch</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>
|
||||
31
web/templates/_layout.html
Normal file
31
web/templates/_layout.html
Normal 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 %}
|
||||
27
web/templates/_settings_account.html
Normal file
27
web/templates/_settings_account.html
Normal 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>
|
||||
40
web/templates/_settings_filter.html
Normal file
40
web/templates/_settings_filter.html
Normal 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>
|
||||
51
web/templates/_settings_notifications.html
Normal file
51
web/templates/_settings_notifications.html
Normal 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>
|
||||
123
web/templates/_settings_profil.html
Normal file
123
web/templates/_settings_profil.html
Normal 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>
|
||||
50
web/templates/_settings_users.html
Normal file
50
web/templates/_settings_users.html
Normal 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>
|
||||
|
|
@ -9,18 +9,12 @@
|
|||
<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;
|
||||
--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; }
|
||||
|
|
@ -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,6 +53,24 @@
|
|||
}
|
||||
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">
|
||||
|
|
|
|||
113
web/templates/bewerbung_detail.html
Normal file
113
web/templates/bewerbung_detail.html
Normal 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 %}
|
||||
32
web/templates/bewerbungen.html
Normal file
32
web/templates/bewerbungen.html
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
23
web/templates/einstellungen.html
Normal file
23
web/templates/einstellungen.html
Normal 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
29
web/templates/fehler.html
Normal 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 %}
|
||||
32
web/templates/fehler_detail.html
Normal file
32
web/templates/fehler_detail.html
Normal 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
23
web/templates/logs.html
Normal 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 %}
|
||||
182
web/templates/wohnungen.html
Normal file
182
web/templates/wohnungen.html
Normal 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 %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue