Compare commits
2 commits
cb617dd38a
...
f1e26b38d0
| Author | SHA1 | Date | |
|---|---|---|---|
| f1e26b38d0 | |||
| 4f23726e8f |
12 changed files with 1489 additions and 1365 deletions
1229
web/app.py
1229
web/app.py
File diff suppressed because it is too large
Load diff
279
web/common.py
Normal file
279
web/common.py
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
"""Shared helpers and singletons used by the route modules.
|
||||
|
||||
Extracted from the old monolithic app.py. Signatures and behaviour are kept
|
||||
byte-for-byte identical with the original helpers so the routers can import
|
||||
from here without any behavioural change.
|
||||
"""
|
||||
import asyncio
|
||||
import hmac
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Depends, HTTPException, Header, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
BERLIN_TZ = ZoneInfo("Europe/Berlin")
|
||||
except Exception:
|
||||
BERLIN_TZ = timezone.utc
|
||||
|
||||
import db
|
||||
import notifications
|
||||
from apply_client import ApplyClient, _row_to_profile
|
||||
from auth import issue_csrf_token
|
||||
from settings import APPLY_FAILURE_THRESHOLD, INTERNAL_API_KEY
|
||||
|
||||
|
||||
logger = logging.getLogger("web")
|
||||
|
||||
# Jinja is instantiated here so every router uses the same environment and
|
||||
# the filters registered in app.py (de_dt, iso_utc, flat_slug) are visible
|
||||
# to every template rendered via this instance.
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
apply_client = ApplyClient()
|
||||
|
||||
# Strong refs for fire-and-forget tasks. asyncio.create_task only weakly
|
||||
# references tasks from the event loop — the GC could drop them mid-flight.
|
||||
_bg_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _spawn(coro) -> asyncio.Task:
|
||||
t = asyncio.create_task(coro)
|
||||
_bg_tasks.add(t)
|
||||
t.add_done_callback(_bg_tasks.discard)
|
||||
return t
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Time helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def _last_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.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def require_internal(x_internal_api_key: str | None = Header(default=None)) -> None:
|
||||
if not x_internal_api_key or not hmac.compare_digest(x_internal_api_key, INTERNAL_API_KEY):
|
||||
raise HTTPException(status_code=401, detail="invalid internal key")
|
||||
|
||||
|
||||
def base_context(request: Request, user, active_tab: str) -> dict:
|
||||
return {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"csrf": issue_csrf_token(user["id"]),
|
||||
"active_tab": active_tab,
|
||||
"is_admin": bool(user["is_admin"]),
|
||||
}
|
||||
|
||||
|
||||
def _is_htmx(request: Request) -> bool:
|
||||
return request.headers.get("hx-request", "").lower() == "true"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filter helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FILTER_KEYS = ("rooms_min", "rooms_max", "max_rent", "min_size",
|
||||
"wbs_required", "max_age_hours")
|
||||
|
||||
|
||||
def _has_filters(f) -> bool:
|
||||
if not f:
|
||||
return False
|
||||
for k in FILTER_KEYS:
|
||||
v = f[k] if hasattr(f, "keys") else None
|
||||
if v not in (None, "", 0, 0.0):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _alert_status(notifications_row) -> tuple[str, str]:
|
||||
"""Return (label, chip_kind) for the user's alarm (notification) setup.
|
||||
|
||||
'aktiv' only if a real push channel is configured with credentials.
|
||||
'ui' is not a real alarm — the dashboard already shows matches when you
|
||||
happen to be looking.
|
||||
"""
|
||||
if not notifications_row:
|
||||
return "nicht eingerichtet", "warn"
|
||||
ch = (notifications_row["channel"] or "ui").strip()
|
||||
if ch == "telegram":
|
||||
if notifications_row["telegram_bot_token"] and notifications_row["telegram_chat_id"]:
|
||||
return "aktiv (Telegram)", "ok"
|
||||
return "unvollständig", "warn"
|
||||
return "nicht eingerichtet", "warn"
|
||||
|
||||
|
||||
def _filter_summary(f) -> str:
|
||||
if not _has_filters(f):
|
||||
return "—"
|
||||
parts = []
|
||||
rmin, rmax = f["rooms_min"], f["rooms_max"]
|
||||
if rmin or rmax:
|
||||
def fmt(x):
|
||||
return "–" if x is None else ("%g" % x)
|
||||
parts.append(f"{fmt(rmin)}–{fmt(rmax)} Zi")
|
||||
if f["max_rent"]:
|
||||
parts.append(f"≤ {int(f['max_rent'])} €")
|
||||
if f["min_size"]:
|
||||
parts.append(f"≥ {int(f['min_size'])} m²")
|
||||
if f["wbs_required"] == "yes":
|
||||
parts.append("WBS")
|
||||
elif f["wbs_required"] == "no":
|
||||
parts.append("ohne WBS")
|
||||
if f["max_age_hours"]:
|
||||
parts.append(f"≤ {int(f['max_age_hours'])} h alt")
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply gates and orchestration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _manual_apply_allowed() -> tuple[bool, str]:
|
||||
"""Manual 'Bewerben' button is only blocked if the apply service is down."""
|
||||
if not apply_client.health():
|
||||
return False, "apply-service nicht erreichbar"
|
||||
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 _has_running_application(user_id: int) -> bool:
|
||||
return db.has_running_application(user_id)
|
||||
|
||||
|
||||
def _finish_apply_background(app_id: int, user_id: int, flat_id: str, url: str,
|
||||
profile: dict, submit_forms: bool) -> None:
|
||||
"""Called on a worker thread AFTER the application row already exists.
|
||||
The HTMX response has already shipped the running state to the user."""
|
||||
logger.info("apply.running user=%s flat=%s application=%s submit=%s",
|
||||
user_id, flat_id, app_id, submit_forms)
|
||||
result = apply_client.apply(url=url, profile=profile,
|
||||
submit_forms=submit_forms, application_id=app_id)
|
||||
success = bool(result.get("success"))
|
||||
message = result.get("message", "")
|
||||
provider = result.get("provider", "")
|
||||
forensics = result.get("forensics") or {}
|
||||
|
||||
db.finish_application(app_id, success=success, message=message,
|
||||
provider=provider, forensics=forensics)
|
||||
|
||||
prefs = db.get_preferences(user_id)
|
||||
if success:
|
||||
db.update_preferences(user_id, {"apply_recent_failures": 0})
|
||||
else:
|
||||
failures = int(prefs["apply_recent_failures"] or 0) + 1
|
||||
updates = {"apply_recent_failures": failures}
|
||||
if failures >= APPLY_FAILURE_THRESHOLD:
|
||||
updates["apply_circuit_open"] = 1
|
||||
db.log_error(source="apply", kind="circuit_open", user_id=user_id,
|
||||
summary=f"{failures} aufeinanderfolgende Fehler",
|
||||
application_id=app_id)
|
||||
db.update_preferences(user_id, updates)
|
||||
db.log_error(source="apply", kind="apply_failure", user_id=user_id,
|
||||
summary=message or "Bewerbung fehlgeschlagen",
|
||||
application_id=app_id,
|
||||
context={"provider": provider, "url": url})
|
||||
|
||||
flat = db.get_flat(flat_id)
|
||||
flat_dict = {"address": flat["address"] if flat else "", "link": url,
|
||||
"rooms": flat["rooms"] if flat else None,
|
||||
"total_rent": flat["total_rent"] if flat else None}
|
||||
if success:
|
||||
notifications.on_apply_ok(user_id, flat_dict, message)
|
||||
else:
|
||||
notifications.on_apply_fail(user_id, flat_dict, message)
|
||||
|
||||
db.log_audit("system", "apply_finished", f"app={app_id} success={success}", user_id=user_id)
|
||||
|
||||
|
||||
def _kick_apply(user_id: int, flat_id: str, url: str, triggered_by: str) -> None:
|
||||
"""Insert the application row synchronously so the immediate HTMX response
|
||||
already renders the row's "läuft…" state. The long-running Playwright call
|
||||
is then offloaded to a background thread."""
|
||||
prefs = db.get_preferences(user_id)
|
||||
profile_row = db.get_profile(user_id)
|
||||
profile = _row_to_profile(profile_row)
|
||||
submit_forms = bool(prefs["submit_forms"])
|
||||
|
||||
app_id = db.start_application(
|
||||
user_id=user_id, flat_id=flat_id, url=url,
|
||||
triggered_by=triggered_by, submit_forms=submit_forms,
|
||||
profile_snapshot=profile,
|
||||
)
|
||||
|
||||
_spawn(asyncio.to_thread(
|
||||
_finish_apply_background, app_id, user_id, flat_id, url, profile, submit_forms,
|
||||
))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Misc
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mask_secret(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
if len(value) <= 10:
|
||||
return "•" * len(value)
|
||||
return value[:6] + "…" + value[-4:]
|
||||
0
web/routes/__init__.py
Normal file
0
web/routes/__init__.py
Normal file
224
web/routes/admin.py
Normal file
224
web/routes/admin.py
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
"""Admin-only routes: protocol log viewer, user management, secrets,
|
||||
CSV export, and the /logs legacy redirect."""
|
||||
import io
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
import db
|
||||
from auth import current_user, hash_password, require_admin, require_csrf
|
||||
from common import (
|
||||
BERLIN_TZ,
|
||||
_de_dt,
|
||||
_mask_secret,
|
||||
base_context,
|
||||
client_ip,
|
||||
templates,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
ADMIN_SECTIONS = ("protokoll", "benutzer", "geheimnisse")
|
||||
|
||||
|
||||
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,
|
||||
limit: int = 500) -> list[dict]:
|
||||
users = {row["id"]: row["username"] for row in db.list_users()}
|
||||
events: list[dict] = []
|
||||
for a in db.audit_in_range(start_iso, end_iso, limit=limit):
|
||||
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.errors_in_range(start_iso, end_iso, limit=limit):
|
||||
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
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
def tab_logs_legacy():
|
||||
# Old top-level Protokoll tab was merged into /admin/protokoll.
|
||||
return RedirectResponse("/admin/protokoll", status_code=301)
|
||||
|
||||
|
||||
@router.get("/admin", response_class=HTMLResponse)
|
||||
def tab_admin_root(request: Request):
|
||||
return RedirectResponse("/admin/protokoll", status_code=303)
|
||||
|
||||
|
||||
@router.get("/admin/{section}", response_class=HTMLResponse)
|
||||
def tab_admin(request: Request, section: str):
|
||||
u = current_user(request)
|
||||
if not u:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
if not u["is_admin"]:
|
||||
raise HTTPException(403, "admin only")
|
||||
if section not in ADMIN_SECTIONS:
|
||||
raise HTTPException(404)
|
||||
|
||||
ctx = base_context(request, u, "admin")
|
||||
ctx["section"] = section
|
||||
|
||||
if section == "protokoll":
|
||||
q = request.query_params
|
||||
from_str = q.get("from") or ""
|
||||
to_str = q.get("to") or ""
|
||||
start_iso, end_iso = _parse_date_range(from_str or None, to_str or None)
|
||||
ctx.update({
|
||||
"events": _collect_events(start_iso, end_iso, limit=500),
|
||||
"from_str": from_str, "to_str": to_str,
|
||||
})
|
||||
elif section == "benutzer":
|
||||
ctx["users"] = db.list_users()
|
||||
elif section == "geheimnisse":
|
||||
secrets = db.all_secrets()
|
||||
ctx["secrets_masked"] = {k: _mask_secret(secrets.get(k, "")) for k in db.SECRET_KEYS}
|
||||
ctx["secret_flash"] = request.query_params.get("ok")
|
||||
return templates.TemplateResponse("admin.html", ctx)
|
||||
|
||||
|
||||
@router.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, limit=5000)
|
||||
|
||||
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 = "wohnungsdidi-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}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/actions/users/create")
|
||||
async def action_users_create(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
is_admin: str = Form(""),
|
||||
csrf: str = Form(...),
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
require_csrf(admin["id"], csrf)
|
||||
username = (username or "").strip()
|
||||
if not username or len(password) < 10:
|
||||
raise HTTPException(400, "username required, password >= 10 chars")
|
||||
try:
|
||||
uid = db.create_user(username, hash_password(password),
|
||||
is_admin=(is_admin.lower() in ("on", "true", "yes", "1")))
|
||||
except sqlite3.IntegrityError:
|
||||
return RedirectResponse("/admin/benutzer?err=exists", status_code=303)
|
||||
db.log_audit(admin["username"], "user.created", f"new_user={username} id={uid}",
|
||||
user_id=admin["id"], ip=client_ip(request))
|
||||
return RedirectResponse("/admin/benutzer?ok=1", status_code=303)
|
||||
|
||||
|
||||
@router.post("/actions/users/disable")
|
||||
async def action_users_disable(
|
||||
request: Request,
|
||||
target_id: int = Form(...),
|
||||
value: str = Form(...),
|
||||
csrf: str = Form(...),
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
require_csrf(admin["id"], csrf)
|
||||
if target_id == admin["id"]:
|
||||
raise HTTPException(400, "refusing to disable self")
|
||||
db.set_user_disabled(target_id, value == "on")
|
||||
db.log_audit(admin["username"], "user.toggle_disable",
|
||||
f"target={target_id} disabled={value=='on'}",
|
||||
user_id=admin["id"], ip=client_ip(request))
|
||||
return RedirectResponse("/admin/benutzer", status_code=303)
|
||||
|
||||
|
||||
@router.post("/actions/users/delete")
|
||||
async def action_users_delete(
|
||||
request: Request,
|
||||
target_id: int = Form(...),
|
||||
csrf: str = Form(...),
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
require_csrf(admin["id"], csrf)
|
||||
if target_id == admin["id"]:
|
||||
raise HTTPException(400, "refusing to delete self")
|
||||
target = db.get_user(target_id)
|
||||
if not target:
|
||||
return RedirectResponse("/admin/benutzer", status_code=303)
|
||||
db.delete_user(target_id)
|
||||
db.log_audit(admin["username"], "user.deleted",
|
||||
f"target={target_id} username={target['username']}",
|
||||
user_id=admin["id"], ip=client_ip(request))
|
||||
return RedirectResponse("/admin/benutzer?deleted=1", status_code=303)
|
||||
|
||||
|
||||
@router.post("/actions/secrets")
|
||||
async def action_secrets(request: Request, admin=Depends(require_admin)):
|
||||
form = await request.form()
|
||||
require_csrf(admin["id"], form.get("csrf", ""))
|
||||
changed = []
|
||||
for key in db.SECRET_KEYS:
|
||||
raw = (form.get(key) or "").strip()
|
||||
if not raw:
|
||||
continue
|
||||
db.set_secret(key, raw)
|
||||
changed.append(key)
|
||||
db.log_audit(admin["username"], "secrets.updated",
|
||||
",".join(changed) or "no-op",
|
||||
user_id=admin["id"], ip=client_ip(request))
|
||||
return RedirectResponse("/admin/geheimnisse?ok=1", status_code=303)
|
||||
59
web/routes/auth.py
Normal file
59
web/routes/auth.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Authentication routes: login page, login submit, logout."""
|
||||
from fastapi import APIRouter, Form, Request, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
import db
|
||||
from auth import (
|
||||
clear_session_cookie,
|
||||
current_user,
|
||||
issue_session_cookie,
|
||||
rate_limit_login,
|
||||
verify_login,
|
||||
)
|
||||
from common import client_ip, templates
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
def login_form(request: Request, error: str | None = None):
|
||||
if current_user(request):
|
||||
return RedirectResponse("/", status_code=303)
|
||||
return templates.TemplateResponse("login.html", {"request": request, "error": error})
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login_submit(request: Request, username: str = Form(...), password: str = Form(...)):
|
||||
ip = client_ip(request)
|
||||
if not rate_limit_login(ip):
|
||||
db.log_audit(username or "?", "login_rate_limited", ip=ip)
|
||||
db.log_error(source="web", kind="rate_limit", summary=f"login throttled for {ip}",
|
||||
context={"username": username or ""})
|
||||
return templates.TemplateResponse(
|
||||
"login.html",
|
||||
{"request": request, "error": "Zu viele Versuche. Bitte später erneut."},
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
user = verify_login(username, password)
|
||||
if not user:
|
||||
db.log_audit(username or "?", "login_failed", ip=ip)
|
||||
return templates.TemplateResponse(
|
||||
"login.html",
|
||||
{"request": request, "error": "Login fehlgeschlagen."},
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
response = RedirectResponse("/", status_code=303)
|
||||
issue_session_cookie(response, user["id"])
|
||||
db.log_audit(user["username"], "login_success", user_id=user["id"], ip=ip)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(request: Request):
|
||||
u = current_user(request)
|
||||
response = RedirectResponse("/login", status_code=303)
|
||||
clear_session_cookie(response)
|
||||
if u:
|
||||
db.log_audit(u["username"], "logout", user_id=u["id"], ip=client_ip(request))
|
||||
return response
|
||||
102
web/routes/bewerbungen.py
Normal file
102
web/routes/bewerbungen.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""Bewerbungen (application history) tab and forensics ZIP export."""
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
|
||||
|
||||
import db
|
||||
from auth import current_user
|
||||
from common import base_context, templates
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/bewerbungen", response_class=HTMLResponse)
|
||||
def tab_bewerbungen(request: Request):
|
||||
u = current_user(request)
|
||||
if not u:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
ctx = base_context(request, u, "bewerbungen")
|
||||
ctx["applications"] = db.recent_applications(u["id"], limit=100)
|
||||
return templates.TemplateResponse("bewerbungen.html", ctx)
|
||||
|
||||
|
||||
@router.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"wohnungsdidi 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" snapshots/NN_*.jpg Screenshot at each step (NN = order)\n"
|
||||
f" snapshots/NN_*.html Page HTML at each step\n"
|
||||
)
|
||||
zf.writestr("application.json", json.dumps(app_meta, indent=2, default=str))
|
||||
zf.writestr("flat.json", json.dumps(dict(flat) if flat else {}, indent=2, default=str))
|
||||
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):
|
||||
label = (s.get("label") or f"step{idx}").replace("/", "_").replace(" ", "_")
|
||||
b64 = s.get("b64_jpeg", "")
|
||||
if b64:
|
||||
try:
|
||||
data = base64.b64decode(b64)
|
||||
zf.writestr(f"snapshots/{idx:02d}_{label}.jpg", data)
|
||||
except Exception:
|
||||
pass
|
||||
html = s.get("html") or ""
|
||||
if html:
|
||||
zf.writestr(f"snapshots/{idx:02d}_{label}.html", html)
|
||||
|
||||
buf.seek(0)
|
||||
filename = f"wohnungsdidi-report-{a['id']}.zip"
|
||||
return StreamingResponse(
|
||||
buf, media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
188
web/routes/einstellungen.py
Normal file
188
web/routes/einstellungen.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"""Einstellungen (settings) tab: profile, filter info, notifications, partner,
|
||||
account, plus the related action endpoints."""
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
import db
|
||||
from auth import current_user, hash_password, require_csrf, require_user
|
||||
from common import base_context, client_ip, templates
|
||||
from matching import row_to_dict
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
VALID_SECTIONS = ("profil", "filter", "benachrichtigungen", "partner", "account")
|
||||
|
||||
|
||||
@router.get("/einstellungen", response_class=HTMLResponse)
|
||||
def tab_settings_root(request: Request):
|
||||
return RedirectResponse("/einstellungen/profil", status_code=303)
|
||||
|
||||
|
||||
@router.get("/einstellungen/{section}", response_class=HTMLResponse)
|
||||
def tab_settings(request: Request, section: str):
|
||||
u = current_user(request)
|
||||
if not u:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
# Benutzer verwaltung lives under /admin/benutzer since the admin tab rework.
|
||||
if section == "benutzer":
|
||||
return RedirectResponse("/admin/benutzer", status_code=301)
|
||||
if section not in VALID_SECTIONS:
|
||||
raise HTTPException(404)
|
||||
|
||||
ctx = base_context(request, u, "einstellungen")
|
||||
ctx["section"] = section
|
||||
|
||||
if section == "profil":
|
||||
ctx["profile"] = db.get_profile(u["id"])
|
||||
elif section == "filter":
|
||||
ctx["filters"] = row_to_dict(db.get_filters(u["id"]))
|
||||
elif section == "benachrichtigungen":
|
||||
ctx["notifications"] = db.get_notifications(u["id"])
|
||||
elif section == "partner":
|
||||
ctx["partner"] = db.get_partner_user(u["id"])
|
||||
ctx["partner_profile"] = db.get_profile(ctx["partner"]["id"]) if ctx["partner"] else None
|
||||
ctx["incoming_requests"] = db.partnership_incoming(u["id"])
|
||||
ctx["outgoing_requests"] = db.partnership_outgoing(u["id"])
|
||||
ctx["partner_flash"] = request.query_params.get("flash") or ""
|
||||
return templates.TemplateResponse("einstellungen.html", ctx)
|
||||
|
||||
|
||||
@router.post("/actions/profile")
|
||||
async def action_profile(request: Request, user=Depends(require_user)):
|
||||
form = await request.form()
|
||||
require_csrf(user["id"], form.get("csrf", ""))
|
||||
|
||||
def _b(name): return form.get(name, "").lower() in ("true", "on", "yes", "1")
|
||||
def _i(name):
|
||||
try: return int(form.get(name) or 0)
|
||||
except ValueError: return 0
|
||||
|
||||
# Field names are intentionally opaque ("contact_addr", "immomio_login",
|
||||
# "immomio_secret") to keep password managers — specifically Bitwarden —
|
||||
# from recognising the form as a login/identity form and autofilling.
|
||||
db.update_profile(user["id"], {
|
||||
"salutation": form.get("salutation", ""),
|
||||
"firstname": form.get("firstname", ""),
|
||||
"lastname": form.get("lastname", ""),
|
||||
"email": form.get("contact_addr", ""),
|
||||
"telephone": form.get("telephone", ""),
|
||||
"street": form.get("street", ""),
|
||||
"house_number": form.get("house_number", ""),
|
||||
"postcode": form.get("postcode", ""),
|
||||
"city": form.get("city", ""),
|
||||
"is_possessing_wbs": 1 if _b("is_possessing_wbs") else 0,
|
||||
"wbs_type": form.get("wbs_type", "0"),
|
||||
"wbs_valid_till": form.get("wbs_valid_till", "1970-01-01"),
|
||||
"wbs_rooms": _i("wbs_rooms"),
|
||||
"wbs_adults": _i("wbs_adults"),
|
||||
"wbs_children": _i("wbs_children"),
|
||||
"is_prio_wbs": 1 if _b("is_prio_wbs") else 0,
|
||||
"immomio_email": form.get("immomio_login", ""),
|
||||
"immomio_password": form.get("immomio_secret", ""),
|
||||
})
|
||||
db.log_audit(user["username"], "profile.updated", user_id=user["id"], ip=client_ip(request))
|
||||
return RedirectResponse("/einstellungen/profil", status_code=303)
|
||||
|
||||
|
||||
@router.post("/actions/notifications")
|
||||
async def action_notifications(request: Request, user=Depends(require_user)):
|
||||
form = await request.form()
|
||||
require_csrf(user["id"], form.get("csrf", ""))
|
||||
def _b(n): return 1 if form.get(n, "").lower() in ("on", "true", "1", "yes") else 0
|
||||
channel = form.get("channel", "ui")
|
||||
if channel not in ("ui", "telegram"):
|
||||
channel = "ui"
|
||||
db.update_notifications(user["id"], {
|
||||
"channel": channel,
|
||||
"telegram_bot_token": form.get("telegram_bot_token", ""),
|
||||
"telegram_chat_id": form.get("telegram_chat_id", ""),
|
||||
"notify_on_match": _b("notify_on_match"),
|
||||
"notify_on_apply_success": _b("notify_on_apply_success"),
|
||||
"notify_on_apply_fail": _b("notify_on_apply_fail"),
|
||||
})
|
||||
db.log_audit(user["username"], "notifications.updated", user_id=user["id"], ip=client_ip(request))
|
||||
return RedirectResponse("/einstellungen/benachrichtigungen", status_code=303)
|
||||
|
||||
|
||||
@router.post("/actions/account/password")
|
||||
async def action_password(
|
||||
request: Request,
|
||||
old_password: str = Form(""),
|
||||
new_password: str = Form(""),
|
||||
new_password_repeat: str = Form(""),
|
||||
csrf: str = Form(...),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
require_csrf(user["id"], csrf)
|
||||
if not new_password or new_password != new_password_repeat:
|
||||
return RedirectResponse("/einstellungen/account?err=mismatch", status_code=303)
|
||||
if len(new_password) < 10:
|
||||
return RedirectResponse("/einstellungen/account?err=tooshort", status_code=303)
|
||||
row = db.get_user_by_username(user["username"])
|
||||
from auth import verify_hash
|
||||
if not row or not verify_hash(row["password_hash"], old_password):
|
||||
return RedirectResponse("/einstellungen/account?err=wrongold", status_code=303)
|
||||
db.set_user_password(user["id"], hash_password(new_password))
|
||||
db.log_audit(user["username"], "password.changed", user_id=user["id"], ip=client_ip(request))
|
||||
return RedirectResponse("/einstellungen/account?ok=1", status_code=303)
|
||||
|
||||
|
||||
@router.post("/actions/partner/request")
|
||||
async def action_partner_request(
|
||||
request: Request,
|
||||
partner_username: str = Form(...),
|
||||
csrf: str = Form(...),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
require_csrf(user["id"], csrf)
|
||||
target = db.get_user_by_username((partner_username or "").strip())
|
||||
if not target or target["id"] == user["id"]:
|
||||
return RedirectResponse("/einstellungen/partner?flash=nouser", status_code=303)
|
||||
req_id = db.partnership_request(user["id"], target["id"])
|
||||
if req_id is None:
|
||||
return RedirectResponse("/einstellungen/partner?flash=exists", status_code=303)
|
||||
db.log_audit(user["username"], "partner.requested",
|
||||
f"target={target['username']}", user_id=user["id"], ip=client_ip(request))
|
||||
return RedirectResponse("/einstellungen/partner?flash=sent", status_code=303)
|
||||
|
||||
|
||||
@router.post("/actions/partner/accept")
|
||||
async def action_partner_accept(
|
||||
request: Request,
|
||||
request_id: int = Form(...),
|
||||
csrf: str = Form(...),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
require_csrf(user["id"], csrf)
|
||||
if not db.partnership_accept(request_id, user["id"]):
|
||||
return RedirectResponse("/einstellungen/partner?flash=accept_failed", status_code=303)
|
||||
db.log_audit(user["username"], "partner.accepted",
|
||||
f"request={request_id}", user_id=user["id"], ip=client_ip(request))
|
||||
return RedirectResponse("/einstellungen/partner?flash=accepted", status_code=303)
|
||||
|
||||
|
||||
@router.post("/actions/partner/decline")
|
||||
async def action_partner_decline(
|
||||
request: Request,
|
||||
request_id: int = Form(...),
|
||||
csrf: str = Form(...),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
require_csrf(user["id"], csrf)
|
||||
db.partnership_decline(request_id, user["id"])
|
||||
db.log_audit(user["username"], "partner.declined",
|
||||
f"request={request_id}", user_id=user["id"], ip=client_ip(request))
|
||||
return RedirectResponse("/einstellungen/partner?flash=declined", status_code=303)
|
||||
|
||||
|
||||
@router.post("/actions/partner/unlink")
|
||||
async def action_partner_unlink(
|
||||
request: Request,
|
||||
csrf: str = Form(...),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
require_csrf(user["id"], csrf)
|
||||
db.partnership_unlink(user["id"])
|
||||
db.log_audit(user["username"], "partner.unlinked", user_id=user["id"], ip=client_ip(request))
|
||||
return RedirectResponse("/einstellungen/partner?flash=unlinked", status_code=303)
|
||||
77
web/routes/internal.py
Normal file
77
web/routes/internal.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Internal service-to-service endpoints. Authenticated via INTERNAL_API_KEY
|
||||
header; never called by browsers."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
import db
|
||||
import enrichment
|
||||
import notifications
|
||||
from common import _auto_apply_allowed, _kick_apply, require_internal
|
||||
from matching import flat_matches_filter, row_to_dict
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/internal/flats")
|
||||
async def internal_submit_flat(
|
||||
payload: dict,
|
||||
_guard: None = Depends(require_internal),
|
||||
):
|
||||
if not payload.get("id") or not payload.get("link"):
|
||||
raise HTTPException(400, "id and link required")
|
||||
|
||||
is_new = db.upsert_flat(payload)
|
||||
if not is_new:
|
||||
return {"status": "duplicate"}
|
||||
|
||||
# Kick LLM enrichment + image download for this fresh flat.
|
||||
enrichment.kick(str(payload["id"]))
|
||||
|
||||
for u in db.list_users():
|
||||
if u["disabled"]:
|
||||
continue
|
||||
filters = row_to_dict(db.get_filters(u["id"]))
|
||||
if not flat_matches_filter(payload, filters):
|
||||
continue
|
||||
|
||||
db.log_audit("alert", "flat_matched",
|
||||
f"user={u['username']} flat={payload['id']}",
|
||||
user_id=u["id"])
|
||||
notifications.on_match(u["id"], payload)
|
||||
|
||||
prefs = db.get_preferences(u["id"])
|
||||
if _auto_apply_allowed(prefs):
|
||||
_kick_apply(u["id"], str(payload["id"]), payload["link"], "auto")
|
||||
db.log_audit("system", "auto_apply_kick",
|
||||
f"user={u['username']} flat={payload['id']}",
|
||||
user_id=u["id"])
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/internal/heartbeat")
|
||||
async def internal_heartbeat(payload: dict, _g: None = Depends(require_internal)):
|
||||
service = payload.get("service", "unknown")
|
||||
db.set_state(f"last_{service}_heartbeat", db.now_iso())
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/internal/error")
|
||||
async def internal_report_error(
|
||||
payload: dict,
|
||||
_g: None = Depends(require_internal),
|
||||
):
|
||||
db.log_error(
|
||||
source=payload.get("source", "unknown"),
|
||||
kind=payload.get("kind", "error"),
|
||||
summary=payload.get("summary", ""),
|
||||
context=payload.get("context"),
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/internal/secrets")
|
||||
async def internal_secrets(_g: None = Depends(require_internal)):
|
||||
"""Give sibling services (alert) the current runtime creds that the admin
|
||||
may have edited via the UI, so no redeploy is needed when rotating."""
|
||||
return db.all_secrets()
|
||||
372
web/routes/wohnungen.py
Normal file
372
web/routes/wohnungen.py
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
"""Wohnungen (main dashboard) routes, including the HTMX partials and the
|
||||
per-flat action endpoints. The tab-specific context builder and the
|
||||
partial-or-redirect helper live here since they're only used by this tab.
|
||||
"""
|
||||
import mimetypes
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
import db
|
||||
import enrichment
|
||||
from auth import current_user, require_admin, require_csrf, require_user
|
||||
from common import (
|
||||
_alert_status,
|
||||
_auto_apply_allowed,
|
||||
_filter_summary,
|
||||
_has_filters,
|
||||
_has_running_application,
|
||||
_is_htmx,
|
||||
_kick_apply,
|
||||
_last_scrape_utc,
|
||||
_manual_apply_allowed,
|
||||
_parse_iso,
|
||||
apply_client,
|
||||
base_context,
|
||||
client_ip,
|
||||
templates,
|
||||
)
|
||||
from matching import flat_matches_filter, row_to_dict
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _wohnungen_context(user) -> dict:
|
||||
uid = user["id"]
|
||||
filters_row = db.get_filters(uid)
|
||||
notif_row = db.get_notifications(uid)
|
||||
prefs = db.get_preferences(uid)
|
||||
filters = row_to_dict(filters_row)
|
||||
flats = db.recent_flats(100)
|
||||
|
||||
rejected = db.rejected_flat_ids(uid)
|
||||
max_age_hours = filters_row["max_age_hours"] if filters_row else None
|
||||
age_cutoff = None
|
||||
if max_age_hours:
|
||||
age_cutoff = datetime.now(timezone.utc) - timedelta(hours=int(max_age_hours))
|
||||
# One query for this user's latest application per flat, instead of a
|
||||
# per-flat query inside the loop.
|
||||
latest_apps = db.latest_applications_by_flat(uid)
|
||||
flats_view = []
|
||||
for f in flats:
|
||||
if f["id"] in rejected:
|
||||
continue
|
||||
if age_cutoff is not None:
|
||||
disc = _parse_iso(f["discovered_at"])
|
||||
if disc is None:
|
||||
continue
|
||||
if disc.tzinfo is None:
|
||||
disc = disc.replace(tzinfo=timezone.utc)
|
||||
if disc < age_cutoff:
|
||||
continue
|
||||
if not flat_matches_filter({
|
||||
"rooms": f["rooms"], "total_rent": f["total_rent"], "size": f["size"],
|
||||
"wbs": f["wbs"],
|
||||
}, filters):
|
||||
continue
|
||||
flats_view.append({"row": f, "last": latest_apps.get(f["id"])})
|
||||
|
||||
rejected_view = db.rejected_flats(uid)
|
||||
enrichment_counts = db.enrichment_counts()
|
||||
|
||||
partner = db.get_partner_user(uid)
|
||||
partner_info = None
|
||||
if partner:
|
||||
partner_profile = db.get_profile(partner["id"])
|
||||
initial = ((partner_profile["firstname"] if partner_profile else "")
|
||||
or partner["username"] or "?")[:1].upper()
|
||||
display_name = (partner_profile["firstname"]
|
||||
if partner_profile and partner_profile["firstname"]
|
||||
else partner["username"])
|
||||
actions = db.partner_flat_actions(partner["id"])
|
||||
partner_info = {
|
||||
"initial": initial,
|
||||
"name": display_name,
|
||||
"applied_flat_ids": actions["applied"],
|
||||
"rejected_flat_ids": actions["rejected"],
|
||||
}
|
||||
|
||||
allowed, reason = _manual_apply_allowed()
|
||||
alert_label, alert_chip = _alert_status(notif_row)
|
||||
has_running = _has_running_application(uid)
|
||||
map_points = []
|
||||
for item in flats_view:
|
||||
f = item["row"]
|
||||
if f["lat"] is None or f["lng"] is None:
|
||||
continue
|
||||
last = item["last"]
|
||||
is_running = bool(last and last["finished_at"] is None)
|
||||
already_applied = bool(last and last["success"] == 1)
|
||||
if is_running:
|
||||
status = {"label": "läuft…", "chip": "warn"}
|
||||
elif already_applied:
|
||||
status = {"label": "beworben", "chip": "ok"}
|
||||
elif last and last["success"] == 0:
|
||||
status = {"label": "fehlgeschlagen", "chip": "bad"}
|
||||
else:
|
||||
status = None
|
||||
map_points.append({
|
||||
"id": f["id"],
|
||||
"lat": f["lat"], "lng": f["lng"],
|
||||
"address": f["address"] or f["link"],
|
||||
"link": f["link"],
|
||||
"rent": f["total_rent"],
|
||||
"rooms": f["rooms"],
|
||||
"size": f["size"],
|
||||
"status": status,
|
||||
"can_apply": allowed and not already_applied,
|
||||
"is_running": is_running,
|
||||
})
|
||||
return {
|
||||
"flats": flats_view,
|
||||
"rejected_flats": rejected_view,
|
||||
"enrichment_counts": enrichment_counts,
|
||||
"partner": partner_info,
|
||||
"map_points": map_points,
|
||||
"has_filters": _has_filters(filters_row),
|
||||
"alert_label": alert_label,
|
||||
"alert_chip": alert_chip,
|
||||
"filter_summary": _filter_summary(filters_row),
|
||||
"auto_apply_enabled": bool(prefs["auto_apply_enabled"]),
|
||||
"submit_forms": bool(prefs["submit_forms"]),
|
||||
"circuit_open": bool(prefs["apply_circuit_open"]),
|
||||
"apply_failures": int(prefs["apply_recent_failures"] or 0),
|
||||
"apply_allowed": allowed,
|
||||
"apply_block_reason": reason,
|
||||
"apply_reachable": apply_client.health(),
|
||||
"last_scrape_utc": _last_scrape_utc(),
|
||||
"has_running_apply": has_running,
|
||||
"poll_interval": 3 if has_running else 30,
|
||||
}
|
||||
|
||||
|
||||
def _wohnungen_partial_or_redirect(request: Request, user):
|
||||
"""If called via HTMX, render the body partial; otherwise redirect to /."""
|
||||
if _is_htmx(request):
|
||||
ctx = base_context(request, user, "wohnungen")
|
||||
ctx.update(_wohnungen_context(user))
|
||||
return templates.TemplateResponse("_wohnungen_body.html", ctx)
|
||||
return RedirectResponse("/", status_code=303)
|
||||
|
||||
|
||||
@router.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)
|
||||
|
||||
|
||||
@router.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)
|
||||
|
||||
|
||||
@router.get("/partials/wohnung/{flat_id:path}", response_class=HTMLResponse)
|
||||
def partial_wohnung_detail(request: Request, flat_id: str, user=Depends(require_user)):
|
||||
flat = db.get_flat(flat_id)
|
||||
if not flat:
|
||||
raise HTTPException(404)
|
||||
slug = enrichment.flat_slug(flat_id)
|
||||
image_urls = [
|
||||
f"/flat-images/{slug}/{i}"
|
||||
for i in range(1, int(flat["image_count"] or 0) + 1)
|
||||
]
|
||||
ctx = {
|
||||
"request": request,
|
||||
"flat": flat,
|
||||
"enrichment_status": flat["enrichment_status"],
|
||||
"image_urls": image_urls,
|
||||
}
|
||||
return templates.TemplateResponse("_wohnung_detail.html", ctx)
|
||||
|
||||
|
||||
@router.get("/flat-images/{slug}/{index}")
|
||||
def flat_image(slug: str, index: int):
|
||||
"""Serve a downloaded flat image by slug + 1-based index.
|
||||
|
||||
`slug` is derived from enrichment.flat_slug(flat_id) and is filesystem-safe
|
||||
(hex), so it can be composed into a path without sanitisation concerns."""
|
||||
if not slug.isalnum() or not 1 <= index <= 99:
|
||||
raise HTTPException(404)
|
||||
d = enrichment.IMAGES_DIR / slug
|
||||
if not d.exists():
|
||||
raise HTTPException(404)
|
||||
# Files are named NN.<ext>; try the usual extensions.
|
||||
prefix = f"{index:02d}."
|
||||
for f in d.iterdir():
|
||||
if f.name.startswith(prefix):
|
||||
media = mimetypes.guess_type(f.name)[0] or "image/jpeg"
|
||||
return Response(content=f.read_bytes(), media_type=media,
|
||||
headers={"Cache-Control": "public, max-age=3600"})
|
||||
raise HTTPException(404)
|
||||
|
||||
|
||||
@router.post("/actions/filters")
|
||||
async def action_save_filters(
|
||||
request: Request,
|
||||
csrf: str = Form(...),
|
||||
rooms_min: str = Form(""),
|
||||
rooms_max: str = Form(""),
|
||||
max_rent: str = Form(""),
|
||||
min_size: str = Form(""),
|
||||
wbs_required: str = Form(""),
|
||||
max_age_hours: str = Form(""),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
require_csrf(user["id"], csrf)
|
||||
|
||||
def _f(v):
|
||||
v = (v or "").strip().replace(",", ".")
|
||||
return float(v) if v else None
|
||||
|
||||
def _i(v):
|
||||
v = (v or "").strip()
|
||||
try:
|
||||
return int(v) if v else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
db.update_filters(user["id"], {
|
||||
"rooms_min": _f(rooms_min),
|
||||
"rooms_max": _f(rooms_max),
|
||||
"max_rent": _f(max_rent),
|
||||
"min_size": _f(min_size),
|
||||
"wbs_required": (wbs_required or "").strip(),
|
||||
"max_age_hours": _i(max_age_hours),
|
||||
})
|
||||
db.log_audit(user["username"], "filters.updated", user_id=user["id"], ip=client_ip(request))
|
||||
return RedirectResponse("/", status_code=303)
|
||||
|
||||
|
||||
@router.post("/actions/auto-apply")
|
||||
async def action_auto_apply(
|
||||
request: Request,
|
||||
value: str = Form(default="off"),
|
||||
csrf: str = Form(...),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
require_csrf(user["id"], csrf)
|
||||
new = 1 if value == "on" else 0
|
||||
db.update_preferences(user["id"], {"auto_apply_enabled": new})
|
||||
db.log_audit(user["username"], "auto_apply", "on" if new else "off",
|
||||
user_id=user["id"], ip=client_ip(request))
|
||||
return _wohnungen_partial_or_redirect(request, user)
|
||||
|
||||
|
||||
@router.post("/actions/reset-circuit")
|
||||
async def action_reset_circuit(
|
||||
request: Request,
|
||||
csrf: str = Form(...),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
require_csrf(user["id"], csrf)
|
||||
db.update_preferences(user["id"], {"apply_circuit_open": 0, "apply_recent_failures": 0})
|
||||
db.log_audit(user["username"], "reset_circuit", user_id=user["id"], ip=client_ip(request))
|
||||
return _wohnungen_partial_or_redirect(request, user)
|
||||
|
||||
|
||||
@router.post("/actions/apply")
|
||||
async def action_apply(
|
||||
request: Request,
|
||||
flat_id: str = Form(...),
|
||||
csrf: str = Form(...),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
require_csrf(user["id"], csrf)
|
||||
allowed, reason = _manual_apply_allowed()
|
||||
if not allowed:
|
||||
raise HTTPException(409, f"apply disabled: {reason}")
|
||||
flat = db.get_flat(flat_id)
|
||||
if not flat:
|
||||
raise HTTPException(404, "flat not found")
|
||||
last = db.last_application_for_flat(user["id"], flat_id)
|
||||
if last and last["finished_at"] is None:
|
||||
# Another apply is already running for this user+flat; don't queue a second.
|
||||
return _wohnungen_partial_or_redirect(request, user)
|
||||
if last and last["success"] == 1:
|
||||
# Already successfully applied — no point in re-running.
|
||||
return _wohnungen_partial_or_redirect(request, user)
|
||||
db.log_audit(user["username"], "trigger_apply", f"flat_id={flat_id}",
|
||||
user_id=user["id"], ip=client_ip(request))
|
||||
_kick_apply(user["id"], flat_id, flat["link"], "user")
|
||||
return _wohnungen_partial_or_redirect(request, user)
|
||||
|
||||
|
||||
@router.post("/actions/reject")
|
||||
async def action_reject(
|
||||
request: Request,
|
||||
flat_id: str = Form(...),
|
||||
csrf: str = Form(...),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
require_csrf(user["id"], csrf)
|
||||
db.reject_flat(user["id"], flat_id)
|
||||
db.log_audit(user["username"], "flat.rejected", f"flat_id={flat_id}",
|
||||
user_id=user["id"], ip=client_ip(request))
|
||||
return _wohnungen_partial_or_redirect(request, user)
|
||||
|
||||
|
||||
@router.post("/actions/unreject")
|
||||
async def action_unreject(
|
||||
request: Request,
|
||||
flat_id: str = Form(...),
|
||||
csrf: str = Form(...),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
require_csrf(user["id"], csrf)
|
||||
db.unreject_flat(user["id"], flat_id)
|
||||
db.log_audit(user["username"], "flat.unrejected", f"flat_id={flat_id}",
|
||||
user_id=user["id"], ip=client_ip(request))
|
||||
return _wohnungen_partial_or_redirect(request, user)
|
||||
|
||||
|
||||
@router.post("/actions/submit-forms")
|
||||
async def action_submit_forms(
|
||||
request: Request,
|
||||
value: str = Form(default="off"),
|
||||
csrf: str = Form(...),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
require_csrf(user["id"], csrf)
|
||||
new = 1 if value == "on" else 0
|
||||
db.update_preferences(user["id"], {"submit_forms": new})
|
||||
db.log_audit(user["username"], "submit_forms", "on" if new else "off",
|
||||
user_id=user["id"], ip=client_ip(request))
|
||||
if _is_htmx(request):
|
||||
return _wohnungen_partial_or_redirect(request, user)
|
||||
return RedirectResponse(request.headers.get("referer", "/einstellungen/profil"), status_code=303)
|
||||
|
||||
|
||||
@router.post("/actions/enrich-all")
|
||||
async def action_enrich_all(
|
||||
request: Request,
|
||||
csrf: str = Form(...),
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
require_csrf(admin["id"], csrf)
|
||||
queued = enrichment.kick_backfill()
|
||||
db.log_audit(admin["username"], "enrichment.backfill",
|
||||
f"queued={queued}", user_id=admin["id"], ip=client_ip(request))
|
||||
return _wohnungen_partial_or_redirect(request, admin)
|
||||
|
||||
|
||||
@router.post("/actions/enrich-flat")
|
||||
async def action_enrich_flat(
|
||||
request: Request,
|
||||
flat_id: str = Form(...),
|
||||
csrf: str = Form(...),
|
||||
admin=Depends(require_admin),
|
||||
):
|
||||
require_csrf(admin["id"], csrf)
|
||||
db.set_flat_enrichment(flat_id, "pending")
|
||||
enrichment.kick(flat_id)
|
||||
db.log_audit(admin["username"], "enrichment.retry",
|
||||
f"flat={flat_id}", user_id=admin["id"], ip=client_ip(request))
|
||||
return _wohnungen_partial_or_redirect(request, admin)
|
||||
163
web/static/app.css
Normal file
163
web/static/app.css
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/* wohnungsdidi — site styling.
|
||||
Imported via <link> from base.html. Tailwind utilities (via CDN) handle
|
||||
layout/spacing; this file owns our design tokens and component classes. */
|
||||
|
||||
:root {
|
||||
--bg-from: #e4f0fb; --bg-to: #f7fbfe;
|
||||
--surface: #ffffff; --border: #d8e6f3;
|
||||
--text: #10253f; --muted: #667d98;
|
||||
--primary: #2f8ae0; --primary-hover: #1f74c8;
|
||||
--danger: #e05a6a; --danger-hover: #c44a59;
|
||||
--ghost: #eaf2fb; --ghost-hover: #d5e5f4;
|
||||
--accent: #fbd76b;
|
||||
}
|
||||
|
||||
html { color-scheme: light; }
|
||||
body {
|
||||
background: linear-gradient(180deg, var(--bg-from) 0%, var(--bg-to) 100%);
|
||||
background-attachment: fixed;
|
||||
color: var(--text);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Inter, sans-serif;
|
||||
}
|
||||
|
||||
/* Card + separator hooks */
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
|
||||
box-shadow: 0 1px 2px rgba(16, 37, 63, 0.04); }
|
||||
.border-soft { border-color: var(--border) !important; }
|
||||
.divide-soft > :not([hidden]) ~ :not([hidden]) { border-color: var(--border) !important; }
|
||||
|
||||
/* Buttons */
|
||||
.btn { border-radius: 9px; padding: 0.45rem 0.95rem; font-weight: 500;
|
||||
transition: background 0.15s, box-shadow 0.15s, transform 0.05s; display: inline-block; }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn:disabled, .btn[disabled] { opacity: .55; cursor: not-allowed; pointer-events: none; }
|
||||
.btn-primary { background: var(--primary); color: white; box-shadow: 0 1px 2px rgba(47,138,224,.25); }
|
||||
.btn-primary:hover { background: var(--primary-hover); }
|
||||
.btn-danger { background: var(--danger); color: white; }
|
||||
.btn-danger:hover { background: var(--danger-hover); }
|
||||
.btn-ghost { background: var(--ghost); color: var(--text); border: 1px solid var(--border); }
|
||||
.btn-ghost:hover { background: var(--ghost-hover); }
|
||||
|
||||
/* Auto-apply hot button (unused in current UI, kept for future) */
|
||||
.btn-hot { background: linear-gradient(135deg, #ff7a85 0%, #e14a56 100%); color: white;
|
||||
box-shadow: 0 2px 6px rgba(225, 74, 86, 0.35); font-weight: 600; }
|
||||
.btn-hot:hover { filter: brightness(1.05); }
|
||||
.btn-hot.off { background: linear-gradient(135deg, #cfd9e6 0%, #99abc2 100%);
|
||||
box-shadow: 0 1px 2px rgba(16, 37, 63, 0.15); }
|
||||
|
||||
/* Partner-Aktionsbadge — kleiner Kreis oben rechts am Button */
|
||||
.btn-with-badge { position: relative; display: inline-block; }
|
||||
.partner-badge {
|
||||
position: absolute; top: -6px; right: -6px;
|
||||
width: 18px; height: 18px; border-radius: 9999px;
|
||||
background: var(--primary); color: #fff;
|
||||
font-size: 10px; font-weight: 700; line-height: 1;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 1px 2px rgba(16,37,63,.25);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.input { background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 0.55rem 0.8rem; width: 100%; color: var(--text);
|
||||
transition: border-color .15s, box-shadow .15s; }
|
||||
.input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47,138,224,.18); }
|
||||
|
||||
/* Chips */
|
||||
.chip { padding: .2rem .7rem; border-radius: 999px; font-size: .75rem; font-weight: 500; display: inline-block; }
|
||||
.chip-ok { background: #e4f6ec; color: #1f8a4a; border: 1px solid #b7e4c7; }
|
||||
.chip-warn { background: #fff4dd; color: #a36a1f; border: 1px solid #f5d48b; }
|
||||
.chip-bad { background: #fde6e9; color: #b8404e; border: 1px solid #f5b5bf; }
|
||||
.chip-info { background: #e3effc; color: #1f5f99; border: 1px solid #b6d4f0; }
|
||||
|
||||
/* Countdown — tabular digits + fixed width so the text doesn't wobble
|
||||
while the seconds tick down. */
|
||||
.countdown { font-variant-numeric: tabular-nums; display: inline-block;
|
||||
min-width: 4.2em; text-align: left; }
|
||||
.sep { color: var(--muted); user-select: none; }
|
||||
|
||||
/* iOS-style toggle switch */
|
||||
.switch { position: relative; display: inline-block; width: 46px; height: 26px; flex-shrink: 0; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; position: absolute; }
|
||||
.switch-visual { position: absolute; cursor: pointer; inset: 0;
|
||||
background: #cfd9e6; border-radius: 999px; transition: background .2s; }
|
||||
.switch-visual::before { content: ""; position: absolute; width: 20px; height: 20px;
|
||||
left: 3px; top: 3px; background: #fff; border-radius: 50%;
|
||||
box-shadow: 0 1px 3px rgba(16,37,63,0.25); transition: transform .2s; }
|
||||
.switch input:checked + .switch-visual { background: var(--primary); }
|
||||
.switch input:checked + .switch-visual::before { transform: translateX(20px); }
|
||||
.switch.warn input:checked + .switch-visual { background: var(--danger); }
|
||||
.switch input:focus-visible + .switch-visual { box-shadow: 0 0 0 3px rgba(47,138,224,.25); }
|
||||
|
||||
/* View toggle (Liste / Karte) — segmented pill, CSS-only via :has() */
|
||||
.view-toggle { display: inline-flex; border: 1px solid var(--border); border-radius: 999px;
|
||||
overflow: hidden; background: var(--surface); font-size: 0.85rem; font-weight: 500; }
|
||||
.view-toggle label { padding: 0.35rem 0.95rem; cursor: pointer; user-select: none;
|
||||
color: var(--muted); transition: background .15s, color .15s; }
|
||||
.view-toggle input { position: absolute; opacity: 0; pointer-events: none; width: 0; height: 0; }
|
||||
.view-toggle label:hover { color: var(--text); background: var(--ghost); }
|
||||
.view-toggle label:has(input:checked) { background: var(--primary); color: #fff; }
|
||||
.view-map { display: none; }
|
||||
body:has(#v_map:checked) .view-list { display: none; }
|
||||
body:has(#v_map:checked) .view-map { display: block; }
|
||||
#flats-map { height: 520px; border-radius: 10px; }
|
||||
|
||||
/* Flat detail expand */
|
||||
.flat-row { border-top: 1px solid var(--border); }
|
||||
.flat-row:first-child { border-top: 0; }
|
||||
.flat-expand-btn { width: 1.75rem; height: 1.75rem; border-radius: 999px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 1px solid var(--border); background: var(--surface);
|
||||
color: var(--muted); cursor: pointer; transition: transform .2s, background .15s; }
|
||||
.flat-expand-btn:hover { background: var(--ghost); color: var(--text); }
|
||||
.flat-expand-btn.open { transform: rotate(180deg); }
|
||||
.flat-detail { background: #fafcfe; border-top: 1px solid var(--border); }
|
||||
.flat-detail:empty { display: none; }
|
||||
|
||||
/* Normalised image gallery — every tile has the same aspect ratio */
|
||||
.flat-gallery { display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 8px; }
|
||||
.flat-gallery-tile { aspect-ratio: 4 / 3; overflow: hidden;
|
||||
border-radius: 8px; border: 1px solid var(--border);
|
||||
background: #f0f5fa; display: block; }
|
||||
.flat-gallery-tile img { width: 100%; height: 100%; object-fit: cover;
|
||||
display: block; transition: transform .3s; }
|
||||
.flat-gallery-tile:hover img { transform: scale(1.04); }
|
||||
|
||||
/* Leaflet popup — match site visual */
|
||||
.leaflet-popup-content-wrapper { border-radius: 12px; box-shadow: 0 6px 20px rgba(16,37,63,.15); }
|
||||
.leaflet-popup-content { margin: 12px 14px; min-width: 220px; color: var(--text); }
|
||||
.map-popup-title { font-weight: 600; font-size: 13px; display: inline-block; color: var(--primary); }
|
||||
.map-popup-title:hover { text-decoration: underline; }
|
||||
.map-popup-meta { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||
.map-popup-status { margin-top: 8px; }
|
||||
.map-popup-actions { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.map-popup-actions .btn { padding: 0.35rem 0.7rem; font-size: 12px; }
|
||||
.map-popup-actions form { margin: 0; }
|
||||
|
||||
/* Brand avatar */
|
||||
.brand-dot {
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 10px;
|
||||
background: linear-gradient(135deg, #66b7f2 0%, #2f8ae0 60%, #fbd76b 100%);
|
||||
box-shadow: 0 1px 4px rgba(47, 138, 224, 0.35);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
overflow: hidden; flex-shrink: 0;
|
||||
}
|
||||
.brand-dot img { width: 88%; height: 88%; object-fit: contain; display: block; }
|
||||
|
||||
/* Anchors */
|
||||
a { color: var(--primary); }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* Tab nav */
|
||||
.tab { padding: 0.7rem 0.2rem; color: var(--muted); border-bottom: 2px solid transparent;
|
||||
margin-right: 1.5rem; font-weight: 500; }
|
||||
.tab.active { color: var(--text); border-color: var(--primary); }
|
||||
.tab:hover { color: var(--text); text-decoration: none; }
|
||||
|
||||
/* Mono / forensic JSON tree */
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; }
|
||||
details > summary { cursor: pointer; user-select: none; }
|
||||
details > summary::marker { color: var(--muted); }
|
||||
|
|
@ -158,8 +158,7 @@
|
|||
<input type="hidden" name="flat_id" value="{{ f.id }}">
|
||||
<button class="btn btn-primary text-sm" type="submit"
|
||||
{% if is_running %}disabled{% endif %}
|
||||
hx-confirm="Bewerbung für {{ (f.address or f.link)|e }} starten?"
|
||||
hx-on::before-request="this.disabled=true">
|
||||
hx-confirm="Bewerbung für {{ (f.address or f.link)|e }} starten?">
|
||||
Bewerben
|
||||
</button>
|
||||
{% if partner and f.id in partner.applied_flat_ids %}
|
||||
|
|
@ -176,8 +175,7 @@
|
|||
<input type="hidden" name="flat_id" value="{{ f.id }}">
|
||||
<button class="btn btn-ghost text-sm" type="submit"
|
||||
{% if is_running %}disabled{% endif %}
|
||||
hx-confirm="Ablehnen und aus der Liste entfernen?"
|
||||
hx-on::before-request="this.disabled=true">
|
||||
hx-confirm="Ablehnen und aus der Liste entfernen?">
|
||||
Ablehnen
|
||||
</button>
|
||||
{% if partner and f.id in partner.rejected_flat_ids %}
|
||||
|
|
|
|||
|
|
@ -12,162 +12,9 @@
|
|||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/app.js" defer></script>
|
||||
<script src="/static/map.js" defer></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-from: #e4f0fb; --bg-to: #f7fbfe;
|
||||
--surface: #ffffff; --border: #d8e6f3;
|
||||
--text: #10253f; --muted: #667d98;
|
||||
--primary: #2f8ae0; --primary-hover: #1f74c8;
|
||||
--danger: #e05a6a; --danger-hover: #c44a59;
|
||||
--ghost: #eaf2fb; --ghost-hover: #d5e5f4;
|
||||
--accent: #fbd76b;
|
||||
}
|
||||
html { color-scheme: light; }
|
||||
body {
|
||||
background: linear-gradient(180deg, var(--bg-from) 0%, var(--bg-to) 100%);
|
||||
background-attachment: fixed;
|
||||
color: var(--text);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Inter, sans-serif;
|
||||
}
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
|
||||
box-shadow: 0 1px 2px rgba(16, 37, 63, 0.04); }
|
||||
.border-soft { border-color: var(--border) !important; }
|
||||
.divide-soft > :not([hidden]) ~ :not([hidden]) { border-color: var(--border) !important; }
|
||||
.btn { border-radius: 9px; padding: 0.45rem 0.95rem; font-weight: 500;
|
||||
transition: background 0.15s, box-shadow 0.15s, transform 0.05s; display: inline-block; }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn:disabled, .btn[disabled] { opacity: .55; cursor: not-allowed; pointer-events: none; }
|
||||
|
||||
/* Partner-Aktionsbadge — kleiner Kreis mit dem Anfangsbuchstaben oben rechts am Button */
|
||||
.btn-with-badge { position: relative; display: inline-block; }
|
||||
.partner-badge {
|
||||
position: absolute; top: -6px; right: -6px;
|
||||
width: 18px; height: 18px; border-radius: 9999px;
|
||||
background: var(--primary); color: #fff;
|
||||
font-size: 10px; font-weight: 700; line-height: 1;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 1px 2px rgba(16,37,63,.25);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.btn-primary { background: var(--primary); color: white; box-shadow: 0 1px 2px rgba(47,138,224,.25); }
|
||||
.btn-primary:hover { background: var(--primary-hover); }
|
||||
.btn-danger { background: var(--danger); color: white; }
|
||||
.btn-danger:hover { background: var(--danger-hover); }
|
||||
.btn-ghost { background: var(--ghost); color: var(--text); border: 1px solid var(--border); }
|
||||
.btn-ghost:hover { background: var(--ghost-hover); }
|
||||
.input { background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 0.55rem 0.8rem; width: 100%; color: var(--text);
|
||||
transition: border-color .15s, box-shadow .15s; }
|
||||
.input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(47,138,224,.18); }
|
||||
.chip { padding: .2rem .7rem; border-radius: 999px; font-size: .75rem; font-weight: 500; display: inline-block; }
|
||||
/* Countdown — tabular digits + fixed width so the text doesn't wobble
|
||||
while the seconds tick down (3 → 10 → 59 → 1 min). */
|
||||
.countdown { font-variant-numeric: tabular-nums; display: inline-block;
|
||||
min-width: 4.2em; text-align: left; }
|
||||
/* Neutral separator in flex rows so the '·' has equal gap on both sides. */
|
||||
.sep { color: var(--muted); user-select: none; }
|
||||
.chip-ok { background: #e4f6ec; color: #1f8a4a; border: 1px solid #b7e4c7; }
|
||||
.chip-warn { background: #fff4dd; color: #a36a1f; border: 1px solid #f5d48b; }
|
||||
.chip-bad { background: #fde6e9; color: #b8404e; border: 1px solid #f5b5bf; }
|
||||
.chip-info { background: #e3effc; color: #1f5f99; border: 1px solid #b6d4f0; }
|
||||
/* iOS-style toggle switch */
|
||||
.switch { position: relative; display: inline-block; width: 46px; height: 26px;
|
||||
flex-shrink: 0; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; position: absolute; }
|
||||
.switch-visual { position: absolute; cursor: pointer; inset: 0;
|
||||
background: #cfd9e6; border-radius: 999px;
|
||||
transition: background .2s; }
|
||||
.switch-visual::before { content: ""; position: absolute; width: 20px; height: 20px;
|
||||
left: 3px; top: 3px; background: #fff; border-radius: 50%;
|
||||
box-shadow: 0 1px 3px rgba(16,37,63,0.25);
|
||||
transition: transform .2s; }
|
||||
.switch input:checked + .switch-visual { background: var(--primary); }
|
||||
.switch input:checked + .switch-visual::before { transform: translateX(20px); }
|
||||
.switch.warn input:checked + .switch-visual { background: var(--danger); }
|
||||
.switch input:focus-visible + .switch-visual { box-shadow: 0 0 0 3px rgba(47,138,224,.25); }
|
||||
|
||||
/* View toggle (Liste / Karte) — segmented pill, CSS-only via :has() */
|
||||
.view-toggle { display: inline-flex; border: 1px solid var(--border);
|
||||
border-radius: 999px; overflow: hidden; background: var(--surface);
|
||||
font-size: 0.85rem; font-weight: 500; }
|
||||
.view-toggle label { padding: 0.35rem 0.95rem; cursor: pointer; user-select: none;
|
||||
color: var(--muted); transition: background .15s, color .15s; }
|
||||
.view-toggle input { position: absolute; opacity: 0; pointer-events: none;
|
||||
width: 0; height: 0; }
|
||||
.view-toggle label:hover { color: var(--text); background: var(--ghost); }
|
||||
.view-toggle label:has(input:checked) { background: var(--primary); color: #fff; }
|
||||
.view-map { display: none; }
|
||||
body:has(#v_map:checked) .view-list { display: none; }
|
||||
body:has(#v_map:checked) .view-map { display: block; }
|
||||
#flats-map { height: 520px; border-radius: 10px; }
|
||||
|
||||
/* Flat detail expand */
|
||||
.flat-row { border-top: 1px solid var(--border); }
|
||||
.flat-row:first-child { border-top: 0; }
|
||||
.flat-expand-btn { width: 1.75rem; height: 1.75rem; border-radius: 999px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 1px solid var(--border); background: var(--surface);
|
||||
color: var(--muted); cursor: pointer; transition: transform .2s, background .15s; }
|
||||
.flat-expand-btn:hover { background: var(--ghost); color: var(--text); }
|
||||
.flat-expand-btn.open { transform: rotate(180deg); }
|
||||
.flat-detail { background: #fafcfe; border-top: 1px solid var(--border); }
|
||||
.flat-detail:empty { display: none; }
|
||||
|
||||
/* Normalised image gallery — every tile has the same aspect ratio */
|
||||
.flat-gallery { display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 8px; }
|
||||
.flat-gallery-tile { aspect-ratio: 4 / 3; overflow: hidden;
|
||||
border-radius: 8px; border: 1px solid var(--border);
|
||||
background: #f0f5fa; display: block; }
|
||||
.flat-gallery-tile img { width: 100%; height: 100%; object-fit: cover;
|
||||
display: block; transition: transform .3s; }
|
||||
.flat-gallery-tile:hover img { transform: scale(1.04); }
|
||||
|
||||
/* Leaflet popup — match site visual */
|
||||
.leaflet-popup-content-wrapper { border-radius: 12px; box-shadow: 0 6px 20px rgba(16,37,63,.15); }
|
||||
.leaflet-popup-content { margin: 12px 14px; min-width: 220px; color: var(--text); }
|
||||
.map-popup-title { font-weight: 600; font-size: 13px; display: inline-block; color: var(--primary); }
|
||||
.map-popup-title:hover { text-decoration: underline; }
|
||||
.map-popup-meta { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||
.map-popup-status { margin-top: 8px; }
|
||||
.map-popup-actions { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.map-popup-actions .btn { padding: 0.35rem 0.7rem; font-size: 12px; }
|
||||
.map-popup-actions form { margin: 0; }
|
||||
.brand-dot {
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 10px;
|
||||
background: linear-gradient(135deg, #66b7f2 0%, #2f8ae0 60%, #fbd76b 100%);
|
||||
box-shadow: 0 1px 4px rgba(47, 138, 224, 0.35);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
overflow: hidden; flex-shrink: 0;
|
||||
}
|
||||
.brand-dot img {
|
||||
width: 88%; height: 88%; object-fit: contain; display: block;
|
||||
}
|
||||
a { color: var(--primary); }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* tab nav */
|
||||
.tab { padding: 0.7rem 0.2rem; color: var(--muted); border-bottom: 2px solid transparent;
|
||||
margin-right: 1.5rem; font-weight: 500; }
|
||||
.tab.active { color: var(--text); border-color: var(--primary); }
|
||||
.tab:hover { color: var(--text); text-decoration: none; }
|
||||
|
||||
/* auto-apply hot button */
|
||||
.btn-hot { background: linear-gradient(135deg, #ff7a85 0%, #e14a56 100%); color: white;
|
||||
box-shadow: 0 2px 6px rgba(225, 74, 86, 0.35); font-weight: 600; }
|
||||
.btn-hot:hover { filter: brightness(1.05); }
|
||||
.btn-hot.off { background: linear-gradient(135deg, #cfd9e6 0%, #99abc2 100%);
|
||||
box-shadow: 0 1px 2px rgba(16, 37, 63, 0.15); }
|
||||
|
||||
/* forensic JSON tree */
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; }
|
||||
details > summary { cursor: pointer; user-select: none; }
|
||||
details > summary::marker { color: var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
{% block body %}{% endblock %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue