ui: live timers, Berlin timestamps, ZIP failure reports, drop kill-switch/Fehler tab
* remove the kill-switch: auto-apply toggle is the single on/off; manual
'Bewerben' button now only gated by apply reachability; circuit breaker
stays but only gates auto-apply (manual bypasses, so a user can retry)
* Berlin-timezone date filter (de_dt) formats timestamps as DD.MM.YYYY HH:MM
everywhere; storage stays UTC
* Wohnungen: live 'entdeckt vor X' on every flat + 'nächste Aktualisierung in Xs'
countdown in the header, driven by /static/app.js; HTMX polls body every 30s
* drop the Fehler tab entirely; failed applications now carry a
'Fehler-Report herunterladen (ZIP)' link -> /bewerbungen/{id}/report.zip
bundles application.json, flat.json, profile_snapshot.json, forensics.json,
step_log.txt, page.html, console/errors/network JSONs, and decoded
screenshots/*.jpg for AI-assisted debugging
* trim the 'sensibel' blurb from the Profil tab
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c630b500ef
commit
332d9eea19
13 changed files with 429 additions and 343 deletions
268
web/app.py
268
web/app.py
|
|
@ -1,27 +1,37 @@
|
||||||
"""
|
"""
|
||||||
lazyflat web app.
|
lazyflat web app.
|
||||||
|
|
||||||
Five tabs:
|
Four tabs:
|
||||||
- / → Wohnungen (all flats, per-user match highlighting, filter block, auto-apply switch)
|
- / → Wohnungen (all flats, per-user match highlighting, filter block, auto-apply switch)
|
||||||
- /bewerbungen → Bewerbungen (user's application history, forensics drill-in)
|
- /bewerbungen → Bewerbungen (history + forensics; failed apps expose a ZIP report download)
|
||||||
- /logs → Logs (user-scoped audit log)
|
- /logs → Logs (user-scoped audit log)
|
||||||
- /fehler → Fehler (user-scoped error records + admin-global)
|
|
||||||
- /einstellungen/<section> → Einstellungen: profile, filter, notifications, account, admin users
|
- /einstellungen/<section> → Einstellungen: profile, filter, notifications, account, admin users
|
||||||
|
|
||||||
All state-changing POSTs require CSRF. Internal endpoints require INTERNAL_API_KEY.
|
All state-changing POSTs require CSRF. Internal endpoints require INTERNAL_API_KEY.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import hmac
|
import hmac
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import zipfile
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, Form, Header, HTTPException, Request, Response, status
|
from fastapi import Depends, FastAPI, Form, Header, HTTPException, Request, Response, status
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
BERLIN_TZ = ZoneInfo("Europe/Berlin")
|
||||||
|
except Exception:
|
||||||
|
BERLIN_TZ = timezone.utc
|
||||||
|
|
||||||
import db
|
import db
|
||||||
import notifications
|
import notifications
|
||||||
import retention
|
import retention
|
||||||
|
|
@ -40,7 +50,12 @@ from auth import (
|
||||||
verify_login,
|
verify_login,
|
||||||
)
|
)
|
||||||
from matching import flat_matches_filter, row_to_dict
|
from matching import flat_matches_filter, row_to_dict
|
||||||
from settings import APPLY_FAILURE_THRESHOLD, INTERNAL_API_KEY, PUBLIC_URL
|
from settings import (
|
||||||
|
ALERT_SCRAPE_INTERVAL_SECONDS,
|
||||||
|
APPLY_FAILURE_THRESHOLD,
|
||||||
|
INTERNAL_API_KEY,
|
||||||
|
PUBLIC_URL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|
@ -55,11 +70,9 @@ apply_client = ApplyClient()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# App
|
# App + Jinja
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_app: FastAPI):
|
async def lifespan(_app: FastAPI):
|
||||||
db.init_db()
|
db.init_db()
|
||||||
|
|
@ -74,6 +87,40 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso(s: str | None) -> datetime | None:
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# Python handles +00:00 but not trailing Z. Storage always uses +00:00.
|
||||||
|
return datetime.fromisoformat(s)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _de_dt(s: str | None) -> str:
|
||||||
|
"""UTC ISO → 'DD.MM.YYYY HH:MM' in Europe/Berlin."""
|
||||||
|
dt = _parse_iso(s)
|
||||||
|
if dt is None:
|
||||||
|
return ""
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(BERLIN_TZ).strftime("%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_utc(s: str | None) -> str:
|
||||||
|
"""Pass-through of a UTC ISO string (for data-attr in JS)."""
|
||||||
|
dt = _parse_iso(s)
|
||||||
|
if dt is None:
|
||||||
|
return ""
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
templates.env.filters["de_dt"] = _de_dt
|
||||||
|
templates.env.filters["iso_utc"] = _iso_utc
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def security_headers(request: Request, call_next):
|
async def security_headers(request: Request, call_next):
|
||||||
resp: Response = await call_next(request)
|
resp: Response = await call_next(request)
|
||||||
|
|
@ -117,17 +164,32 @@ def base_context(request: Request, user, active_tab: str) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_apply_gate(user_id: int) -> tuple[bool, str]:
|
def _manual_apply_allowed() -> tuple[bool, str]:
|
||||||
prefs = db.get_preferences(user_id)
|
"""Manual 'Bewerben' button is only blocked if the apply service is down."""
|
||||||
if prefs["kill_switch"]:
|
|
||||||
return False, "kill switch aktiv"
|
|
||||||
if prefs["apply_circuit_open"]:
|
|
||||||
return False, "circuit breaker offen (zu viele fehlgeschlagene Bewerbungen)"
|
|
||||||
if not apply_client.health():
|
if not apply_client.health():
|
||||||
return False, "apply-service nicht erreichbar"
|
return False, "apply-service nicht erreichbar"
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_apply_allowed(prefs) -> bool:
|
||||||
|
"""Auto-apply trigger gate: user must have enabled it and the circuit must be closed."""
|
||||||
|
if not prefs["auto_apply_enabled"]:
|
||||||
|
return False
|
||||||
|
if prefs["apply_circuit_open"]:
|
||||||
|
return False
|
||||||
|
return apply_client.health()
|
||||||
|
|
||||||
|
|
||||||
|
def _next_scrape_utc() -> str:
|
||||||
|
hb = db.get_state("last_alert_heartbeat")
|
||||||
|
dt = _parse_iso(hb)
|
||||||
|
if dt is None:
|
||||||
|
return ""
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return (dt + timedelta(seconds=ALERT_SCRAPE_INTERVAL_SECONDS)).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
def _run_apply_background(user_id: int, flat_id: str, url: str, triggered_by: str) -> None:
|
def _run_apply_background(user_id: int, flat_id: str, url: str, triggered_by: str) -> None:
|
||||||
prefs = db.get_preferences(user_id)
|
prefs = db.get_preferences(user_id)
|
||||||
profile_row = db.get_profile(user_id)
|
profile_row = db.get_profile(user_id)
|
||||||
|
|
@ -153,7 +215,6 @@ def _run_apply_background(user_id: int, flat_id: str, url: str, triggered_by: st
|
||||||
db.finish_application(app_id, success=success, message=message,
|
db.finish_application(app_id, success=success, message=message,
|
||||||
provider=provider, forensics=forensics)
|
provider=provider, forensics=forensics)
|
||||||
|
|
||||||
# Circuit breaker (per user)
|
|
||||||
if success:
|
if success:
|
||||||
db.update_preferences(user_id, {"apply_recent_failures": 0})
|
db.update_preferences(user_id, {"apply_recent_failures": 0})
|
||||||
else:
|
else:
|
||||||
|
|
@ -165,13 +226,11 @@ def _run_apply_background(user_id: int, flat_id: str, url: str, triggered_by: st
|
||||||
summary=f"{failures} aufeinanderfolgende Fehler",
|
summary=f"{failures} aufeinanderfolgende Fehler",
|
||||||
application_id=app_id)
|
application_id=app_id)
|
||||||
db.update_preferences(user_id, updates)
|
db.update_preferences(user_id, updates)
|
||||||
# record forensic error row
|
|
||||||
db.log_error(source="apply", kind="apply_failure", user_id=user_id,
|
db.log_error(source="apply", kind="apply_failure", user_id=user_id,
|
||||||
summary=message or "Bewerbung fehlgeschlagen",
|
summary=message or "Bewerbung fehlgeschlagen",
|
||||||
application_id=app_id,
|
application_id=app_id,
|
||||||
context={"provider": provider, "url": url})
|
context={"provider": provider, "url": url})
|
||||||
|
|
||||||
# Notify user
|
|
||||||
flat = db.get_flat(flat_id)
|
flat = db.get_flat(flat_id)
|
||||||
flat_dict = {"address": flat["address"] if flat else "", "link": url,
|
flat_dict = {"address": flat["address"] if flat else "", "link": url,
|
||||||
"rooms": flat["rooms"] if flat else None,
|
"rooms": flat["rooms"] if flat else None,
|
||||||
|
|
@ -246,17 +305,6 @@ def logout(request: Request):
|
||||||
# Tab: Wohnungen
|
# Tab: Wohnungen
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
|
||||||
def tab_wohnungen(request: Request):
|
|
||||||
u = current_user(request)
|
|
||||||
if not u:
|
|
||||||
return RedirectResponse("/login", status_code=303)
|
|
||||||
|
|
||||||
ctx = base_context(request, u, "wohnungen")
|
|
||||||
ctx.update(_wohnungen_context(u))
|
|
||||||
return templates.TemplateResponse("wohnungen.html", ctx)
|
|
||||||
|
|
||||||
|
|
||||||
def _wohnungen_context(user) -> dict:
|
def _wohnungen_context(user) -> dict:
|
||||||
uid = user["id"]
|
uid = user["id"]
|
||||||
filters_row = db.get_filters(uid)
|
filters_row = db.get_filters(uid)
|
||||||
|
|
@ -266,14 +314,9 @@ def _wohnungen_context(user) -> dict:
|
||||||
|
|
||||||
flats_view = []
|
flats_view = []
|
||||||
for f in flats:
|
for f in flats:
|
||||||
try:
|
|
||||||
payload = json.loads(f["payload_json"])
|
|
||||||
except Exception:
|
|
||||||
payload = {}
|
|
||||||
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,
|
"row": f,
|
||||||
"payload": payload,
|
|
||||||
"matched": flat_matches_filter({
|
"matched": flat_matches_filter({
|
||||||
"rooms": f["rooms"], "total_rent": f["total_rent"], "size": f["size"],
|
"rooms": f["rooms"], "total_rent": f["total_rent"], "size": f["size"],
|
||||||
"wbs": f["wbs"], "connectivity": {"morning_time": f["connectivity_morning_time"]},
|
"wbs": f["wbs"], "connectivity": {"morning_time": f["connectivity_morning_time"]},
|
||||||
|
|
@ -281,22 +324,40 @@ def _wohnungen_context(user) -> dict:
|
||||||
"last": last,
|
"last": last,
|
||||||
})
|
})
|
||||||
|
|
||||||
allowed, reason = _get_apply_gate(uid)
|
allowed, reason = _manual_apply_allowed()
|
||||||
return {
|
return {
|
||||||
"flats": flats_view,
|
"flats": flats_view,
|
||||||
"filters": filters,
|
"filters": filters,
|
||||||
"auto_apply_enabled": bool(prefs["auto_apply_enabled"]),
|
"auto_apply_enabled": bool(prefs["auto_apply_enabled"]),
|
||||||
"submit_forms": bool(prefs["submit_forms"]),
|
"submit_forms": bool(prefs["submit_forms"]),
|
||||||
"kill_switch": bool(prefs["kill_switch"]),
|
|
||||||
"circuit_open": bool(prefs["apply_circuit_open"]),
|
"circuit_open": bool(prefs["apply_circuit_open"]),
|
||||||
"apply_failures": int(prefs["apply_recent_failures"] or 0),
|
"apply_failures": int(prefs["apply_recent_failures"] or 0),
|
||||||
"apply_allowed": allowed,
|
"apply_allowed": allowed,
|
||||||
"apply_block_reason": reason,
|
"apply_block_reason": reason,
|
||||||
"apply_reachable": apply_client.health(),
|
"apply_reachable": apply_client.health(),
|
||||||
"last_alert_heartbeat": db.get_state("last_alert_heartbeat") or "",
|
"last_alert_heartbeat": db.get_state("last_alert_heartbeat") or "",
|
||||||
|
"next_scrape_utc": _next_scrape_utc(),
|
||||||
|
"scrape_interval_seconds": ALERT_SCRAPE_INTERVAL_SECONDS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def tab_wohnungen(request: Request):
|
||||||
|
u = current_user(request)
|
||||||
|
if not u:
|
||||||
|
return RedirectResponse("/login", status_code=303)
|
||||||
|
ctx = base_context(request, u, "wohnungen")
|
||||||
|
ctx.update(_wohnungen_context(u))
|
||||||
|
return templates.TemplateResponse("wohnungen.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/partials/wohnungen", response_class=HTMLResponse)
|
||||||
|
def partial_wohnungen(request: Request, user=Depends(require_user)):
|
||||||
|
ctx = base_context(request, user, "wohnungen")
|
||||||
|
ctx.update(_wohnungen_context(user))
|
||||||
|
return templates.TemplateResponse("_wohnungen_body.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/actions/filters")
|
@app.post("/actions/filters")
|
||||||
async def action_save_filters(
|
async def action_save_filters(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -342,21 +403,6 @@ async def action_auto_apply(
|
||||||
return RedirectResponse("/", status_code=303)
|
return RedirectResponse("/", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/actions/kill-switch")
|
|
||||||
async def action_kill_switch(
|
|
||||||
request: Request,
|
|
||||||
value: str = Form(...),
|
|
||||||
csrf: str = Form(...),
|
|
||||||
user=Depends(require_user),
|
|
||||||
):
|
|
||||||
require_csrf(user["id"], csrf)
|
|
||||||
new = 1 if value == "on" else 0
|
|
||||||
db.update_preferences(user["id"], {"kill_switch": new})
|
|
||||||
db.log_audit(user["username"], "kill_switch", "on" if new else "off",
|
|
||||||
user_id=user["id"], ip=client_ip(request))
|
|
||||||
return RedirectResponse("/", status_code=303)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/actions/reset-circuit")
|
@app.post("/actions/reset-circuit")
|
||||||
async def action_reset_circuit(
|
async def action_reset_circuit(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -377,7 +423,7 @@ async def action_apply(
|
||||||
user=Depends(require_user),
|
user=Depends(require_user),
|
||||||
):
|
):
|
||||||
require_csrf(user["id"], csrf)
|
require_csrf(user["id"], csrf)
|
||||||
allowed, reason = _get_apply_gate(user["id"])
|
allowed, reason = _manual_apply_allowed()
|
||||||
if not allowed:
|
if not allowed:
|
||||||
raise HTTPException(409, f"apply disabled: {reason}")
|
raise HTTPException(409, f"apply disabled: {reason}")
|
||||||
flat = db.get_flat(flat_id)
|
flat = db.get_flat(flat_id)
|
||||||
|
|
@ -418,6 +464,79 @@ def bewerbung_detail(request: Request, app_id: int):
|
||||||
return templates.TemplateResponse("bewerbung_detail.html", ctx)
|
return templates.TemplateResponse("bewerbung_detail.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/bewerbungen/{app_id}/report.zip")
|
||||||
|
def bewerbung_zip(request: Request, app_id: int):
|
||||||
|
u = current_user(request)
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(401)
|
||||||
|
a = db.get_application(app_id)
|
||||||
|
if not a or (a["user_id"] != u["id"] and not u["is_admin"]):
|
||||||
|
raise HTTPException(404)
|
||||||
|
|
||||||
|
flat = db.get_flat(a["flat_id"])
|
||||||
|
forensics = json.loads(a["forensics_json"]) if a["forensics_json"] else {}
|
||||||
|
profile = json.loads(a["profile_snapshot_json"]) if a["profile_snapshot_json"] else {}
|
||||||
|
app_meta = {k: a[k] for k in a.keys() if k not in ("forensics_json", "profile_snapshot_json")}
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr(
|
||||||
|
"README.txt",
|
||||||
|
f"lazyflat application report\n"
|
||||||
|
f"application_id={a['id']}\n"
|
||||||
|
f"flat_id={a['flat_id']}\n"
|
||||||
|
f"provider={a['provider']}\n"
|
||||||
|
f"success={a['success']}\n"
|
||||||
|
f"started_at_utc={a['started_at']}\n"
|
||||||
|
f"finished_at_utc={a['finished_at']}\n"
|
||||||
|
f"submit_forms_used={bool(a['submit_forms_used'])}\n"
|
||||||
|
f"\n"
|
||||||
|
f"Contents:\n"
|
||||||
|
f" application.json DB row + metadata\n"
|
||||||
|
f" flat.json Flat details at discovery time\n"
|
||||||
|
f" profile_snapshot.json Profile used for this attempt\n"
|
||||||
|
f" forensics.json Full captured forensics\n"
|
||||||
|
f" step_log.txt Human-readable step log\n"
|
||||||
|
f" page.html Final page HTML (if captured)\n"
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
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("profile_snapshot.json", json.dumps(profile, indent=2, default=str))
|
||||||
|
zf.writestr("forensics.json", json.dumps(forensics, indent=2, default=str))
|
||||||
|
|
||||||
|
step_lines = []
|
||||||
|
for s in forensics.get("steps", []):
|
||||||
|
step_lines.append(f"[{s.get('ts', 0):7.2f}s] {s.get('step', '?'):<24} {s.get('status', ''):<5} {s.get('detail', '')}")
|
||||||
|
zf.writestr("step_log.txt", "\n".join(step_lines))
|
||||||
|
|
||||||
|
if forensics.get("final_html"):
|
||||||
|
zf.writestr("page.html", forensics["final_html"])
|
||||||
|
zf.writestr("console.json", json.dumps(forensics.get("console", []), indent=2))
|
||||||
|
zf.writestr("errors.json", json.dumps(forensics.get("errors", []), indent=2))
|
||||||
|
zf.writestr("network.json", json.dumps(forensics.get("network", []), indent=2))
|
||||||
|
|
||||||
|
for idx, s in enumerate(forensics.get("screenshots", []), start=1):
|
||||||
|
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)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
buf.seek(0)
|
||||||
|
filename = f"lazyflat-report-{a['id']}.zip"
|
||||||
|
return StreamingResponse(
|
||||||
|
buf, media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tab: Logs
|
# Tab: Logs
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -432,41 +551,6 @@ def tab_logs(request: Request):
|
||||||
return templates.TemplateResponse("logs.html", ctx)
|
return templates.TemplateResponse("logs.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tab: Fehler
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@app.get("/fehler", response_class=HTMLResponse)
|
|
||||||
def tab_fehler(request: Request):
|
|
||||||
u = current_user(request)
|
|
||||||
if not u:
|
|
||||||
return RedirectResponse("/login", status_code=303)
|
|
||||||
ctx = base_context(request, u, "fehler")
|
|
||||||
ctx["errors"] = db.recent_errors(u["id"], limit=200, include_global=bool(u["is_admin"]))
|
|
||||||
return templates.TemplateResponse("fehler.html", ctx)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/fehler/{err_id}", response_class=HTMLResponse)
|
|
||||||
def fehler_detail(request: Request, err_id: int):
|
|
||||||
u = current_user(request)
|
|
||||||
if not u:
|
|
||||||
return RedirectResponse("/login", status_code=303)
|
|
||||||
e = db.get_error(err_id)
|
|
||||||
if not e or (e["user_id"] is not None and e["user_id"] != u["id"] and not u["is_admin"]):
|
|
||||||
raise HTTPException(404, "not found")
|
|
||||||
app_row = db.get_application(e["application_id"]) if e["application_id"] else None
|
|
||||||
forensics = None
|
|
||||||
if app_row and app_row["forensics_json"]:
|
|
||||||
try:
|
|
||||||
forensics = json.loads(app_row["forensics_json"])
|
|
||||||
except Exception:
|
|
||||||
forensics = None
|
|
||||||
context = json.loads(e["context_json"]) if e["context_json"] else None
|
|
||||||
ctx = base_context(request, u, "fehler")
|
|
||||||
ctx.update({"error": e, "application": app_row, "forensics": forensics, "context": context})
|
|
||||||
return templates.TemplateResponse("fehler_detail.html", ctx)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tab: Einstellungen (sub-tabs)
|
# Tab: Einstellungen (sub-tabs)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -595,8 +679,6 @@ async def action_submit_forms(
|
||||||
return RedirectResponse("/einstellungen/profil", status_code=303)
|
return RedirectResponse("/einstellungen/profil", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
# --- Admin: Benutzer ---------------------------------------------------------
|
|
||||||
|
|
||||||
@app.post("/actions/users/create")
|
@app.post("/actions/users/create")
|
||||||
async def action_users_create(
|
async def action_users_create(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -654,7 +736,6 @@ async def internal_submit_flat(
|
||||||
if not is_new:
|
if not is_new:
|
||||||
return {"status": "duplicate"}
|
return {"status": "duplicate"}
|
||||||
|
|
||||||
# per-user matching + auto-apply + notifications
|
|
||||||
for u in db.list_users():
|
for u in db.list_users():
|
||||||
if u["disabled"]:
|
if u["disabled"]:
|
||||||
continue
|
continue
|
||||||
|
|
@ -668,7 +749,7 @@ async def internal_submit_flat(
|
||||||
notifications.on_match(u["id"], payload)
|
notifications.on_match(u["id"], payload)
|
||||||
|
|
||||||
prefs = db.get_preferences(u["id"])
|
prefs = db.get_preferences(u["id"])
|
||||||
if prefs["auto_apply_enabled"] and not prefs["kill_switch"] and not prefs["apply_circuit_open"]:
|
if _auto_apply_allowed(prefs):
|
||||||
_kick_apply(u["id"], str(payload["id"]), payload["link"], "auto")
|
_kick_apply(u["id"], str(payload["id"]), payload["link"], "auto")
|
||||||
db.log_audit("system", "auto_apply_kick",
|
db.log_audit("system", "auto_apply_kick",
|
||||||
f"user={u['username']} flat={payload['id']}",
|
f"user={u['username']} flat={payload['id']}",
|
||||||
|
|
@ -689,7 +770,6 @@ async def internal_report_error(
|
||||||
payload: dict,
|
payload: dict,
|
||||||
_g: None = Depends(require_internal),
|
_g: None = Depends(require_internal),
|
||||||
):
|
):
|
||||||
"""Alert/other services can push errors here."""
|
|
||||||
db.log_error(
|
db.log_error(
|
||||||
source=payload.get("source", "unknown"),
|
source=payload.get("source", "unknown"),
|
||||||
kind=payload.get("kind", "error"),
|
kind=payload.get("kind", "error"),
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@ APPLY_URL: str = getenv("APPLY_URL", "http://apply:8000")
|
||||||
APPLY_TIMEOUT: int = int(getenv("APPLY_TIMEOUT", "600"))
|
APPLY_TIMEOUT: int = int(getenv("APPLY_TIMEOUT", "600"))
|
||||||
APPLY_FAILURE_THRESHOLD: int = int(getenv("APPLY_FAILURE_THRESHOLD", "3"))
|
APPLY_FAILURE_THRESHOLD: int = int(getenv("APPLY_FAILURE_THRESHOLD", "3"))
|
||||||
|
|
||||||
|
# --- Alert service knob (mirrored so web can predict the next scrape) ---------
|
||||||
|
ALERT_SCRAPE_INTERVAL_SECONDS: int = int(getenv("ALERT_SCRAPE_INTERVAL_SECONDS", getenv("SLEEP_INTERVALL", "60")))
|
||||||
|
|
||||||
# --- Storage ------------------------------------------------------------------
|
# --- Storage ------------------------------------------------------------------
|
||||||
DATA_DIR: Path = Path(getenv("DATA_DIR", "/data"))
|
DATA_DIR: Path = Path(getenv("DATA_DIR", "/data"))
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
|
||||||
49
web/static/app.js
Normal file
49
web/static/app.js
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
// lazyflat — live time helpers.
|
||||||
|
// Any element with [data-rel-utc="<iso>"] gets its text replaced every 5s
|
||||||
|
// with a German relative-time string ("vor 3 min"). Elements with
|
||||||
|
// [data-countdown-utc="<iso>"] show "in Xs" counting down each second.
|
||||||
|
|
||||||
|
function fmtRelative(iso) {
|
||||||
|
const ts = Date.parse(iso);
|
||||||
|
if (!iso || Number.isNaN(ts)) return "—";
|
||||||
|
const diff = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
||||||
|
if (diff < 5) return "gerade eben";
|
||||||
|
if (diff < 60) return `vor ${diff} s`;
|
||||||
|
if (diff < 3600) return `vor ${Math.floor(diff / 60)} min`;
|
||||||
|
if (diff < 86400) return `vor ${Math.floor(diff / 3600)} h`;
|
||||||
|
return `vor ${Math.floor(diff / 86400)} Tagen`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCountdown(iso) {
|
||||||
|
const ts = Date.parse(iso);
|
||||||
|
if (!iso || Number.isNaN(ts)) return "—";
|
||||||
|
const secs = Math.floor((ts - Date.now()) / 1000);
|
||||||
|
if (secs <= 0) return "Aktualisierung läuft…";
|
||||||
|
if (secs < 60) return `in ${secs} s`;
|
||||||
|
if (secs < 3600) return `in ${Math.floor(secs / 60)} min`;
|
||||||
|
return `in ${Math.floor(secs / 3600)} h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRelativeTimes() {
|
||||||
|
document.querySelectorAll("[data-rel-utc]").forEach((el) => {
|
||||||
|
el.textContent = fmtRelative(el.dataset.relUtc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCountdowns() {
|
||||||
|
document.querySelectorAll("[data-countdown-utc]").forEach((el) => {
|
||||||
|
el.textContent = fmtCountdown(el.dataset.countdownUtc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
updateRelativeTimes();
|
||||||
|
updateCountdowns();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run immediately + on intervals. Also re-run after HTMX swaps so freshly
|
||||||
|
// injected DOM gets formatted too.
|
||||||
|
document.addEventListener("DOMContentLoaded", tick);
|
||||||
|
document.body && document.body.addEventListener("htmx:afterSwap", tick);
|
||||||
|
setInterval(updateCountdowns, 1000);
|
||||||
|
setInterval(updateRelativeTimes, 5000);
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
<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>
|
||||||
<a class="tab {% if active_tab=='logs' %}active{% endif %}" href="/logs">Logs</a>
|
<a class="tab {% if active_tab=='logs' %}active{% endif %}" href="/logs">Logs</a>
|
||||||
<a class="tab {% if active_tab=='fehler' %}active{% endif %}" href="/fehler">Fehler</a>
|
|
||||||
<a class="tab {% if active_tab=='einstellungen' %}active{% endif %}" href="/einstellungen">Einstellungen</a>
|
<a class="tab {% if active_tab=='einstellungen' %}active{% endif %}" href="/einstellungen">Einstellungen</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,4 @@
|
||||||
<h2 class="font-semibold mb-4">Bewerbungsdaten</h2>
|
<h2 class="font-semibold mb-4">Bewerbungsdaten</h2>
|
||||||
<p class="text-sm text-slate-600 mb-4">
|
|
||||||
Diese Angaben werden beim Bewerben an die jeweilige Website gesendet.
|
|
||||||
<span class="chip chip-warn">sensibel</span> — werden nur in der DB gespeichert und pro Bewerbung als Snapshot protokolliert.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form method="post" action="/actions/profile" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<form method="post" action="/actions/profile" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||||
|
|
|
||||||
178
web/templates/_wohnungen_body.html
Normal file
178
web/templates/_wohnungen_body.html
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
{# Renders the full Wohnungen body; used both by the full page and by HTMX poll. #}
|
||||||
|
<div id="wohnungen-body" class="space-y-6">
|
||||||
|
|
||||||
|
<!-- Auto-Bewerben + Status-Leiste -->
|
||||||
|
<section class="card p-5 flex flex-col md:flex-row md:items-center gap-4 justify-between">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="text-xs uppercase tracking-wide text-slate-500">Auto-Bewerben</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if auto_apply_enabled %}
|
||||||
|
<span class="chip chip-warn">aktiviert</span>
|
||||||
|
<span class="text-sm text-slate-600">bei Match wird automatisch beworben</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="chip chip-info">deaktiviert</span>
|
||||||
|
<span class="text-sm text-slate-600">Matches werden nur angezeigt</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<form method="post" action="/actions/auto-apply">
|
||||||
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||||
|
<input type="hidden" name="value" value="{% if auto_apply_enabled %}off{% else %}on{% endif %}">
|
||||||
|
<button class="btn btn-hot {% if not auto_apply_enabled %}off{% endif %}"
|
||||||
|
onclick="return confirm('{% if auto_apply_enabled %}Auto-Bewerben deaktivieren?{% else %}Auto-Bewerben aktivieren? Das System bewirbt dann automatisch bei jedem Match – bitte Profil und Filter prüfen.{% endif %}');"
|
||||||
|
type="submit">
|
||||||
|
{% if auto_apply_enabled %}AUTO-BEWERBEN DEAKTIVIEREN{% else %}AUTO-BEWERBEN AKTIVIEREN{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if circuit_open %}
|
||||||
|
<form method="post" action="/actions/reset-circuit">
|
||||||
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||||
|
<button class="btn btn-ghost text-sm" type="submit">Circuit zurücksetzen</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if not apply_allowed %}
|
||||||
|
<div class="card p-4">
|
||||||
|
<span class="chip chip-bad">apply blockiert</span>
|
||||||
|
<span class="ml-2 text-sm text-slate-600">{{ apply_block_reason }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Status-Reihe -->
|
||||||
|
<section class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div class="card p-3">
|
||||||
|
<div class="text-xs text-slate-500">alert</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
{% if last_alert_heartbeat %}<span class="chip chip-ok">live</span>
|
||||||
|
{% else %}<span class="chip chip-warn">kein Heartbeat</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3">
|
||||||
|
<div class="text-xs text-slate-500">apply</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
{% if apply_reachable %}<span class="chip chip-ok">ok</span>
|
||||||
|
{% else %}<span class="chip chip-bad">down</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3">
|
||||||
|
<div class="text-xs text-slate-500">submit_forms</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
{% if submit_forms %}<span class="chip chip-warn">echt senden</span>
|
||||||
|
{% else %}<span class="chip chip-info">dry-run</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card p-3">
|
||||||
|
<div class="text-xs text-slate-500">Fehler in Serie</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
{% if circuit_open %}<span class="chip chip-bad">circuit open</span>
|
||||||
|
{% elif apply_failures > 0 %}<span class="chip chip-warn">{{ apply_failures }}</span>
|
||||||
|
{% else %}<span class="chip chip-ok">0</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Filter Panel -->
|
||||||
|
<details class="card" {% if not filters.rooms_min and not filters.max_rent %}open{% endif %}>
|
||||||
|
<summary class="px-5 py-3 font-semibold select-none">Eigene Filter</summary>
|
||||||
|
<form method="post" action="/actions/filters" class="p-5 grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">Zimmer min</label>
|
||||||
|
<input class="input" name="rooms_min" value="{{ filters.rooms_min if filters.rooms_min is not none else '' }}" placeholder="z.B. 2">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">Zimmer max</label>
|
||||||
|
<input class="input" name="rooms_max" value="{{ filters.rooms_max if filters.rooms_max is not none else '' }}" placeholder="z.B. 3">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">max Miete (€)</label>
|
||||||
|
<input class="input" name="max_rent" value="{{ filters.max_rent if filters.max_rent is not none else '' }}" placeholder="1500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">min Größe (m²)</label>
|
||||||
|
<input class="input" name="min_size" value="{{ filters.min_size if filters.min_size is not none else '' }}" placeholder="40">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">max Anfahrt morgens (min)</label>
|
||||||
|
<input class="input" name="max_morning_commute" value="{{ filters.max_morning_commute if filters.max_morning_commute is not none else '' }}" placeholder="50">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">WBS benötigt</label>
|
||||||
|
<select class="input" name="wbs_required">
|
||||||
|
<option value="" {% if not filters.wbs_required %}selected{% endif %}>egal</option>
|
||||||
|
<option value="yes" {% if filters.wbs_required == 'yes' %}selected{% endif %}>ja</option>
|
||||||
|
<option value="no" {% if filters.wbs_required == 'no' %}selected{% endif %}>nein</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2 md:col-span-3 flex gap-2 pt-2">
|
||||||
|
<button class="btn btn-primary" type="submit">Filter speichern</button>
|
||||||
|
<span class="text-xs text-slate-500 self-center">Leer lassen = kein Limit. Filter bestimmen Match-Hervorhebung + Auto-Bewerben.</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Liste aller Wohnungen -->
|
||||||
|
<section class="card">
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 border-b border-soft gap-4 flex-wrap">
|
||||||
|
<h2 class="font-semibold">Neueste Wohnungen auf inberlinwohnen.de</h2>
|
||||||
|
<div class="text-xs text-slate-500 flex gap-3 items-center">
|
||||||
|
<span>{{ flats|length }} gesehen</span>
|
||||||
|
{% if next_scrape_utc %}
|
||||||
|
<span>· nächste Aktualisierung <span data-countdown-utc="{{ next_scrape_utc }}">…</span></span>
|
||||||
|
{% else %}
|
||||||
|
<span>· warte auf alert…</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-soft">
|
||||||
|
{% for item in flats %}
|
||||||
|
{% set f = item.row %}
|
||||||
|
<div class="px-4 py-3 flex flex-col md:flex-row md:items-center gap-3 {% if item.matched %}bg-[#f2f8ff]{% endif %}">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<a class="font-medium truncate" href="{{ f.link }}" target="_blank" rel="noopener noreferrer">
|
||||||
|
{{ f.address or f.link }}
|
||||||
|
</a>
|
||||||
|
{% if item.matched %}<span class="chip chip-ok">match</span>{% endif %}
|
||||||
|
{% if item.last and item.last.success == 1 %}<span class="chip chip-ok">beworben</span>
|
||||||
|
{% elif item.last and item.last.success == 0 %}<span class="chip chip-bad">apply fehlgeschlagen</span>
|
||||||
|
{% elif item.last %}<span class="chip chip-warn">läuft</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-slate-500 mt-0.5">
|
||||||
|
{% if f.rooms %}{{ "%.1f"|format(f.rooms) }} Z{% endif %}
|
||||||
|
{% if f.size %} · {{ "%.0f"|format(f.size) }} m²{% endif %}
|
||||||
|
{% if f.total_rent %} · {{ "%.0f"|format(f.total_rent) }} €{% endif %}
|
||||||
|
{% if f.sqm_price %} ({{ "%.2f"|format(f.sqm_price) }} €/m²){% endif %}
|
||||||
|
{% if f.connectivity_morning_time %} · {{ "%.0f"|format(f.connectivity_morning_time) }} min morgens{% endif %}
|
||||||
|
{% if f.wbs %} · WBS: {{ f.wbs }}{% endif %}
|
||||||
|
· entdeckt <span data-rel-utc="{{ f.discovered_at|iso_utc }}" title="{{ f.discovered_at|de_dt }}">…</span>
|
||||||
|
</div>
|
||||||
|
{% if item.last and item.last.message %}
|
||||||
|
<div class="text-xs text-slate-500 mt-1 truncate">↳ {{ item.last.message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% if apply_allowed and not (item.last and item.last.success == 1) %}
|
||||||
|
<form method="post" action="/actions/apply">
|
||||||
|
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||||
|
<input type="hidden" name="flat_id" value="{{ f.id }}">
|
||||||
|
<button class="btn btn-primary text-sm" type="submit"
|
||||||
|
onclick="return confirm('Bewerbung für {{ (f.address or f.link)|e }} ausführen?');">
|
||||||
|
Bewerben
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="px-4 py-8 text-center text-slate-500">Noch keine Wohnungen entdeckt.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
<title>{% block title %}lazyflat{% endblock %}</title>
|
<title>{% block title %}lazyflat{% endblock %}</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
|
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
|
||||||
|
<script src="/static/app.js" defer></script>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-from: #e4f0fb; --bg-to: #f7fbfe;
|
--bg-from: #e4f0fb; --bg-to: #f7fbfe;
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,18 @@
|
||||||
{% 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">dry-run</span>{% endif %}
|
||||||
|
|
||||||
|
{% if application.success == 0 %}
|
||||||
|
<a class="btn btn-danger text-sm ml-auto"
|
||||||
|
href="/bewerbungen/{{ application.id }}/report.zip">
|
||||||
|
Fehler-Report herunterladen (ZIP)
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-slate-600 space-y-1">
|
<div class="text-sm text-slate-600 space-y-1">
|
||||||
<div><span class="text-slate-500">URL:</span> <a href="{{ application.url }}" target="_blank" rel="noopener">{{ application.url }}</a></div>
|
<div><span class="text-slate-500">URL:</span> <a href="{{ application.url }}" target="_blank" rel="noopener">{{ application.url }}</a></div>
|
||||||
<div><span class="text-slate-500">gestartet:</span> {{ application.started_at }}</div>
|
<div><span class="text-slate-500">gestartet:</span> {{ application.started_at|de_dt }}</div>
|
||||||
<div><span class="text-slate-500">beendet:</span> {{ application.finished_at or "—" }}</div>
|
<div><span class="text-slate-500">beendet:</span> {{ application.finished_at|de_dt if application.finished_at else "—" }}</div>
|
||||||
{% if application.message %}<div><span class="text-slate-500">Meldung:</span> {{ application.message }}</div>{% endif %}
|
{% if application.message %}<div><span class="text-slate-500">Meldung:</span> {{ application.message }}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -31,7 +38,7 @@
|
||||||
|
|
||||||
{% if forensics %}
|
{% if forensics %}
|
||||||
<section class="card p-5 space-y-4">
|
<section class="card p-5 space-y-4">
|
||||||
<h3 class="font-semibold">Forensik (für KI-Debug)</h3>
|
<h3 class="font-semibold">Forensik</h3>
|
||||||
|
|
||||||
<details open>
|
<details open>
|
||||||
<summary class="font-medium">Step-Log ({{ forensics.steps|length }} Einträge, {{ forensics.duration_s }} s)</summary>
|
<summary class="font-medium">Step-Log ({{ forensics.steps|length }} Einträge, {{ forensics.duration_s }} s)</summary>
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,24 @@
|
||||||
<div class="px-4 py-3 text-sm">
|
<div class="px-4 py-3 text-sm">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
{% 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">fail</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">{{ a.triggered_by }}</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">dry-run</span>{% endif %}
|
||||||
<span class="text-slate-500 text-xs ml-auto">{{ a.started_at }}</span>
|
<span class="text-slate-500 text-xs ml-auto"
|
||||||
|
title="{{ a.started_at|de_dt }}">{{ a.started_at|de_dt }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 truncate">
|
<div class="mt-1 truncate">
|
||||||
<a href="/bewerbungen/{{ a.id }}">#{{ a.id }} — {{ a.address or a.url }}</a>
|
<a href="/bewerbungen/{{ a.id }}">#{{ a.id }} — {{ a.address or a.url }}</a>
|
||||||
</div>
|
</div>
|
||||||
{% if a.message %}<div class="text-xs text-slate-500 mt-0.5 truncate">{{ a.message }}</div>{% endif %}
|
{% if a.message %}<div class="text-xs text-slate-500 mt-0.5 truncate">{{ a.message }}</div>{% endif %}
|
||||||
|
{% if a.success == 0 %}
|
||||||
|
<div class="mt-1">
|
||||||
|
<a class="text-xs" href="/bewerbungen/{{ a.id }}/report.zip">↓ Fehler-Report (ZIP)</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="px-4 py-8 text-center text-slate-500">Noch keine Bewerbungen.</div>
|
<div class="px-4 py-8 text-center text-slate-500">Noch keine Bewerbungen.</div>
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
{% extends "_layout.html" %}
|
|
||||||
{% block title %}Fehler — lazyflat{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<section class="card">
|
|
||||||
<div class="px-4 py-3 border-b border-soft flex items-center justify-between">
|
|
||||||
<h2 class="font-semibold">Fehler {% if is_admin %}<span class="chip chip-info ml-2">inkl. globaler</span>{% endif %}</h2>
|
|
||||||
<span class="text-xs text-slate-500">{{ errors|length }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="divide-y divide-soft">
|
|
||||||
{% for e in errors %}
|
|
||||||
<a class="block px-4 py-3 hover:bg-[#f6fafd]" href="/fehler/{{ e.id }}">
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<span class="chip chip-bad">{{ e.kind }}</span>
|
|
||||||
<span class="chip chip-info">{{ e.source }}</span>
|
|
||||||
{% if e.application_id %}<span class="chip chip-info">#{{ e.application_id }}</span>{% endif %}
|
|
||||||
<span class="text-xs text-slate-500 ml-auto">{{ e.timestamp }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm mt-1 truncate">{{ e.summary or "(kein Text)" }}</div>
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<div class="px-4 py-8 text-center text-slate-500">Keine Fehler — läuft rund.</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<div class="px-4 py-3 border-t border-soft text-xs text-slate-500">
|
|
||||||
Fehler werden 14 Tage aufbewahrt. Bei fehlgeschlagenen Bewerbungen enthält die Detailseite Screenshots,
|
|
||||||
Step-Log, Browser-Konsole + Netzwerk-Trace für KI-gestützte Fehleranalyse.
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
{% extends "_layout.html" %}
|
|
||||||
{% block title %}Fehler #{{ error.id }} — lazyflat{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<a href="/fehler" class="text-sm">← zurück zu den Fehlern</a>
|
|
||||||
|
|
||||||
<section class="card p-5 space-y-2">
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<h2 class="font-semibold text-lg">Fehler #{{ error.id }}</h2>
|
|
||||||
<span class="chip chip-bad">{{ error.kind }}</span>
|
|
||||||
<span class="chip chip-info">{{ error.source }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-slate-600">
|
|
||||||
{{ error.timestamp }} · {{ error.summary or "(kein Text)" }}
|
|
||||||
</div>
|
|
||||||
{% if context %}
|
|
||||||
<details class="mt-2">
|
|
||||||
<summary class="font-medium">Kontext</summary>
|
|
||||||
<pre class="mono whitespace-pre-wrap break-all mt-2 p-3 bg-[#f6fafd] rounded-lg border border-soft">{{ context | tojson(indent=2) }}</pre>
|
|
||||||
</details>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{% if application %}
|
|
||||||
<section class="card p-5">
|
|
||||||
<h3 class="font-semibold mb-2">Zugehörige Bewerbung</h3>
|
|
||||||
<div class="text-sm">
|
|
||||||
<a href="/bewerbungen/{{ application.id }}">Bewerbung #{{ application.id }} öffnen</a>
|
|
||||||
— vollständige Forensik dort.
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<div class="divide-y divide-soft">
|
<div class="divide-y divide-soft">
|
||||||
{% for e in events %}
|
{% for e in events %}
|
||||||
<div class="px-4 py-2 mono">
|
<div class="px-4 py-2 mono">
|
||||||
<span class="text-slate-500">{{ e.timestamp }}</span>
|
<span class="text-slate-500">{{ e.timestamp|de_dt }}</span>
|
||||||
<span class="text-slate-400">{{ e.actor }}</span>
|
<span class="text-slate-400">{{ e.actor }}</span>
|
||||||
<span class="text-slate-700">{{ e.action }}</span>
|
<span class="text-slate-700">{{ e.action }}</span>
|
||||||
{% if e.details %}<span class="text-slate-500">— {{ e.details }}</span>{% endif %}
|
{% if e.details %}<span class="text-slate-500">— {{ e.details }}</span>{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -1,182 +1,10 @@
|
||||||
{% extends "_layout.html" %}
|
{% extends "_layout.html" %}
|
||||||
{% block title %}Wohnungen — lazyflat{% endblock %}
|
{% block title %}Wohnungen — lazyflat{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div id="wohnungen-body"
|
||||||
<!-- Auto-Bewerben + Status-Leiste -->
|
hx-get="/partials/wohnungen"
|
||||||
<section class="card p-5 flex flex-col md:flex-row md:items-center gap-4 justify-between">
|
hx-trigger="every 30s"
|
||||||
<div class="flex flex-col gap-1">
|
hx-swap="outerHTML">
|
||||||
<div class="text-xs uppercase tracking-wide text-slate-500">Auto-Bewerben</div>
|
{% include "_wohnungen_body.html" %}
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{% if auto_apply_enabled %}
|
|
||||||
<span class="chip chip-warn">an</span>
|
|
||||||
<span class="text-sm text-slate-600">bei Match wird automatisch beworben</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="chip chip-info">aus</span>
|
|
||||||
<span class="text-sm text-slate-600">Matches werden nur angezeigt</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
|
||||||
<!-- der rote Knopf -->
|
|
||||||
<form method="post" action="/actions/auto-apply">
|
|
||||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
|
||||||
<input type="hidden" name="value" value="{% if auto_apply_enabled %}off{% else %}on{% endif %}">
|
|
||||||
<button class="btn btn-hot {% if not auto_apply_enabled %}off{% endif %}"
|
|
||||||
onclick="return confirm('{% if auto_apply_enabled %}Auto-Bewerben deaktivieren?{% else %}Auto-Bewerben aktivieren? Das System bewirbt dann automatisch bei jedem Match – bitte Profil und Filter prüfen.{% endif %}');"
|
|
||||||
type="submit">
|
|
||||||
{% if auto_apply_enabled %}AUTO-BEWERBEN: AN{% else %}AUTO-BEWERBEN AKTIVIEREN{% endif %}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- kill switch -->
|
|
||||||
<form method="post" action="/actions/kill-switch">
|
|
||||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
|
||||||
<input type="hidden" name="value" value="{% if kill_switch %}off{% else %}on{% endif %}">
|
|
||||||
<button class="btn {% if kill_switch %}btn-ghost{% else %}btn-danger{% endif %} text-sm" type="submit">
|
|
||||||
{% if kill_switch %}Kill-Switch deaktivieren{% else %}Kill-Switch{% endif %}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% if circuit_open %}
|
|
||||||
<form method="post" action="/actions/reset-circuit">
|
|
||||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
|
||||||
<button class="btn btn-ghost text-sm" type="submit">Circuit zurücksetzen</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{% if not apply_allowed %}
|
|
||||||
<div class="card p-4">
|
|
||||||
<span class="chip chip-bad">apply blockiert</span>
|
|
||||||
<span class="ml-2 text-sm text-slate-600">{{ apply_block_reason }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Status zeile -->
|
|
||||||
<section class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
||||||
<div class="card p-3">
|
|
||||||
<div class="text-xs text-slate-500">alert</div>
|
|
||||||
<div class="mt-1">
|
|
||||||
{% if last_alert_heartbeat %}<span class="chip chip-ok">live</span>
|
|
||||||
{% else %}<span class="chip chip-warn">kein Heartbeat</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-3">
|
|
||||||
<div class="text-xs text-slate-500">apply</div>
|
|
||||||
<div class="mt-1">
|
|
||||||
{% if apply_reachable %}<span class="chip chip-ok">ok</span>
|
|
||||||
{% else %}<span class="chip chip-bad">down</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-3">
|
|
||||||
<div class="text-xs text-slate-500">submit_forms</div>
|
|
||||||
<div class="mt-1">
|
|
||||||
{% if submit_forms %}<span class="chip chip-warn">echt senden</span>
|
|
||||||
{% else %}<span class="chip chip-info">dry-run</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card p-3">
|
|
||||||
<div class="text-xs text-slate-500">Fehler in Serie</div>
|
|
||||||
<div class="mt-1">
|
|
||||||
{% if circuit_open %}<span class="chip chip-bad">circuit open</span>
|
|
||||||
{% elif apply_failures > 0 %}<span class="chip chip-warn">{{ apply_failures }}</span>
|
|
||||||
{% else %}<span class="chip chip-ok">0</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Filter Panel -->
|
|
||||||
<details class="card" {% if not filters.rooms_min and not filters.max_rent %}open{% endif %}>
|
|
||||||
<summary class="px-5 py-3 font-semibold select-none">Eigene Filter</summary>
|
|
||||||
<form method="post" action="/actions/filters" class="p-5 grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
||||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">Zimmer min</label>
|
|
||||||
<input class="input" name="rooms_min" value="{{ filters.rooms_min if filters.rooms_min is not none else '' }}" placeholder="z.B. 2">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">Zimmer max</label>
|
|
||||||
<input class="input" name="rooms_max" value="{{ filters.rooms_max if filters.rooms_max is not none else '' }}" placeholder="z.B. 3">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">max Miete (€)</label>
|
|
||||||
<input class="input" name="max_rent" value="{{ filters.max_rent if filters.max_rent is not none else '' }}" placeholder="1500">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">min Größe (m²)</label>
|
|
||||||
<input class="input" name="min_size" value="{{ filters.min_size if filters.min_size is not none else '' }}" placeholder="40">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">max Anfahrt morgens (min)</label>
|
|
||||||
<input class="input" name="max_morning_commute" value="{{ filters.max_morning_commute if filters.max_morning_commute is not none else '' }}" placeholder="50">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs uppercase tracking-wide text-slate-500 mb-1">WBS benötigt</label>
|
|
||||||
<select class="input" name="wbs_required">
|
|
||||||
<option value="" {% if not filters.wbs_required %}selected{% endif %}>egal</option>
|
|
||||||
<option value="yes" {% if filters.wbs_required == 'yes' %}selected{% endif %}>ja</option>
|
|
||||||
<option value="no" {% if filters.wbs_required == 'no' %}selected{% endif %}>nein</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2 md:col-span-3 flex gap-2 pt-2">
|
|
||||||
<button class="btn btn-primary" type="submit">Filter speichern</button>
|
|
||||||
<span class="text-xs text-slate-500 self-center">Leer lassen = kein Limit. Filter bestimmen Match-Hervorhebung + Auto-Bewerben.</span>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<!-- Liste aller Wohnungen -->
|
|
||||||
<section class="card">
|
|
||||||
<div class="flex items-center justify-between px-4 py-3 border-b border-soft">
|
|
||||||
<h2 class="font-semibold">Neueste Wohnungen auf inberlinwohnen.de</h2>
|
|
||||||
<span class="text-xs text-slate-500">{{ flats|length }} gesamt</span>
|
|
||||||
</div>
|
|
||||||
<div class="divide-y divide-soft">
|
|
||||||
{% for item in flats %}
|
|
||||||
{% set f = item.row %}
|
|
||||||
<div class="px-4 py-3 flex flex-col md:flex-row md:items-center gap-3 {% if item.matched %}bg-[#f2f8ff]{% endif %}">
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<a class="font-medium truncate" href="{{ f.link }}" target="_blank" rel="noopener noreferrer">
|
|
||||||
{{ f.address or f.link }}
|
|
||||||
</a>
|
|
||||||
{% if item.matched %}<span class="chip chip-ok">match</span>{% endif %}
|
|
||||||
{% if item.last and item.last.success == 1 %}<span class="chip chip-ok">beworben</span>
|
|
||||||
{% elif item.last and item.last.success == 0 %}<span class="chip chip-bad">apply fehlgeschlagen</span>
|
|
||||||
{% elif item.last %}<span class="chip chip-warn">läuft</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-slate-500 mt-0.5">
|
|
||||||
{% if f.rooms %}{{ "%.1f"|format(f.rooms) }} Z{% endif %}
|
|
||||||
{% if f.size %} · {{ "%.0f"|format(f.size) }} m²{% endif %}
|
|
||||||
{% if f.total_rent %} · {{ "%.0f"|format(f.total_rent) }} €{% endif %}
|
|
||||||
{% if f.sqm_price %} ({{ "%.2f"|format(f.sqm_price) }} €/m²){% endif %}
|
|
||||||
{% if f.connectivity_morning_time %} · {{ "%.0f"|format(f.connectivity_morning_time) }} min morgens{% endif %}
|
|
||||||
{% if f.wbs %} · WBS: {{ f.wbs }}{% endif %}
|
|
||||||
· entdeckt {{ f.discovered_at }}
|
|
||||||
</div>
|
|
||||||
{% if item.last and item.last.message %}
|
|
||||||
<div class="text-xs text-slate-500 mt-1 truncate">↳ {{ item.last.message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
{% if apply_allowed and not (item.last and item.last.success == 1) %}
|
|
||||||
<form method="post" action="/actions/apply">
|
|
||||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
|
||||||
<input type="hidden" name="flat_id" value="{{ f.id }}">
|
|
||||||
<button class="btn btn-primary text-sm" type="submit"
|
|
||||||
onclick="return confirm('Bewerbung für {{ (f.address or f.link)|e }} ausführen?');">
|
|
||||||
Bewerben
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="px-4 py-8 text-center text-slate-500">Noch keine Wohnungen entdeckt.</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue