per-step screenshot + html snapshots, matches-only list, full German UI, CSV export
* apply: Recorder.step_snap(page, name) captures both a JPEG screenshot and the page HTML for every major moment; every provider now calls step_snap at each logical step so failure reports contain the exact DOM and rendered state at every stage of the flow * ZIP report: each snapshot becomes snapshots/NN_<label>.jpg + snapshots/NN_<label>.html for AI-assisted debugging * web: Wohnungsliste zeigt nur noch Flats, die die eigenen Filter treffen; Match-Chip entfernt (Liste ist jetzt implizit matchend) * UI komplett auf Deutsch: Protokoll statt Logs, Administrator statt admin, Trockenmodus statt dry-run, Automatik pausiert statt circuit open, Alarm statt Alert, Abmelden statt Logout * Wohnungen-Header: Zeile 1 Info (Alarm + Filter), Zeile 2 Schalter mit echten Radio-Paaren (An/Aus) für Automatisch bewerben und Trockenmodus; hx-confirm auf den kritischen Radios; per-form CSS für sichtbaren Check-State * Protokoll: von/bis-Datumsfilter (Berliner Zeit) + CSV-Download (/logs/export.csv) mit UTC + lokaler Zeit Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
04b591fa9e
commit
7444f90d6a
16 changed files with 360 additions and 202 deletions
|
|
@ -31,6 +31,7 @@ MAX_CONSOLE_ENTRIES = 200
|
||||||
MAX_NETWORK_ENTRIES = 150
|
MAX_NETWORK_ENTRIES = 150
|
||||||
MAX_BODY_SNIPPET = 2000
|
MAX_BODY_SNIPPET = 2000
|
||||||
MAX_HTML_DUMP = 200_000 # 200 KB
|
MAX_HTML_DUMP = 200_000 # 200 KB
|
||||||
|
MAX_SCREENSHOTS = 40
|
||||||
SCREENSHOT_JPEG_QUALITY = 60
|
SCREENSHOT_JPEG_QUALITY = 60
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -117,21 +118,33 @@ class Recorder:
|
||||||
page.on("request", on_request)
|
page.on("request", on_request)
|
||||||
page.on("response", lambda r: asyncio.create_task(on_response(r)))
|
page.on("response", lambda r: asyncio.create_task(on_response(r)))
|
||||||
|
|
||||||
# --- screenshots --------------------------------------------------------
|
# --- screenshots + html dump -------------------------------------------
|
||||||
async def snap(self, page, label: str) -> None:
|
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:
|
try:
|
||||||
data = await page.screenshot(type="jpeg", quality=SCREENSHOT_JPEG_QUALITY,
|
img = await page.screenshot(type="jpeg", quality=SCREENSHOT_JPEG_QUALITY,
|
||||||
full_page=False, timeout=5000)
|
full_page=False, timeout=5000)
|
||||||
b64 = base64.b64encode(data).decode("ascii")
|
entry["b64_jpeg"] = base64.b64encode(img).decode("ascii")
|
||||||
self.screenshots.append({
|
entry["size"] = len(img)
|
||||||
"ts": round(time.time() - self.started_at, 3),
|
|
||||||
"label": label,
|
|
||||||
"url": page.url,
|
|
||||||
"b64_jpeg": b64,
|
|
||||||
"size": len(data),
|
|
||||||
})
|
|
||||||
except Exception as e:
|
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:
|
async def finalize(self, page) -> None:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -17,29 +17,34 @@ class Degewo(Provider):
|
||||||
r = ctx.recorder
|
r = ctx.recorder
|
||||||
p = ctx.profile
|
p = ctx.profile
|
||||||
async with open_page(url, recorder=r) as page:
|
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")
|
cookie_accept_btn = page.locator("#cookie-consent-submit-all")
|
||||||
if await cookie_accept_btn.is_visible():
|
if await cookie_accept_btn.is_visible():
|
||||||
await cookie_accept_btn.click()
|
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":
|
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"))
|
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():
|
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"))
|
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():
|
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"))
|
return ApplicationResult(False, message=_("ad_offline"))
|
||||||
|
|
||||||
r.step("form.open"); await page.get_by_role("link", name="Kontakt").click()
|
await r.step_snap(page, "form.open")
|
||||||
r.step("iframe.locate")
|
await page.get_by_role("link", name="Kontakt").click()
|
||||||
|
|
||||||
|
await r.step_snap(page, "iframe.locate")
|
||||||
form_frame = page.frame_locator("iframe[src*='wohnungshelden']")
|
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.locator("#salutation").fill(p.salutation)
|
||||||
await form_frame.get_by_role("option", name=p.salutation, exact=True).click()
|
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("#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 form_frame.locator("input[title='Anzahl einziehende Personen']").fill(str(p.person_count))
|
||||||
await page.wait_for_timeout(1000)
|
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']")
|
wbs_question = form_frame.locator("input[id*='wbs_available'][id$='Ja']")
|
||||||
if await wbs_question.is_visible():
|
if await wbs_question.is_visible():
|
||||||
if not p.is_possessing_wbs:
|
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"))
|
return ApplicationResult(False, _("wbs_required"))
|
||||||
await wbs_question.click()
|
await wbs_question.click()
|
||||||
await form_frame.locator("input[title='WBS gültig bis']").fill(p.wbs_valid_till_dt().strftime("%d.%m.%Y"))
|
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 form_frame.locator("ng-select[id*='fuer_wen_ist_wohnungsanfrage']").click()
|
||||||
await page.wait_for_timeout(1000)
|
await page.wait_for_timeout(1000)
|
||||||
await form_frame.get_by_role("option", name="Für mich selbst").click()
|
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:
|
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"))
|
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)
|
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():
|
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)
|
return ApplicationResult(success=True)
|
||||||
elif await self._missing_fields_warning(page):
|
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"))
|
return ApplicationResult(success=False, message=_("missing_fields"))
|
||||||
elif await self._already_applied_warning(page):
|
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"))
|
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"))
|
return ApplicationResult(success=False, message=_("submit_conformation_msg_not_found"))
|
||||||
|
|
||||||
async def _already_applied_warning(self, page) -> bool:
|
async def _already_applied_warning(self, page) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -21,37 +21,42 @@ class Gesobau(Provider):
|
||||||
return ApplicationResult(False, _("missing_fields"))
|
return ApplicationResult(False, _("missing_fields"))
|
||||||
|
|
||||||
async with open_page(url, recorder=r) as page:
|
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")
|
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)
|
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.locator('input[name="email"]').fill(p.immomio_email)
|
||||||
await page.get_by_role("button", name="Anmelden").click()
|
await page.get_by_role("button", name="Anmelden").click()
|
||||||
await page.wait_for_timeout(1000)
|
await page.wait_for_timeout(1000)
|
||||||
await page.locator("#password").fill(p.immomio_password)
|
await page.locator("#password").fill(p.immomio_password)
|
||||||
await page.locator("#kc-login").click()
|
await page.locator("#kc-login").click()
|
||||||
await page.wait_for_timeout(1000)
|
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.goto(immomio_link)
|
||||||
await page.wait_for_timeout(1000)
|
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")
|
cookie_accept_btn = page.get_by_role("button", name="Alle erlauben")
|
||||||
if await cookie_accept_btn.is_visible():
|
if await cookie_accept_btn.is_visible():
|
||||||
await cookie_accept_btn.click()
|
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 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":
|
if page.url == "https://tenant.immomio.com/de/properties/applications":
|
||||||
return ApplicationResult(False, message=_("already_applied"))
|
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")
|
answer_questions_btn = page.get_by_role("button", name="Fragen beantworten")
|
||||||
if await answer_questions_btn.is_visible():
|
if await answer_questions_btn.is_visible():
|
||||||
await answer_questions_btn.click()
|
await answer_questions_btn.click()
|
||||||
|
|
@ -61,9 +66,9 @@ class Gesobau(Provider):
|
||||||
await answer_questions_btn.click()
|
await answer_questions_btn.click()
|
||||||
await page.wait_for_timeout(2000)
|
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():
|
if not await answer_questions_btn.is_visible():
|
||||||
return ApplicationResult(True)
|
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"))
|
return ApplicationResult(False, _("submit_conformation_msg_not_found"))
|
||||||
|
|
|
||||||
|
|
@ -17,31 +17,36 @@ class Gewobag(Provider):
|
||||||
r = ctx.recorder
|
r = ctx.recorder
|
||||||
p = ctx.profile
|
p = ctx.profile
|
||||||
async with open_page(url, recorder=r) as page:
|
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")
|
btn = page.get_by_text("Alle Cookies akzeptieren")
|
||||||
if await btn.is_visible():
|
if await btn.is_visible():
|
||||||
await btn.click()
|
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():
|
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"))
|
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():
|
if await page.locator('#immo-mediation-notice').is_visible():
|
||||||
|
await r.step_snap(page, "abort.deactivated", status="warn")
|
||||||
return ApplicationResult(False, message=_("ad_deactivated"))
|
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):
|
if await self._is_senior_flat(page):
|
||||||
|
await r.step_snap(page, "abort.senior_flat", status="warn")
|
||||||
return ApplicationResult(False, _("senior_flat"))
|
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):
|
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"))
|
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")
|
form_iframe = page.frame_locator("#contact-iframe")
|
||||||
|
|
||||||
async def fill_field(locator, filling):
|
async def fill_field(locator, filling):
|
||||||
|
|
@ -73,7 +78,7 @@ class Gewobag(Provider):
|
||||||
await sec.locator("input[type='file']").set_input_files(files)
|
await sec.locator("input[type='file']").set_input_files(files)
|
||||||
await page.wait_for_timeout(2000)
|
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 select_field("#salutation-dropdown", p.salutation)
|
||||||
await fill_field("#firstName", p.firstname)
|
await fill_field("#firstName", p.firstname)
|
||||||
await fill_field("#lastName", p.lastname)
|
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*='anzahl_kinder']", str(p.children_count))
|
||||||
await fill_field("input[id*='gesamtzahl_der_einziehenden_personen']", str(p.person_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 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 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*='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 fill_field("input[id*='telephone_number']", p.telephone)
|
||||||
await check_checkbox("input[id*='datenschutzhinweis']")
|
await check_checkbox("input[id*='datenschutzhinweis']")
|
||||||
await upload_files("el-application-form-document-upload", files=None)
|
await upload_files("el-application-form-document-upload", files=None)
|
||||||
|
await r.step_snap(page, "form.filled")
|
||||||
|
|
||||||
if not ctx.submit_forms:
|
if not ctx.submit_forms:
|
||||||
r.step("submit.dry_run")
|
await r.step_snap(page, "submit.dry_run")
|
||||||
return ApplicationResult(True, _("application_success_dry"))
|
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)
|
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/"):
|
if page.url.startswith("https://www.gewobag.de/daten-uebermittelt/"):
|
||||||
return ApplicationResult(True)
|
return ApplicationResult(True)
|
||||||
elif await self._is_missing_fields_warning(page):
|
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"))
|
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"))
|
return ApplicationResult(False, _("submit_conformation_msg_not_found"))
|
||||||
|
|
||||||
async def _is_senior_flat(self, page):
|
async def _is_senior_flat(self, page):
|
||||||
|
|
|
||||||
|
|
@ -17,39 +17,48 @@ class Howoge(Provider):
|
||||||
r = ctx.recorder
|
r = ctx.recorder
|
||||||
p = ctx.profile
|
p = ctx.profile
|
||||||
async with open_page(url, recorder=r) as page:
|
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")
|
cookie_accept_btn = page.get_by_role("button", name="Alles akzeptieren")
|
||||||
if await cookie_accept_btn.is_visible():
|
if await cookie_accept_btn.is_visible():
|
||||||
await cookie_accept_btn.click()
|
await cookie_accept_btn.click()
|
||||||
r.step("cookies.accepted")
|
await r.step_snap(page, "cookies.accepted")
|
||||||
else:
|
else:
|
||||||
r.step("cookies.absent")
|
r.step("cookies.absent")
|
||||||
|
|
||||||
r.step("page.404_check", detail=page.url)
|
r.step("page.404_check", detail=page.url)
|
||||||
if page.url == "https://www.howoge.de/404":
|
if page.url == "https://www.howoge.de/404":
|
||||||
|
await r.step_snap(page, "abort.not_found", status="warn")
|
||||||
return ApplicationResult(False, message=_("not_found"))
|
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()
|
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()
|
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()
|
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()
|
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-lastname").fill(p.lastname)
|
||||||
await page.locator("#immo-form-email").fill(p.email)
|
await page.locator("#immo-form-email").fill(p.email)
|
||||||
|
|
||||||
if not ctx.submit_forms:
|
if not ctx.submit_forms:
|
||||||
r.step("submit.dry_run")
|
await r.step_snap(page, "submit.dry_run")
|
||||||
return ApplicationResult(True, _("application_success_dry"))
|
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():
|
if await page.get_by_role("heading", name="Vielen Dank.").is_visible():
|
||||||
return ApplicationResult(True)
|
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"))
|
return ApplicationResult(False, _("submit_conformation_msg_not_found"))
|
||||||
|
|
|
||||||
|
|
@ -17,19 +17,18 @@ class Stadtundland(Provider):
|
||||||
r = ctx.recorder
|
r = ctx.recorder
|
||||||
p = ctx.profile
|
p = ctx.profile
|
||||||
async with open_page(url, recorder=r) as page:
|
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")
|
cookie_accept_btn = page.get_by_text("Alle akzeptieren")
|
||||||
if await cookie_accept_btn.is_visible():
|
if await cookie_accept_btn.is_visible():
|
||||||
await cookie_accept_btn.click()
|
await cookie_accept_btn.click()
|
||||||
r.step("cookies.accepted")
|
await r.step_snap(page, "cookies.accepted")
|
||||||
else:
|
|
||||||
r.step("cookies.absent")
|
|
||||||
|
|
||||||
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():
|
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"))
|
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("#name").fill(p.firstname)
|
||||||
await page.locator("#surname").fill(p.lastname)
|
await page.locator("#surname").fill(p.lastname)
|
||||||
await page.locator("#street").fill(p.street)
|
await page.locator("#street").fill(p.street)
|
||||||
|
|
@ -40,17 +39,20 @@ class Stadtundland(Provider):
|
||||||
await page.locator("#email").fill(p.email)
|
await page.locator("#email").fill(p.email)
|
||||||
await page.locator("#privacy").check()
|
await page.locator("#privacy").check()
|
||||||
await page.locator("#provision").check()
|
await page.locator("#provision").check()
|
||||||
|
await r.step_snap(page, "form.filled")
|
||||||
|
|
||||||
if not ctx.submit_forms:
|
if not ctx.submit_forms:
|
||||||
r.step("submit.dry_run")
|
await r.step_snap(page, "submit.dry_run")
|
||||||
return ApplicationResult(True, _("application_success_dry"))
|
return ApplicationResult(True, _("application_success_dry"))
|
||||||
|
|
||||||
r.step("submit.review"); await page.get_by_role("button", name="Eingaben prüfen").click()
|
await r.step_snap(page, "submit.review")
|
||||||
r.step("submit.send"); await page.get_by_role("button", name="Absenden").click()
|
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)
|
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():
|
if await page.locator("p").filter(has_text="Vielen Dank!").is_visible():
|
||||||
return ApplicationResult(True)
|
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"))
|
return ApplicationResult(success=False, message=_("submit_conformation_msg_not_found"))
|
||||||
|
|
|
||||||
|
|
@ -17,17 +17,16 @@ class Wbm(Provider):
|
||||||
r = ctx.recorder
|
r = ctx.recorder
|
||||||
p = ctx.profile
|
p = ctx.profile
|
||||||
async with open_page(url, recorder=r) as page:
|
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():
|
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"))
|
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")
|
cookie_accept_btn = page.get_by_text("Alle zulassen")
|
||||||
if await cookie_accept_btn.is_visible():
|
if await cookie_accept_btn.is_visible():
|
||||||
await cookie_accept_btn.click()
|
await cookie_accept_btn.click()
|
||||||
r.step("cookies.accepted")
|
await r.step_snap(page, "cookies.accepted")
|
||||||
else:
|
|
||||||
r.step("cookies.absent")
|
|
||||||
|
|
||||||
r.step("chatbot.remove")
|
r.step("chatbot.remove")
|
||||||
try:
|
try:
|
||||||
|
|
@ -35,13 +34,15 @@ class Wbm(Provider):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
r.step("chatbot.remove_failed", "warn", str(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/":
|
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"))
|
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:
|
if p.is_possessing_wbs:
|
||||||
await page.locator('label[for="powermail_field_wbsvorhanden_1"]').click()
|
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"))
|
await page.locator("input[name*='[wbsgueltigbis]']").fill(p.wbs_valid_till_dt().strftime("%Y-%m-%d"))
|
||||||
|
|
@ -52,7 +53,7 @@ class Wbm(Provider):
|
||||||
else:
|
else:
|
||||||
await page.locator('label[for="powermail_field_wbsvorhanden_2"]').click()
|
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_anrede").select_option(p.salutation)
|
||||||
await page.locator("#powermail_field_name").fill(p.lastname)
|
await page.locator("#powermail_field_name").fill(p.lastname)
|
||||||
await page.locator("#powermail_field_vorname").fill(p.firstname)
|
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_e_mail").fill(p.email)
|
||||||
await page.locator("#powermail_field_telefon").fill(p.telephone)
|
await page.locator("#powermail_field_telefon").fill(p.telephone)
|
||||||
await page.locator("#powermail_field_datenschutzhinweis_1").check(force=True)
|
await page.locator("#powermail_field_datenschutzhinweis_1").check(force=True)
|
||||||
|
await r.step_snap(page, "form.filled")
|
||||||
|
|
||||||
if not ctx.submit_forms:
|
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"))
|
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():
|
if await page.get_by_text("Wir haben Ihre Anfrage für das Wohnungsangebot erhalten.").is_visible():
|
||||||
return ApplicationResult(True)
|
return ApplicationResult(True)
|
||||||
elif await self._missing_fields_warning(page):
|
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"))
|
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"))
|
return ApplicationResult(success=False, message=_("submit_conformation_msg_not_found"))
|
||||||
|
|
||||||
async def _missing_fields_warning(self, page) -> bool:
|
async def _missing_fields_warning(self, page) -> bool:
|
||||||
|
|
|
||||||
143
web/app.py
143
web/app.py
|
|
@ -383,15 +383,13 @@ def _wohnungen_context(user) -> dict:
|
||||||
|
|
||||||
flats_view = []
|
flats_view = []
|
||||||
for f in flats:
|
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"])
|
last = db.last_application_for_flat(uid, f["id"])
|
||||||
flats_view.append({
|
flats_view.append({"row": f, "last": last})
|
||||||
"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,
|
|
||||||
})
|
|
||||||
|
|
||||||
allowed, reason = _manual_apply_allowed()
|
allowed, reason = _manual_apply_allowed()
|
||||||
alert_label, alert_chip = _alert_status(filters_row, notif_row)
|
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" console.json Browser console entries\n"
|
||||||
f" errors.json Browser pageerror events\n"
|
f" errors.json Browser pageerror events\n"
|
||||||
f" network.json Network requests + partial responses\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("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))
|
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))
|
zf.writestr("network.json", json.dumps(forensics.get("network", []), indent=2))
|
||||||
|
|
||||||
for idx, s in enumerate(forensics.get("screenshots", []), start=1):
|
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", "")
|
b64 = s.get("b64_jpeg", "")
|
||||||
if b64:
|
if b64:
|
||||||
try:
|
try:
|
||||||
data = base64.b64decode(b64)
|
data = base64.b64decode(b64)
|
||||||
label = (s.get("label") or f"step{idx}").replace("/", "_").replace(" ", "_")
|
zf.writestr(f"snapshots/{idx:02d}_{label}.jpg", data)
|
||||||
zf.writestr(f"screenshots/{idx:02d}_{label}.jpg", data)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
html = s.get("html") or ""
|
||||||
|
if html:
|
||||||
|
zf.writestr(f"snapshots/{idx:02d}_{label}.html", html)
|
||||||
|
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
filename = f"lazyflat-report-{a['id']}.zip"
|
filename = f"lazyflat-report-{a['id']}.zip"
|
||||||
|
|
@ -614,47 +616,108 @@ def bewerbung_zip(request: Request, app_id: int):
|
||||||
# Tab: Logs
|
# 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)
|
@app.get("/logs", response_class=HTMLResponse)
|
||||||
def tab_logs(request: Request):
|
def tab_logs(request: Request, **kwargs):
|
||||||
u = current_user(request)
|
u = current_user(request)
|
||||||
if not u:
|
if not u:
|
||||||
return RedirectResponse("/login", status_code=303)
|
return RedirectResponse("/login", status_code=303)
|
||||||
if not u["is_admin"]:
|
if not u["is_admin"]:
|
||||||
raise HTTPException(403, "admin only")
|
raise HTTPException(403, "admin only")
|
||||||
|
|
||||||
# Merge audit + errors across the whole system for a unified view.
|
q = request.query_params
|
||||||
users = {row["id"]: row["username"] for row in db.list_users()}
|
from_str = q.get("from") or ""
|
||||||
events: list[dict] = []
|
to_str = q.get("to") or ""
|
||||||
for a in db.recent_audit(None, limit=500):
|
start_iso, end_iso = _parse_date_range(from_str or None, to_str or None)
|
||||||
events.append({
|
events = _collect_events(start_iso, end_iso)[:500]
|
||||||
"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]
|
|
||||||
|
|
||||||
ctx = base_context(request, u, "logs")
|
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)
|
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)
|
# Tab: Einstellungen (sub-tabs)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@
|
||||||
<h1 class="text-xl font-semibold">lazyflat</h1>
|
<h1 class="text-xl font-semibold">lazyflat</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4 text-sm">
|
<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>
|
<span class="text-slate-500">{{ user.username }}{% if is_admin %} · <span class="chip chip-info">Administrator</span>{% endif %}</span>
|
||||||
<form method="post" action="/logout">
|
<form method="post" action="/logout">
|
||||||
<button class="btn btn-ghost text-sm" type="submit">Logout</button>
|
<button class="btn btn-ghost text-sm" type="submit">Abmelden</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
<a class="tab {% if active_tab=='wohnungen' %}active{% endif %}" href="/">Wohnungen</a>
|
<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=='bewerbungen' %}active{% endif %}" href="/bewerbungen">Bewerbungen</a>
|
||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
<a class="tab {% if active_tab=='logs' %}active{% endif %}" href="/logs">Logs</a>
|
<a class="tab {% if active_tab=='logs' %}active{% endif %}" href="/logs">Protokoll</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="tab {% if active_tab=='einstellungen' %}active{% endif %}" href="/einstellungen">Einstellungen</a>
|
<a class="tab {% if active_tab=='einstellungen' %}active{% endif %}" href="/einstellungen">Einstellungen</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -102,18 +102,10 @@
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<hr class="my-6 border-soft">
|
<hr class="my-6 border-soft">
|
||||||
<h3 class="font-semibold mb-2">Formulare wirklich absenden?</h3>
|
<h3 class="font-semibold mb-2">Trockenmodus</h3>
|
||||||
<p class="text-sm text-slate-600 mb-3">
|
<p class="text-sm text-slate-600 mb-3">
|
||||||
<span class="chip chip-warn">experimentell</span>
|
<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.
|
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.
|
||||||
</p>
|
</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>
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
</div>
|
</div>
|
||||||
<label class="inline-flex items-center gap-2">
|
<label class="inline-flex items-center gap-2">
|
||||||
<input type="checkbox" name="is_admin">
|
<input type="checkbox" name="is_admin">
|
||||||
<span>Admin-Rechte</span>
|
<span>Administrator-Rechte</span>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-primary" type="submit">Anlegen</button>
|
<button class="btn btn-primary" type="submit">Anlegen</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
{% for u in users %}
|
{% for u in users %}
|
||||||
<div class="px-3 py-2 flex items-center gap-2 text-sm">
|
<div class="px-3 py-2 flex items-center gap-2 text-sm">
|
||||||
<span class="flex-1">{{ u.username }}</span>
|
<span class="flex-1">{{ u.username }}</span>
|
||||||
{% if u.is_admin %}<span class="chip chip-info">admin</span>{% endif %}
|
{% if u.is_admin %}<span class="chip chip-info">Administrator</span>{% endif %}
|
||||||
{% if u.disabled %}<span class="chip chip-bad">deaktiviert</span>
|
{% if u.disabled %}<span class="chip chip-bad">deaktiviert</span>
|
||||||
{% else %}<span class="chip chip-ok">aktiv</span>{% endif %}
|
{% else %}<span class="chip chip-ok">aktiv</span>{% endif %}
|
||||||
{% if u.id != user.id %}
|
{% if u.id != user.id %}
|
||||||
|
|
|
||||||
|
|
@ -5,62 +5,73 @@
|
||||||
hx-trigger="every {{ poll_interval }}s"
|
hx-trigger="every {{ poll_interval }}s"
|
||||||
hx-swap="outerHTML">
|
hx-swap="outerHTML">
|
||||||
|
|
||||||
<!-- Slim status strip: Alert · Filter · Auto-Bewerben · Trockenmodus -->
|
<!-- Reihe 1: Info-Kacheln Alarm + Filter -->
|
||||||
<section class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<section class="grid grid-cols-2 gap-3">
|
||||||
<!-- Alert -->
|
|
||||||
<a class="card px-4 py-2.5 flex flex-col gap-0.5 hover:bg-[#f6fafd]" href="/einstellungen/filter">
|
<a class="card px-4 py-2.5 flex flex-col gap-0.5 hover:bg-[#f6fafd]" href="/einstellungen/filter">
|
||||||
<div class="text-[11px] uppercase tracking-wide text-slate-500">Alert</div>
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Alarm</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="chip chip-{{ alert_chip }}">{{ alert_label }}</span>
|
<span class="chip chip-{{ alert_chip }}">{{ alert_label }}</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Filter summary -->
|
|
||||||
<a class="card px-4 py-2.5 flex flex-col gap-0.5 hover:bg-[#f6fafd]" href="/einstellungen/filter">
|
<a class="card px-4 py-2.5 flex flex-col gap-0.5 hover:bg-[#f6fafd]" href="/einstellungen/filter">
|
||||||
<div class="text-[11px] uppercase tracking-wide text-slate-500">Filter</div>
|
<div class="text-[11px] uppercase tracking-wide text-slate-500">Filter</div>
|
||||||
<div class="text-sm text-slate-700 truncate">{{ filter_summary }}</div>
|
<div class="text-sm text-slate-700 truncate">{{ filter_summary }}</div>
|
||||||
</a>
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Auto-Bewerben toggle -->
|
<!-- Reihe 2: Schalter Automatisch bewerben + Trockenmodus (Radio-Gruppen) -->
|
||||||
<form class="card px-4 py-2.5 flex items-center justify-between gap-2"
|
<section class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
method="post" action="/actions/auto-apply"
|
<!-- Automatisch bewerben -->
|
||||||
hx-post="/actions/auto-apply" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
<form class="card p-4"
|
||||||
|
hx-post="/actions/auto-apply"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-target="#wohnungen-body"
|
||||||
|
hx-swap="outerHTML">
|
||||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||||
<input type="hidden" name="value" value="{% if auto_apply_enabled %}off{% else %}on{% endif %}">
|
<div class="text-[11px] uppercase tracking-wide text-slate-500 mb-2">Automatisch bewerben</div>
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex gap-4">
|
||||||
<div class="text-[11px] uppercase tracking-wide text-slate-500">Auto-Bewerben</div>
|
<label class="radio-opt">
|
||||||
<div>{% if auto_apply_enabled %}<span class="chip chip-warn">aktiv</span>
|
<input type="radio" name="value" value="off"
|
||||||
{% else %}<span class="chip chip-info">aus</span>{% endif %}</div>
|
{% if not auto_apply_enabled %}checked{% endif %}>
|
||||||
|
<span>Aus</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio-opt"
|
||||||
|
{% if not auto_apply_enabled %}data-hx-confirm="Automatisches Bewerben einschalten? Bei jedem passenden Flat wird automatisch beworben."{% endif %}>
|
||||||
|
<input type="radio" name="value" value="on"
|
||||||
|
hx-confirm="Automatisches Bewerben einschalten? Bei jedem passenden Flat wird automatisch beworben."
|
||||||
|
{% if auto_apply_enabled %}checked{% endif %}>
|
||||||
|
<span>An</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn {% if auto_apply_enabled %}btn-ghost{% else %}btn-hot{% endif %} text-xs"
|
|
||||||
onclick="return confirm('{% if auto_apply_enabled %}Auto-Bewerben deaktivieren?{% else %}Auto-Bewerben aktivieren? Bei jedem passenden Flat wird automatisch beworben.{% endif %}');"
|
|
||||||
type="submit">
|
|
||||||
{% if auto_apply_enabled %}AUS{% else %}AN{% endif %}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Trockenmodus toggle -->
|
<!-- Trockenmodus -->
|
||||||
<form class="card px-4 py-2.5 flex items-center justify-between gap-2"
|
<form class="card p-4"
|
||||||
method="post" action="/actions/submit-forms"
|
hx-post="/actions/submit-forms"
|
||||||
hx-post="/actions/submit-forms" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
hx-trigger="change"
|
||||||
|
hx-target="#wohnungen-body"
|
||||||
|
hx-swap="outerHTML">
|
||||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||||
<input type="hidden" name="value" value="{% if submit_forms %}off{% else %}on{% endif %}">
|
<div class="text-[11px] uppercase tracking-wide text-slate-500 mb-2">Trockenmodus</div>
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex gap-4">
|
||||||
<div class="text-[11px] uppercase tracking-wide text-slate-500">Trockenmodus</div>
|
<label class="radio-opt">
|
||||||
<div>{% if submit_forms %}<span class="chip chip-warn">aus (echt!)</span>
|
<input type="radio" name="value" value="on"
|
||||||
{% else %}<span class="chip chip-ok">an</span>{% endif %}</div>
|
{% if not submit_forms %}checked{% endif %}>
|
||||||
|
<span>An <span class="text-xs text-slate-500">(Formular ausfüllen, nicht absenden)</span></span>
|
||||||
|
</label>
|
||||||
|
<label class="radio-opt">
|
||||||
|
<input type="radio" name="value" value="off"
|
||||||
|
hx-confirm="Trockenmodus ausschalten? Formulare werden dann WIRKLICH abgesendet!"
|
||||||
|
{% if submit_forms %}checked{% endif %}>
|
||||||
|
<span>Aus <span class="text-xs text-[#b8404e]">(echt senden)</span></span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-ghost text-xs"
|
|
||||||
onclick="return confirm('{% if submit_forms %}Trockenmodus wieder einschalten?{% else %}Trockenmodus ausschalten? Formulare werden dann WIRKLICH abgesendet!{% endif %}');"
|
|
||||||
type="submit">
|
|
||||||
{% if submit_forms %}AN{% else %}AUS{% endif %}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if not apply_allowed %}
|
{% if not apply_allowed %}
|
||||||
<div class="card p-3 text-sm">
|
<div class="card p-3 text-sm">
|
||||||
<span class="chip chip-bad">apply blockiert</span>
|
<span class="chip chip-bad">Bewerbungs-Dienst nicht erreichbar</span>
|
||||||
<span class="ml-2 text-slate-600">{{ apply_block_reason }}</span>
|
<span class="ml-2 text-slate-600">{{ apply_block_reason }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -68,8 +79,8 @@
|
||||||
{% if circuit_open %}
|
{% if circuit_open %}
|
||||||
<div class="card p-3 text-sm flex items-center justify-between">
|
<div class="card p-3 text-sm flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span class="chip chip-bad">circuit open</span>
|
<span class="chip chip-bad">Automatik pausiert</span>
|
||||||
<span class="ml-2 text-slate-600">{{ apply_failures }} Fehler in Serie — Auto-Bewerben pausiert</span>
|
<span class="ml-2 text-slate-600">{{ apply_failures }} Fehler in Folge</span>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/actions/reset-circuit"
|
<form method="post" action="/actions/reset-circuit"
|
||||||
hx-post="/actions/reset-circuit" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
hx-post="/actions/reset-circuit" hx-target="#wohnungen-body" hx-swap="outerHTML">
|
||||||
|
|
@ -79,12 +90,12 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Liste aller Wohnungen -->
|
<!-- Liste passender Wohnungen -->
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="flex items-center justify-between px-4 py-3 border-b border-soft gap-4 flex-wrap">
|
<div class="flex items-center justify-between px-4 py-3 border-b border-soft gap-4 flex-wrap">
|
||||||
<h2 class="font-semibold">Neueste Wohnungen auf inberlinwohnen.de</h2>
|
<h2 class="font-semibold">Passende Wohnungen auf inberlinwohnen.de</h2>
|
||||||
<div class="text-xs text-slate-500 flex gap-3 items-center">
|
<div class="text-xs text-slate-500 flex gap-3 items-center">
|
||||||
<span>{{ flats|length }} gesehen</span>
|
<span>{{ flats|length }} gefunden</span>
|
||||||
{% if next_scrape_utc %}
|
{% if next_scrape_utc %}
|
||||||
<span>· nächste Aktualisierung <span data-countdown-utc="{{ next_scrape_utc }}">…</span></span>
|
<span>· nächste Aktualisierung <span data-countdown-utc="{{ next_scrape_utc }}">…</span></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -93,17 +104,16 @@
|
||||||
<div class="divide-y divide-soft">
|
<div class="divide-y divide-soft">
|
||||||
{% for item in flats %}
|
{% for item in flats %}
|
||||||
{% set f = item.row %}
|
{% 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="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-1 min-w-0">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<a class="font-medium truncate" href="{{ f.link }}" target="_blank" rel="noopener noreferrer">
|
<a class="font-medium truncate" href="{{ f.link }}" target="_blank" rel="noopener noreferrer">
|
||||||
{{ f.address or f.link }}
|
{{ f.address or f.link }}
|
||||||
</a>
|
</a>
|
||||||
{% if item.matched %}<span class="chip chip-ok">match</span>{% endif %}
|
|
||||||
{% if item.last and item.last.finished_at is none %}
|
{% if item.last and item.last.finished_at is none %}
|
||||||
<span class="chip chip-warn">läuft…</span>
|
<span class="chip chip-warn">läuft…</span>
|
||||||
{% elif item.last and item.last.success == 1 %}<span class="chip chip-ok">beworben</span>
|
{% elif 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 and item.last.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-slate-500 mt-0.5">
|
<div class="text-xs text-slate-500 mt-0.5">
|
||||||
|
|
@ -128,7 +138,7 @@
|
||||||
<input type="hidden" name="flat_id" value="{{ f.id }}">
|
<input type="hidden" name="flat_id" value="{{ f.id }}">
|
||||||
<button class="btn btn-primary text-sm" type="submit"
|
<button class="btn btn-primary text-sm" type="submit"
|
||||||
{% if is_running %}disabled{% endif %}
|
{% if is_running %}disabled{% endif %}
|
||||||
onclick="return confirm('Bewerbung für {{ (f.address or f.link)|e }} ausführen?');">
|
hx-confirm="Bewerbung für {{ (f.address or f.link)|e }} starten?">
|
||||||
{% if is_running %}läuft…{% else %}Bewerben{% endif %}
|
{% if is_running %}läuft…{% else %}Bewerben{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -136,7 +146,13 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="px-4 py-8 text-center text-slate-500">Noch keine Wohnungen entdeckt.</div>
|
<div class="px-4 py-8 text-center text-slate-500">
|
||||||
|
{% if alert_label == 'nicht eingerichtet' %}
|
||||||
|
Bitte zuerst Filter einstellen, damit passende Wohnungen angezeigt werden.
|
||||||
|
{% else %}
|
||||||
|
Aktuell keine Wohnung, die alle Filter erfüllt.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,12 @@
|
||||||
.chip-warn { background: #fff4dd; color: #a36a1f; border: 1px solid #f5d48b; }
|
.chip-warn { background: #fff4dd; color: #a36a1f; border: 1px solid #f5d48b; }
|
||||||
.chip-bad { background: #fde6e9; color: #b8404e; border: 1px solid #f5b5bf; }
|
.chip-bad { background: #fde6e9; color: #b8404e; border: 1px solid #f5b5bf; }
|
||||||
.chip-info { background: #e3effc; color: #1f5f99; border: 1px solid #b6d4f0; }
|
.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 {
|
.brand-dot {
|
||||||
width: 2rem; height: 2rem; border-radius: 10px;
|
width: 2rem; height: 2rem; border-radius: 10px;
|
||||||
background: linear-gradient(135deg, #66b7f2 0%, #2f8ae0 60%, #fbd76b 100%);
|
background: linear-gradient(135deg, #66b7f2 0%, #2f8ae0 60%, #fbd76b 100%);
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,10 @@
|
||||||
{% if application.success == 1 %}<span class="chip chip-ok">erfolgreich</span>
|
{% if application.success == 1 %}<span class="chip chip-ok">erfolgreich</span>
|
||||||
{% elif application.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
|
{% elif application.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
|
||||||
{% else %}<span class="chip chip-warn">läuft</span>{% endif %}
|
{% else %}<span class="chip chip-warn">läuft</span>{% endif %}
|
||||||
<span class="chip chip-info">{{ application.triggered_by }}</span>
|
<span class="chip chip-info">{% if application.triggered_by == 'auto' %}automatisch{% else %}manuell{% endif %}</span>
|
||||||
{% if application.provider %}<span class="chip chip-info">{{ application.provider }}</span>{% endif %}
|
{% 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>
|
{% if application.submit_forms_used %}<span class="chip chip-warn">echt gesendet</span>
|
||||||
{% else %}<span class="chip chip-info">dry-run</span>{% endif %}
|
{% else %}<span class="chip chip-info">Trockenmodus</span>{% endif %}
|
||||||
|
|
||||||
{% if application.success == 0 %}
|
{% if application.success == 0 %}
|
||||||
<a class="btn btn-danger text-sm ml-auto"
|
<a class="btn btn-danger text-sm ml-auto"
|
||||||
|
|
@ -53,12 +53,20 @@
|
||||||
|
|
||||||
{% if forensics.screenshots %}
|
{% if forensics.screenshots %}
|
||||||
<details>
|
<details>
|
||||||
<summary class="font-medium">Screenshots ({{ forensics.screenshots|length }})</summary>
|
<summary class="font-medium">Momentaufnahmen ({{ forensics.screenshots|length }})</summary>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
|
||||||
{% for s in forensics.screenshots %}
|
{% for s in forensics.screenshots %}
|
||||||
<div class="border border-soft rounded-lg p-2">
|
<div class="border border-soft rounded-lg p-2 space-y-2">
|
||||||
<div class="text-xs text-slate-500 mb-1">{{ s.label }} @ {{ "%.2f"|format(s.ts) }}s — {{ s.url }}</div>
|
<div class="text-xs text-slate-500">{{ s.label }} @ {{ "%.2f"|format(s.ts) }}s — {{ s.url }}</div>
|
||||||
|
{% if s.b64_jpeg %}
|
||||||
<img src="data:image/jpeg;base64,{{ s.b64_jpeg }}" class="w-full rounded" alt="{{ s.label }}">
|
<img src="data:image/jpeg;base64,{{ s.b64_jpeg }}" class="w-full rounded" alt="{{ s.label }}">
|
||||||
|
{% endif %}
|
||||||
|
{% if s.html %}
|
||||||
|
<details>
|
||||||
|
<summary class="text-xs text-slate-500">Quellcode anzeigen ({{ s.html_size }} B)</summary>
|
||||||
|
<pre class="mono whitespace-pre-wrap break-all mt-2 p-2 bg-[#f6fafd] rounded border border-soft max-h-72 overflow-auto">{{ s.html }}</pre>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@
|
||||||
{% if a.success == 1 %}<span class="chip chip-ok">ok</span>
|
{% if a.success == 1 %}<span class="chip chip-ok">ok</span>
|
||||||
{% elif a.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
|
{% elif a.success == 0 %}<span class="chip chip-bad">fehlgeschlagen</span>
|
||||||
{% else %}<span class="chip chip-warn">läuft</span>{% endif %}
|
{% else %}<span class="chip chip-warn">läuft</span>{% endif %}
|
||||||
<span class="chip chip-info">{{ a.triggered_by }}</span>
|
<span class="chip chip-info">{% if a.triggered_by == 'auto' %}automatisch{% else %}manuell{% endif %}</span>
|
||||||
{% if a.provider %}<span class="chip chip-info">{{ a.provider }}</span>{% endif %}
|
{% 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>
|
{% if a.submit_forms_used %}<span class="chip chip-warn">echt gesendet</span>
|
||||||
{% else %}<span class="chip chip-info">dry-run</span>{% endif %}
|
{% else %}<span class="chip chip-info">Trockenmodus</span>{% endif %}
|
||||||
<span class="text-slate-500 text-xs ml-auto"
|
<span class="text-slate-500 text-xs ml-auto"
|
||||||
title="{{ a.started_at|de_dt }}">{{ a.started_at|de_dt }}</span>
|
title="{{ a.started_at|de_dt }}">{{ a.started_at|de_dt }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,36 @@
|
||||||
{% extends "_layout.html" %}
|
{% extends "_layout.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<section class="card p-4">
|
||||||
|
<form method="get" action="/logs" class="flex flex-wrap items-end gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">von</label>
|
||||||
|
<input class="input" type="date" name="from" value="{{ from_str }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">bis</label>
|
||||||
|
<input class="input" type="date" name="to" value="{{ to_str }}">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary text-sm" type="submit">Anwenden</button>
|
||||||
|
<a class="btn btn-ghost text-sm" href="/logs">zurücksetzen</a>
|
||||||
|
<a class="btn btn-ghost text-sm"
|
||||||
|
href="/logs/export.csv?from={{ from_str }}&to={{ to_str }}">
|
||||||
|
CSV herunterladen
|
||||||
|
</a>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="px-4 py-3 border-b border-soft flex items-center justify-between">
|
<div class="px-4 py-3 border-b border-soft flex items-center justify-between">
|
||||||
<h2 class="font-semibold">System-Log</h2>
|
<h2 class="font-semibold">System-Protokoll</h2>
|
||||||
<span class="text-xs text-slate-500">{{ events|length }} Einträge (max 300, letzte 14 Tage)</span>
|
<span class="text-xs text-slate-500">
|
||||||
|
{{ 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 %}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-soft">
|
<div class="divide-y divide-soft">
|
||||||
{% for e in events %}
|
{% for e in events %}
|
||||||
|
|
@ -15,12 +42,12 @@
|
||||||
<span class="chip chip-info">{{ e.source }}</span>
|
<span class="chip chip-info">{{ e.source }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="text-slate-700">{{ e.action }}</span>
|
<span class="text-slate-700">{{ e.action }}</span>
|
||||||
{% if e.user %}<span class="text-slate-500">user={{ e.user }}</span>{% endif %}
|
{% if e.user %}<span class="text-slate-500">Benutzer: {{ e.user }}</span>{% endif %}
|
||||||
{% if e.details %}<span class="text-slate-500">— {{ e.details }}</span>{% endif %}
|
{% if e.details %}<span class="text-slate-500">— {{ e.details }}</span>{% endif %}
|
||||||
{% if e.ip %}<span class="text-slate-400">[{{ e.ip }}]</span>{% endif %}
|
{% if e.ip %}<span class="text-slate-400">[{{ e.ip }}]</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="px-4 py-8 text-center text-slate-500">Keine Einträge.</div>
|
<div class="px-4 py-8 text-center text-slate-500">Keine Einträge im gewählten Zeitraum.</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue