diff --git a/apply/actions.py b/apply/actions.py index 436e8f3..c1aab40 100644 --- a/apply/actions.py +++ b/apply/actions.py @@ -31,6 +31,7 @@ MAX_CONSOLE_ENTRIES = 200 MAX_NETWORK_ENTRIES = 150 MAX_BODY_SNIPPET = 2000 MAX_HTML_DUMP = 200_000 # 200 KB +MAX_SCREENSHOTS = 40 SCREENSHOT_JPEG_QUALITY = 60 @@ -117,21 +118,33 @@ class Recorder: page.on("request", on_request) page.on("response", lambda r: asyncio.create_task(on_response(r))) - # --- screenshots -------------------------------------------------------- + # --- screenshots + html dump ------------------------------------------- async def snap(self, page, label: str) -> None: + """Capture screenshot + full page HTML for this moment.""" + if len(self.screenshots) >= MAX_SCREENSHOTS: + return + ts = round(time.time() - self.started_at, 3) + entry = {"ts": ts, "label": label, "url": page.url, + "b64_jpeg": "", "size": 0, "html": "", "html_size": 0} 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), - }) + img = await page.screenshot(type="jpeg", quality=SCREENSHOT_JPEG_QUALITY, + full_page=False, timeout=5000) + entry["b64_jpeg"] = base64.b64encode(img).decode("ascii") + entry["size"] = len(img) except Exception as e: - logger.warning("snap failed (%s): %s", label, e) + logger.warning("snap screenshot failed (%s): %s", label, e) + try: + html = await page.content() + entry["html"] = html[:MAX_HTML_DUMP] + entry["html_size"] = len(html) + except Exception as e: + logger.warning("snap html failed (%s): %s", label, e) + self.screenshots.append(entry) + + async def step_snap(self, page, name: str, detail: str = "", status: str = "ok") -> None: + """Log a step AND capture a screenshot + HTML for it.""" + self.step(name, status, detail) + await self.snap(page, name) async def finalize(self, page) -> None: try: diff --git a/apply/providers/degewo.py b/apply/providers/degewo.py index c27f325..23e70ee 100644 --- a/apply/providers/degewo.py +++ b/apply/providers/degewo.py @@ -17,29 +17,34 @@ class Degewo(Provider): r = ctx.recorder p = ctx.profile async with open_page(url, recorder=r) as page: - r.step("cookies.check") + await r.step_snap(page, "cookies.check") cookie_accept_btn = page.locator("#cookie-consent-submit-all") if await cookie_accept_btn.is_visible(): await cookie_accept_btn.click() - r.step("cookies.accepted") + await r.step_snap(page, "cookies.accepted") - r.step("page.404_check") + await r.step_snap(page, "page.404_check") if page.url == "https://www.degewo.de/immosuche/404": + await r.step_snap(page, "abort.not_found", status="warn") return ApplicationResult(False, message=_("ad_offline")) - r.step("ad.deactivated_check") + await r.step_snap(page, "ad.deactivated_check") if await page.locator("span", has_text="Inserat deaktiviert").is_visible(): + await r.step_snap(page, "abort.deactivated", status="warn") return ApplicationResult(False, message=_("ad_deactivated")) - r.step("page.moved_check") + await r.step_snap(page, "page.moved_check") if await page.locator("h1", has_text="Diese Seite ist umgezogen!").is_visible(): + await r.step_snap(page, "abort.moved", status="warn") return ApplicationResult(False, message=_("ad_offline")) - r.step("form.open"); await page.get_by_role("link", name="Kontakt").click() - r.step("iframe.locate") + await r.step_snap(page, "form.open") + await page.get_by_role("link", name="Kontakt").click() + + await r.step_snap(page, "iframe.locate") form_frame = page.frame_locator("iframe[src*='wohnungshelden']") - r.step("form.fill_personal") + await r.step_snap(page, "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) @@ -49,11 +54,11 @@ class Degewo(Provider): 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") + await r.step_snap(page, "form.wbs_section") wbs_question = form_frame.locator("input[id*='wbs_available'][id$='Ja']") if await wbs_question.is_visible(): if not p.is_possessing_wbs: - r.step("wbs_required_abort", "warn") + await r.step_snap(page, "wbs_required_abort", status="warn") return ApplicationResult(False, _("wbs_required")) await wbs_question.click() await form_frame.locator("input[title='WBS gültig bis']").fill(p.wbs_valid_till_dt().strftime("%d.%m.%Y")) @@ -66,24 +71,26 @@ class Degewo(Provider): 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() + await r.step_snap(page, "form.filled") if not ctx.submit_forms: - r.step("submit.dry_run") + await r.step_snap(page, "submit.dry_run") return ApplicationResult(success=True, message=_("application_success_dry")) - r.step("submit.click"); await form_frame.locator("button[data-cy*='btn-submit']").click() + await r.step_snap(page, "submit.click") + await form_frame.locator("button[data-cy*='btn-submit']").click() await page.wait_for_timeout(3000) - r.step("success.check") + await r.step_snap(page, "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(): return ApplicationResult(success=True) elif await self._missing_fields_warning(page): - r.step("missing_fields_detected", "warn") + await r.step_snap(page, "missing_fields_detected", status="warn") return ApplicationResult(success=False, message=_("missing_fields")) elif await self._already_applied_warning(page): - r.step("already_applied_detected", "warn") + await r.step_snap(page, "already_applied_detected", status="warn") return ApplicationResult(success=False, message=_("already_applied")) - r.step("success.not_found", "warn") + await r.step_snap(page, "success.not_found", status="warn") return ApplicationResult(success=False, message=_("submit_conformation_msg_not_found")) async def _already_applied_warning(self, page) -> bool: diff --git a/apply/providers/gesobau.py b/apply/providers/gesobau.py index b198903..28f40a3 100644 --- a/apply/providers/gesobau.py +++ b/apply/providers/gesobau.py @@ -21,37 +21,42 @@ class Gesobau(Provider): return ApplicationResult(False, _("missing_fields")) async with open_page(url, recorder=r) as page: - r.step("extract.immomio_link") + await r.step_snap(page, "extract.immomio_link") immomio_link = await page.get_by_role("link", name="Jetzt bewerben").get_attribute("href") - r.step("auth.goto"); await page.goto("https://tenant.immomio.com/de/auth/login") + await r.step_snap(page, "auth.goto") + await page.goto("https://tenant.immomio.com/de/auth/login") await page.wait_for_timeout(1000) - r.step("auth.login") + await r.step_snap(page, "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(p.immomio_password) await page.locator("#kc-login").click() await page.wait_for_timeout(1000) + await r.step_snap(page, "auth.done") - r.step("back.to_immomio", detail=immomio_link or "") + await r.step_snap(page, "back.to_immomio", detail=immomio_link or "") await page.goto(immomio_link) await page.wait_for_timeout(1000) - r.step("cookies.check") + await r.step_snap(page, "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() + await r.step_snap(page, "cookies.accepted") - r.step("apply.click"); await page.get_by_role("button", name="Jetzt bewerben").click() + await r.step_snap(page, "apply.click") + await page.get_by_role("button", name="Jetzt bewerben").click() await page.wait_for_timeout(3000) + await r.step_snap(page, "apply.clicked") - r.step("already_applied.check") + await r.step_snap(page, "already_applied.check") if page.url == "https://tenant.immomio.com/de/properties/applications": return ApplicationResult(False, message=_("already_applied")) - r.step("answer_questions.click") + await r.step_snap(page, "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() @@ -61,9 +66,9 @@ class Gesobau(Provider): await answer_questions_btn.click() await page.wait_for_timeout(2000) - r.step("success.check") + await r.step_snap(page, "success.check") if not await answer_questions_btn.is_visible(): return ApplicationResult(True) - r.step("success.not_found", "warn") + await r.step_snap(page, "success.not_found", status="warn") return ApplicationResult(False, _("submit_conformation_msg_not_found")) diff --git a/apply/providers/gewobag.py b/apply/providers/gewobag.py index 05b530a..32436dd 100644 --- a/apply/providers/gewobag.py +++ b/apply/providers/gewobag.py @@ -17,31 +17,36 @@ class Gewobag(Provider): r = ctx.recorder p = ctx.profile async with open_page(url, recorder=r) as page: - r.step("cookies.check") + await r.step_snap(page, "cookies.check") btn = page.get_by_text("Alle Cookies akzeptieren") if await btn.is_visible(): await btn.click() - r.step("cookies.accepted") + await r.step_snap(page, "cookies.accepted") - r.step("ad.exists_check") + await r.step_snap(page, "ad.exists_check") if await page.get_by_text("Mietangebot nicht gefunden").first.is_visible(): + await r.step_snap(page, "abort.not_found", status="warn") return ApplicationResult(False, message=_("not_found")) - r.step("ad.still_open_check") + await r.step_snap(page, "ad.still_open_check") if await page.locator('#immo-mediation-notice').is_visible(): + await r.step_snap(page, "abort.deactivated", status="warn") return ApplicationResult(False, message=_("ad_deactivated")) - r.step("form.open"); await page.get_by_role("button", name="Anfrage senden").first.click() + await r.step_snap(page, "form.open") + await page.get_by_role("button", name="Anfrage senden").first.click() - r.step("form.senior_check") + await r.step_snap(page, "form.senior_check") if await self._is_senior_flat(page): + await r.step_snap(page, "abort.senior_flat", status="warn") return ApplicationResult(False, _("senior_flat")) - r.step("form.special_wbs_check") + await r.step_snap(page, "form.special_wbs_check") if await self._is_special_needs_wbs(page): + await r.step_snap(page, "abort.special_wbs", status="warn") return ApplicationResult(False, _("special_need_wbs_flat")) - r.step("iframe.locate") + await r.step_snap(page, "iframe.locate") form_iframe = page.frame_locator("#contact-iframe") async def fill_field(locator, filling): @@ -73,7 +78,7 @@ class Gewobag(Provider): await sec.locator("input[type='file']").set_input_files(files) await page.wait_for_timeout(2000) - r.step("form.fill_personal") + await r.step_snap(page, "form.fill_personal") await select_field("#salutation-dropdown", p.salutation) await fill_field("#firstName", p.firstname) await fill_field("#lastName", p.lastname) @@ -87,7 +92,7 @@ class Gewobag(Provider): await fill_field("input[id*='anzahl_kinder']", str(p.children_count)) await fill_field("input[id*='gesamtzahl_der_einziehenden_personen']", str(p.person_count)) - r.step("form.wbs_fill") + await r.step_snap(page, "form.wbs_fill") await check_checkbox("[data-cy*='wbs_available'][data-cy*='-Ja']") 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") @@ -96,21 +101,23 @@ class Gewobag(Provider): 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) + await r.step_snap(page, "form.filled") if not ctx.submit_forms: - r.step("submit.dry_run") + await r.step_snap(page, "submit.dry_run") return ApplicationResult(True, _("application_success_dry")) - r.step("submit.click"); await form_iframe.get_by_role("button", name="Anfrage versenden").click() + await r.step_snap(page, "submit.click") + await form_iframe.get_by_role("button", name="Anfrage versenden").click() await page.wait_for_timeout(5000) - r.step("success.check", detail=page.url) + await r.step_snap(page, "success.check", detail=page.url) if page.url.startswith("https://www.gewobag.de/daten-uebermittelt/"): return ApplicationResult(True) elif await self._is_missing_fields_warning(page): - r.step("missing_fields_detected", "warn") + await r.step_snap(page, "missing_fields_detected", status="warn") return ApplicationResult(False, _("missing_fields")) - r.step("success.not_found", "warn") + await r.step_snap(page, "success.not_found", status="warn") return ApplicationResult(False, _("submit_conformation_msg_not_found")) async def _is_senior_flat(self, page): diff --git a/apply/providers/howoge.py b/apply/providers/howoge.py index ae961de..39b3c22 100644 --- a/apply/providers/howoge.py +++ b/apply/providers/howoge.py @@ -17,39 +17,48 @@ class Howoge(Provider): r = ctx.recorder p = ctx.profile async with open_page(url, recorder=r) as page: - r.step("cookies.check") + await r.step_snap(page, "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() - r.step("cookies.accepted") + await r.step_snap(page, "cookies.accepted") else: r.step("cookies.absent") r.step("page.404_check", detail=page.url) if page.url == "https://www.howoge.de/404": + await r.step_snap(page, "abort.not_found", status="warn") return ApplicationResult(False, message=_("not_found")) - r.step("form.open") + await r.step_snap(page, "form.open") await page.get_by_role("link", name="Besichtigung anfragen").click() - r.step("form.wbs_hint"); await page.get_by_text("Ja, ich habe die Hinweise zum WBS zur Kenntnis genommen.").click() + await r.step_snap(page, "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() - r.step("form.income_hint"); await page.get_by_text("Ja, ich habe den Hinweis zum Haushaltsnettoeinkommen zur Kenntnis genommen.").click() + + await r.step_snap(page, "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() - r.step("form.bonitaet_hint"); await page.get_by_text("Ja, ich habe den Hinweis zur Bonitätsauskunft zur Kenntnis genommen.").click() + + await r.step_snap(page, "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() - r.step("form.name"); await page.locator("#immo-form-firstname").fill(p.firstname) + + await r.step_snap(page, "form.fill") + 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) if not ctx.submit_forms: - r.step("submit.dry_run") + await r.step_snap(page, "submit.dry_run") return ApplicationResult(True, _("application_success_dry")) - r.step("submit.click"); await page.get_by_role("button", name="Anfrage senden").click() + await r.step_snap(page, "submit.click") + await page.get_by_role("button", name="Anfrage senden").click() - r.step("success.check") + await r.step_snap(page, "success.check") if await page.get_by_role("heading", name="Vielen Dank.").is_visible(): return ApplicationResult(True) - r.step("success.not_found", "warn") + await r.step_snap(page, "success.not_found", status="warn") return ApplicationResult(False, _("submit_conformation_msg_not_found")) diff --git a/apply/providers/stadtundland.py b/apply/providers/stadtundland.py index 4074cd7..1806ae4 100644 --- a/apply/providers/stadtundland.py +++ b/apply/providers/stadtundland.py @@ -17,19 +17,18 @@ class Stadtundland(Provider): r = ctx.recorder p = ctx.profile async with open_page(url, recorder=r) as page: - r.step("cookies.check") + await r.step_snap(page, "cookies.check") cookie_accept_btn = page.get_by_text("Alle akzeptieren") if await cookie_accept_btn.is_visible(): await cookie_accept_btn.click() - r.step("cookies.accepted") - else: - r.step("cookies.absent") + await r.step_snap(page, "cookies.accepted") - r.step("page.offline_check") + await r.step_snap(page, "page.offline_check") if await page.get_by_role("heading", name="Hier ist etwas schief gelaufen").is_visible(): + await r.step_snap(page, "abort.offline", status="warn") return ApplicationResult(False, message=_("ad_offline")) - r.step("form.fill") + await r.step_snap(page, "form.fill") await page.locator("#name").fill(p.firstname) await page.locator("#surname").fill(p.lastname) await page.locator("#street").fill(p.street) @@ -40,17 +39,20 @@ class Stadtundland(Provider): await page.locator("#email").fill(p.email) await page.locator("#privacy").check() await page.locator("#provision").check() + await r.step_snap(page, "form.filled") if not ctx.submit_forms: - r.step("submit.dry_run") + await r.step_snap(page, "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 r.step_snap(page, "submit.review") + await page.get_by_role("button", name="Eingaben prüfen").click() + await r.step_snap(page, "submit.send") + await page.get_by_role("button", name="Absenden").click() await page.wait_for_timeout(2000) - r.step("success.check") + await r.step_snap(page, "success.check") if await page.locator("p").filter(has_text="Vielen Dank!").is_visible(): return ApplicationResult(True) - r.step("success.not_found", "warn") + await r.step_snap(page, "success.not_found", status="warn") return ApplicationResult(success=False, message=_("submit_conformation_msg_not_found")) diff --git a/apply/providers/wbm.py b/apply/providers/wbm.py index c0bc1f0..998a872 100644 --- a/apply/providers/wbm.py +++ b/apply/providers/wbm.py @@ -17,17 +17,16 @@ class Wbm(Provider): r = ctx.recorder p = ctx.profile async with open_page(url, recorder=r) as page: - r.step("page.404_check") + await r.step_snap(page, "page.404_check") if await page.get_by_role("heading", name="Page Not Found").is_visible(): + await r.step_snap(page, "abort.not_found", status="warn") return ApplicationResult(False, message=_("not_found")) - r.step("cookies.check") + await r.step_snap(page, "cookies.check") cookie_accept_btn = page.get_by_text("Alle zulassen") if await cookie_accept_btn.is_visible(): await cookie_accept_btn.click() - r.step("cookies.accepted") - else: - r.step("cookies.absent") + await r.step_snap(page, "cookies.accepted") r.step("chatbot.remove") try: @@ -35,13 +34,15 @@ class Wbm(Provider): except Exception as e: r.step("chatbot.remove_failed", "warn", str(e)) - r.step("ad.offline_check") + await r.step_snap(page, "ad.offline_check") if page.url == "https://www.wbm.de/wohnungen-berlin/angebote/": + await r.step_snap(page, "abort.offline", status="warn") return ApplicationResult(False, message=_("ad_offline")) - r.step("form.open"); await page.locator('.openimmo-detail__contact-box-button').click() + await r.step_snap(page, "form.open") + await page.locator('.openimmo-detail__contact-box-button').click() - r.step("form.wbs_section") + await r.step_snap(page, "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(p.wbs_valid_till_dt().strftime("%Y-%m-%d")) @@ -52,7 +53,7 @@ class Wbm(Provider): else: await page.locator('label[for="powermail_field_wbsvorhanden_2"]').click() - r.step("form.personal") + await r.step_snap(page, "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) @@ -62,20 +63,22 @@ class Wbm(Provider): 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) + await r.step_snap(page, "form.filled") if not ctx.submit_forms: - r.step("submit.dry_run") + await r.step_snap(page, "submit.dry_run") return ApplicationResult(success=True, message=_("application_success_dry")) - r.step("submit.click"); await page.get_by_role("button", name="Anfrage absenden").click() + await r.step_snap(page, "submit.click") + await page.get_by_role("button", name="Anfrage absenden").click() - r.step("success.check") + await r.step_snap(page, "success.check") if await page.get_by_text("Wir haben Ihre Anfrage für das Wohnungsangebot erhalten.").is_visible(): return ApplicationResult(True) elif await self._missing_fields_warning(page): - r.step("missing_fields_detected", "warn") + await r.step_snap(page, "missing_fields_detected", status="warn") return ApplicationResult(False, _("missing_fields")) - r.step("success.not_found", "warn") + await r.step_snap(page, "success.not_found", status="warn") return ApplicationResult(success=False, message=_("submit_conformation_msg_not_found")) async def _missing_fields_warning(self, page) -> bool: diff --git a/web/app.py b/web/app.py index 5d5b776..02cda38 100644 --- a/web/app.py +++ b/web/app.py @@ -383,15 +383,13 @@ def _wohnungen_context(user) -> dict: flats_view = [] for f in flats: + if not 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): + continue last = db.last_application_for_flat(uid, f["id"]) - flats_view.append({ - "row": f, - "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, - }) + flats_view.append({"row": f, "last": last}) allowed, reason = _manual_apply_allowed() alert_label, alert_chip = _alert_status(filters_row, notif_row) @@ -574,7 +572,8 @@ def bewerbung_zip(request: Request, app_id: int): f" console.json Browser console entries\n" f" errors.json Browser pageerror events\n" f" network.json Network requests + partial responses\n" - f" screenshots/*.jpg Screenshots at key moments\n" + f" snapshots/NN_*.jpg Screenshot at each step (NN = order)\n" + f" snapshots/NN_*.html Page HTML at each step\n" ) zf.writestr("application.json", json.dumps(app_meta, indent=2, default=str)) zf.writestr("flat.json", json.dumps(dict(flat) if flat else {}, indent=2, default=str)) @@ -593,14 +592,17 @@ def bewerbung_zip(request: Request, app_id: int): zf.writestr("network.json", json.dumps(forensics.get("network", []), indent=2)) for idx, s in enumerate(forensics.get("screenshots", []), start=1): + label = (s.get("label") or f"step{idx}").replace("/", "_").replace(" ", "_") b64 = s.get("b64_jpeg", "") if b64: try: data = base64.b64decode(b64) - label = (s.get("label") or f"step{idx}").replace("/", "_").replace(" ", "_") - zf.writestr(f"screenshots/{idx:02d}_{label}.jpg", data) + zf.writestr(f"snapshots/{idx:02d}_{label}.jpg", data) except Exception: pass + html = s.get("html") or "" + if html: + zf.writestr(f"snapshots/{idx:02d}_{label}.html", html) buf.seek(0) filename = f"lazyflat-report-{a['id']}.zip" @@ -614,47 +616,108 @@ def bewerbung_zip(request: Request, app_id: int): # Tab: Logs # --------------------------------------------------------------------------- +def _parse_date_range(from_str: str | None, to_str: str | None) -> tuple[str | None, str | None]: + """Parse 'YYYY-MM-DD' local-Berlin date inputs into UTC ISO bounds. + Bounds are inclusive start-of-day and start-of-next-day.""" + def _to_utc_iso(s: str, end_of_day: bool) -> str | None: + try: + d = datetime.strptime(s, "%Y-%m-%d").replace(tzinfo=BERLIN_TZ) + except ValueError: + return None + if end_of_day: + d = d + timedelta(days=1) + return d.astimezone(timezone.utc).isoformat(timespec="seconds") + + start = _to_utc_iso(from_str, False) if from_str else None + end = _to_utc_iso(to_str, True) if to_str else None + return start, end + + +def _collect_events(start_iso: str | None, end_iso: str | None) -> list[dict]: + users = {row["id"]: row["username"] for row in db.list_users()} + events: list[dict] = [] + for a in db.recent_audit(None, limit=5000): + if start_iso and a["timestamp"] < start_iso: continue + if end_iso and a["timestamp"] >= end_iso: continue + events.append({ + "kind": "audit", "ts": a["timestamp"], "source": "web", + "actor": a["actor"], "action": a["action"], + "details": a["details"] or "", + "user": users.get(a["user_id"], ""), + "ip": a["ip"] or "", + }) + for e in db.recent_errors(None, limit=5000): + if start_iso and e["timestamp"] < start_iso: continue + if end_iso and e["timestamp"] >= end_iso: continue + events.append({ + "kind": "error", "ts": e["timestamp"], "source": e["source"], + "actor": e["source"], "action": e["kind"], + "details": e["summary"] or "", + "user": users.get(e["user_id"], "") if e["user_id"] else "", + "ip": "", + }) + events.sort(key=lambda x: x["ts"], reverse=True) + return events + + @app.get("/logs", response_class=HTMLResponse) -def tab_logs(request: Request): +def tab_logs(request: Request, **kwargs): u = current_user(request) if not u: return RedirectResponse("/login", status_code=303) if not u["is_admin"]: raise HTTPException(403, "admin only") - # Merge audit + errors across the whole system for a unified view. - users = {row["id"]: row["username"] for row in db.list_users()} - events: list[dict] = [] - for a in db.recent_audit(None, limit=500): - events.append({ - "kind": "audit", - "ts": a["timestamp"], - "source": "web", - "actor": a["actor"], - "action": a["action"], - "details": a["details"] or "", - "user": users.get(a["user_id"], ""), - "ip": a["ip"] or "", - }) - for e in db.recent_errors(None, limit=500): - events.append({ - "kind": "error", - "ts": e["timestamp"], - "source": e["source"], - "actor": e["source"], - "action": e["kind"], - "details": e["summary"] or "", - "user": users.get(e["user_id"], "") if e["user_id"] else "", - "ip": "", - }) - events.sort(key=lambda x: x["ts"], reverse=True) - events = events[:300] + q = request.query_params + from_str = q.get("from") or "" + to_str = q.get("to") or "" + start_iso, end_iso = _parse_date_range(from_str or None, to_str or None) + events = _collect_events(start_iso, end_iso)[:500] ctx = base_context(request, u, "logs") - ctx["events"] = events + ctx.update({"events": events, "from_str": from_str, "to_str": to_str}) return templates.TemplateResponse("logs.html", ctx) +@app.get("/logs/export.csv") +def tab_logs_export(request: Request): + u = current_user(request) + if not u: + raise HTTPException(401) + if not u["is_admin"]: + raise HTTPException(403) + + import csv as _csv + q = request.query_params + start_iso, end_iso = _parse_date_range(q.get("from") or None, q.get("to") or None) + events = _collect_events(start_iso, end_iso) + + buf = io.StringIO() + w = _csv.writer(buf, delimiter=",", quoting=_csv.QUOTE_MINIMAL) + w.writerow(["timestamp_utc", "timestamp_berlin", "kind", "source", "actor", "action", "user", "details", "ip"]) + for e in events: + w.writerow([ + e["ts"], + _de_dt(e["ts"]), + e["kind"], + e["source"], + e["actor"], + e["action"], + e["user"], + e["details"], + e["ip"], + ]) + body = buf.getvalue().encode("utf-8") + filename = "lazyflat-protokoll" + if q.get("from"): filename += f"-{q['from']}" + if q.get("to"): filename += f"-bis-{q['to']}" + filename += ".csv" + return Response( + content=body, media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + # --------------------------------------------------------------------------- # Tab: Einstellungen (sub-tabs) # --------------------------------------------------------------------------- diff --git a/web/templates/_layout.html b/web/templates/_layout.html index 9111b6c..6453d11 100644 --- a/web/templates/_layout.html +++ b/web/templates/_layout.html @@ -11,9 +11,9 @@

lazyflat

- {{ user.username }}{% if is_admin %} · admin{% endif %} + {{ user.username }}{% if is_admin %} · Administrator{% endif %}
- +
@@ -21,7 +21,7 @@ Wohnungen Bewerbungen {% if is_admin %} - Logs + Protokoll {% endif %} Einstellungen diff --git a/web/templates/_settings_profil.html b/web/templates/_settings_profil.html index c915a7e..8370a0b 100644 --- a/web/templates/_settings_profil.html +++ b/web/templates/_settings_profil.html @@ -102,18 +102,10 @@
-

Formulare wirklich absenden?

+

Trockenmodus

experimentell - 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. + Im Trockenmodus wird das Formular ausgefüllt, aber nicht abgesendet. Erst deaktivieren, + wenn du jeden Anbieter einmal im Trockenmodus erfolgreich getestet hast — den Schalter + findest du auch oben auf der Wohnungen-Seite.

-
- - - -
-
- - - -
diff --git a/web/templates/_settings_users.html b/web/templates/_settings_users.html index 6295f05..6e18f44 100644 --- a/web/templates/_settings_users.html +++ b/web/templates/_settings_users.html @@ -18,7 +18,7 @@ @@ -30,7 +30,7 @@ {% for u in users %}
{{ u.username }} - {% if u.is_admin %}admin{% endif %} + {% if u.is_admin %}Administrator{% endif %} {% if u.disabled %}deaktiviert {% else %}aktiv{% endif %} {% if u.id != user.id %} diff --git a/web/templates/_wohnungen_body.html b/web/templates/_wohnungen_body.html index cccecc3..5ec910e 100644 --- a/web/templates/_wohnungen_body.html +++ b/web/templates/_wohnungen_body.html @@ -5,62 +5,73 @@ hx-trigger="every {{ poll_interval }}s" hx-swap="outerHTML"> - -
- + +
-
Alert
+
Alarm
{{ alert_label }}
- -
Filter
{{ filter_summary }}
+
- -
+ +
+ + - -
-
Auto-Bewerben
-
{% if auto_apply_enabled %}aktiv - {% else %}aus{% endif %}
+
Automatisch bewerben
+
+ +
- - -
+ + - -
-
Trockenmodus
-
{% if submit_forms %}aus (echt!) - {% else %}an{% endif %}
+
Trockenmodus
+
+ +
-
{% if not apply_allowed %}
- apply blockiert + Bewerbungs-Dienst nicht erreichbar {{ apply_block_reason }}
{% endif %} @@ -68,8 +79,8 @@ {% if circuit_open %}
- circuit open - {{ apply_failures }} Fehler in Serie — Auto-Bewerben pausiert + Automatik pausiert + {{ apply_failures }} Fehler in Folge
@@ -79,12 +90,12 @@
{% endif %} - +
-

Neueste Wohnungen auf inberlinwohnen.de

+

Passende Wohnungen auf inberlinwohnen.de

- {{ flats|length }} gesehen + {{ flats|length }} gefunden {% if next_scrape_utc %} · nächste Aktualisierung {% endif %} @@ -93,17 +104,16 @@
{% for item in flats %} {% set f = item.row %} -
+
{{ f.address or f.link }} - {% if item.matched %}match{% endif %} {% if item.last and item.last.finished_at is none %} läuft… {% elif item.last and item.last.success == 1 %}beworben - {% elif item.last and item.last.success == 0 %}apply fehlgeschlagen + {% elif item.last and item.last.success == 0 %}fehlgeschlagen {% endif %}
@@ -128,7 +138,7 @@ @@ -136,7 +146,13 @@
{% else %} -
Noch keine Wohnungen entdeckt.
+
+ {% if alert_label == 'nicht eingerichtet' %} + Bitte zuerst Filter einstellen, damit passende Wohnungen angezeigt werden. + {% else %} + Aktuell keine Wohnung, die alle Filter erfüllt. + {% endif %} +
{% endfor %}
diff --git a/web/templates/base.html b/web/templates/base.html index 3a0c1f1..b59fa1e 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -47,6 +47,12 @@ .chip-warn { background: #fff4dd; color: #a36a1f; border: 1px solid #f5d48b; } .chip-bad { background: #fde6e9; color: #b8404e; border: 1px solid #f5b5bf; } .chip-info { background: #e3effc; color: #1f5f99; border: 1px solid #b6d4f0; } + .radio-opt { display: inline-flex; align-items: center; gap: 0.45rem; cursor: pointer; padding: 0.35rem 0.65rem; + border: 1px solid var(--border); border-radius: 10px; background: var(--surface); + transition: border-color .15s, background .15s; user-select: none; } + .radio-opt:has(input:checked) { border-color: var(--primary); background: #ecf4fc; box-shadow: 0 0 0 1px var(--primary) inset; } + .radio-opt:hover { background: var(--ghost); } + .radio-opt input[type="radio"] { accent-color: var(--primary); } .brand-dot { width: 2rem; height: 2rem; border-radius: 10px; background: linear-gradient(135deg, #66b7f2 0%, #2f8ae0 60%, #fbd76b 100%); diff --git a/web/templates/bewerbung_detail.html b/web/templates/bewerbung_detail.html index a4deda5..9977409 100644 --- a/web/templates/bewerbung_detail.html +++ b/web/templates/bewerbung_detail.html @@ -8,10 +8,10 @@ {% if application.success == 1 %}erfolgreich {% elif application.success == 0 %}fehlgeschlagen {% else %}läuft{% endif %} - {{ application.triggered_by }} + {% if application.triggered_by == 'auto' %}automatisch{% else %}manuell{% endif %} {% if application.provider %}{{ application.provider }}{% endif %} {% if application.submit_forms_used %}echt gesendet - {% else %}dry-run{% endif %} + {% else %}Trockenmodus{% endif %} {% if application.success == 0 %} - Screenshots ({{ forensics.screenshots|length }}) + Momentaufnahmen ({{ forensics.screenshots|length }})
{% for s in forensics.screenshots %} -
-
{{ s.label }} @ {{ "%.2f"|format(s.ts) }}s — {{ s.url }}
+
+
{{ s.label }} @ {{ "%.2f"|format(s.ts) }}s — {{ s.url }}
+ {% if s.b64_jpeg %} {{ s.label }} + {% endif %} + {% if s.html %} +
+ Quellcode anzeigen ({{ s.html_size }} B) +
{{ s.html }}
+
+ {% endif %}
{% endfor %}
diff --git a/web/templates/bewerbungen.html b/web/templates/bewerbungen.html index eecc9bf..9204e4d 100644 --- a/web/templates/bewerbungen.html +++ b/web/templates/bewerbungen.html @@ -12,10 +12,10 @@ {% if a.success == 1 %}ok {% elif a.success == 0 %}fehlgeschlagen {% else %}läuft{% endif %} - {{ a.triggered_by }} + {% if a.triggered_by == 'auto' %}automatisch{% else %}manuell{% endif %} {% if a.provider %}{{ a.provider }}{% endif %} {% if a.submit_forms_used %}echt gesendet - {% else %}dry-run{% endif %} + {% else %}Trockenmodus{% endif %} {{ a.started_at|de_dt }}
diff --git a/web/templates/logs.html b/web/templates/logs.html index 741282d..aabd44b 100644 --- a/web/templates/logs.html +++ b/web/templates/logs.html @@ -1,9 +1,36 @@ {% extends "_layout.html" %} {% block content %} +
+
+
+ + +
+
+ + +
+ +
zurücksetzen + + CSV herunterladen + +
+
+
-

System-Log

- {{ events|length }} Einträge (max 300, letzte 14 Tage) +

System-Protokoll

+ + {{ events|length }} Einträge + {% if from_str or to_str %} + · Zeitraum: + {{ from_str or "alles davor" }} — {{ to_str or "heute" }} + {% else %} + · ohne Filter (Anzeige bis 500; Aufbewahrung 14 Tage) + {% endif %} +
{% for e in events %} @@ -15,12 +42,12 @@ {{ e.source }} {% endif %} {{ e.action }} - {% if e.user %}user={{ e.user }}{% endif %} + {% if e.user %}Benutzer: {{ e.user }}{% endif %} {% if e.details %}— {{ e.details }}{% endif %} {% if e.ip %}[{{ e.ip }}]{% endif %}
{% else %} -
Keine Einträge.
+
Keine Einträge im gewählten Zeitraum.
{% endfor %}