lazyflat/web/app.py
EiSiMo f1e26b38d0 refactor: split web/app.py into routers
app.py was ~1300 lines with every route, helper, and middleware mixed
together. Split into:

- app.py (~100 lines): FastAPI bootstrap, lifespan, /health, security
  headers, Jinja filter registration, include_router calls
- common.py: shared helpers (templates, apply_client, base_context,
  _is_htmx, client_ip, require_internal, time helpers, filter helpers,
  apply-gate helpers, _kick_apply / _finish_apply_background,
  _bg_tasks, _spawn, _mask_secret, _has_running_application, BERLIN_TZ)
- routes/auth.py: /login (GET+POST), /logout
- routes/wohnungen.py: /, /partials/wohnungen, /partials/wohnung/{id},
  /flat-images/{slug}/{idx}, /actions/apply|reject|unreject|auto-apply|
  submit-forms|reset-circuit|filters|enrich-all|enrich-flat; owns
  _wohnungen_context + _wohnungen_partial_or_redirect
- routes/bewerbungen.py: /bewerbungen, /bewerbungen/{id}/report.zip
- routes/einstellungen.py: /einstellungen, /einstellungen/{section},
  /actions/profile|notifications|account/password|partner/*; owns
  VALID_SECTIONS
- routes/admin.py: /logs redirect, /admin, /admin/{section},
  /logs/export.csv, /actions/users/*|secrets; owns ADMIN_SECTIONS,
  _parse_date_range, _collect_events
- routes/internal.py: /internal/flats|heartbeat|error|secrets

Route-diff before/after is empty — all 41 routes + /static mount
preserved. No behavior changes, pure mechanical split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:27:12 +02:00

99 lines
3.4 KiB
Python

"""
wohnungsdidi web app.
Tabs:
- / → Wohnungen
- /bewerbungen → Bewerbungen (history + forensics ZIP for failed runs)
- /einstellungen/<section> → Einstellungen: profil | filter | benachrichtigungen | account
- /admin/<section> → Admin-only: protokoll | benutzer
All state-changing POSTs require CSRF. Internal endpoints require INTERNAL_API_KEY.
This module is intentionally thin: lifespan, middleware, template filters, and
`include_router` calls. All business logic lives in `common.py` and
`routes/*.py`.
"""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Response
from fastapi.staticfiles import StaticFiles
import db
import enrichment
import retention
from auth import bootstrap_admin
from common import _de_dt, _iso_utc, logger, templates
from routes.admin import router as admin_router
from routes.auth import router as auth_router
from routes.bewerbungen import router as bewerbungen_router
from routes.einstellungen import router as einstellungen_router
from routes.internal import router as internal_router
from routes.wohnungen import router as wohnungen_router
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-5s %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
logging.getLogger("urllib3").setLevel(logging.WARNING)
# ---------------------------------------------------------------------------
# App + Jinja
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(_app: FastAPI):
db.init_db()
db.seed_secrets_from_env()
bootstrap_admin()
retention.start()
logger.info("web service ready")
yield
app = FastAPI(lifespan=lifespan, title="wohnungsdidi", docs_url=None, redoc_url=None, openapi_url=None)
app.mount("/static", StaticFiles(directory="static"), name="static")
# Template filters are registered on the shared Jinja environment (owned by
# common.templates) so every router that renders a template sees them.
templates.env.filters["de_dt"] = _de_dt
templates.env.filters["iso_utc"] = _iso_utc
templates.env.filters["flat_slug"] = lambda s: enrichment.flat_slug(str(s or ""))
@app.middleware("http")
async def security_headers(request: Request, call_next):
resp: Response = await call_next(request)
resp.headers.setdefault("X-Frame-Options", "DENY")
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
resp.headers.setdefault("Permissions-Policy", "geolocation=(), camera=(), microphone=()")
resp.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; "
"script-src 'self' https://cdn.tailwindcss.com https://unpkg.com; "
"style-src 'self' https://cdn.tailwindcss.com https://unpkg.com 'unsafe-inline'; "
"img-src 'self' data: "
"https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com "
"https://unpkg.com; "
"connect-src 'self'; frame-ancestors 'none';"
)
return resp
@app.get("/health")
def health():
return {"status": "ok"}
# Routers are registered without prefixes — paths stay absolute.
app.include_router(auth_router)
app.include_router(wohnungen_router)
app.include_router(bewerbungen_router)
app.include_router(einstellungen_router)
app.include_router(admin_router)
app.include_router(internal_router)