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>
99 lines
3.4 KiB
Python
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)
|