shinkan-jinkendo/backend/main.py
Lars bd5a409fa7
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 35s
Test Suite / playwright-tests (push) Successful in 1m18s
Test Suite / pytest-backend (pull_request) Successful in 38s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 13s
Test Suite / k6 /health Baseline (pull_request) Successful in 38s
Test Suite / playwright-tests (pull_request) Successful in 1m12s
Add Admin User Content Management Features
- Introduced a new admin user content management endpoint for superadmins, allowing for moderation of user-generated content.
- Updated the backend to include new API functions for retrieving, patching, and deleting user content items.
- Enhanced the frontend with a new Admin User Content page and navigation link for easy access to user content management.
- Updated access layer documentation to reflect the new endpoint and its exempt status.
- Incremented version to 0.8.191 and updated changelog to document these additions in admin functionality.
2026-06-06 17:53:25 +02:00

246 lines
8.8 KiB
Python

"""
Shinkan Jinkendo - Main Application Entry Point
Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung
"""
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
import os
import sys
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS
def _is_production_environment() -> bool:
return os.getenv("ENVIRONMENT", "development").strip().lower() in ("production", "prod")
def _public_openapi_enabled() -> bool:
return os.getenv("PUBLIC_OPENAPI", "").strip().lower() in ("1", "true", "yes")
def _health_ready_public_detail_enabled() -> bool:
"""In Prod standardmäßig keine Tabellen-/Migrations-Details (Information Disclosure)."""
return os.getenv("HEALTH_READY_PUBLIC_DETAIL", "").strip().lower() in ("1", "true", "yes")
# Run database migrations before API start — halbes Schema ist schlimmer als kein Start
# (run_migrations: pending *.sql in einer Transaktion pro Datei, Buchführung schema_migrations)
# Lokal ohne DB / nur Tests: SKIP_DB_MIGRATE=1
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"):
print("[SKIP_DB_MIGRATE] Migrationen uebersprungen (nur fuer Entwicklung ohne DB)")
else:
try:
import run_migrations
rc = run_migrations.main()
if rc != 0:
print(f"[FAIL] Datenbank-Migration fehlgeschlagen (Exit-Code {rc}). Start abgebrochen.")
sys.exit(1)
print("[OK] Database migrations completed")
except SystemExit:
raise
except Exception as e:
print(f"[FAIL] Migration-Laufzeitfehler: {e}")
sys.exit(1)
from routers.auth import limiter as auth_rate_limiter
# OpenAPI: in Produktion standardmäßig aus (Schema nicht öffentlich). Notfall: PUBLIC_OPENAPI=1
_expose_docs = (not _is_production_environment()) or _public_openapi_enabled()
_openapi_url = "/openapi.json" if _expose_docs else None
_docs_url = "/docs" if _expose_docs else None
_redoc_url = "/redoc" if _expose_docs else None
# Initialize FastAPI app
app = FastAPI(
title="Shinkan Jinkendo API",
description="Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung",
version=APP_VERSION,
openapi_url=_openapi_url,
docs_url=_docs_url,
redoc_url=_redoc_url,
)
# SlowAPI: Rate Limits auf /api/auth/* (Decorator in routers/auth.py)
app.state.limiter = auth_rate_limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# CORS — kommaseparierte Liste (z. B. https://dev.shinkan… und http://192.168.x.x:3098)
_cors_raw = os.getenv("ALLOWED_ORIGINS", "http://localhost:3098")
ALLOWED_ORIGINS = [o.strip() for o in _cors_raw.split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "X-Auth-Token", "X-Active-Club-Id"],
)
@app.middleware("http")
async def add_api_security_headers(request: Request, call_next):
"""Konsistente Basis-Header auch für rein JSON-Responses (MIME-Sniffing)."""
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
return response
# Version Endpoint (public, no auth)
@app.get("/api/version")
def get_version():
"""Get application version and build info"""
return {
"app_version": APP_VERSION,
"build_date": BUILD_DATE,
"backend_version": APP_VERSION,
"modules": MODULE_VERSIONS,
"db_schema_version": DB_SCHEMA_VERSION,
"environment": os.getenv("ENVIRONMENT", "development")
}
# Health Check
@app.get("/health")
def health_check():
"""Health check endpoint"""
return {"status": "healthy", "version": APP_VERSION}
@app.get("/api/health/ready")
def health_ready():
"""
Verbindung + Kern-Tabellen prüfen (ohne Login).
Nutzen bei Prod-Debugging: wenn schema_complete=false, Migrationen nicht vollständig.
"""
from db import get_db, get_cursor
REQUIRED = (
"schema_migrations",
"skills",
"skill_main_categories",
"skill_categories",
"maturity_models",
"sessions",
"club_members",
"club_member_roles",
)
tables: dict = {}
err: Optional[str] = None
try:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT 1")
for tbl in REQUIRED:
cur.execute(
"""
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = %s
)
""",
(tbl,),
)
row = cur.fetchone()
tables[tbl] = bool(next(iter(row.values()))) if row else False
migration_count = 0
if tables.get("schema_migrations"):
cur.execute("SELECT COUNT(*)::int FROM schema_migrations")
mig_row = cur.fetchone()
migration_count = (
int(list(mig_row.values())[0]) if mig_row is not None else 0
)
except Exception as e:
err = str(e)
migration_count = 0
complete = bool(err is None and all(tables.get(t) for t in REQUIRED))
body = {
"status": "ready" if complete else "degraded",
"database": err is None,
"detail": err,
"schema_complete": complete,
"tables": tables,
"schema_migrations_count": migration_count,
}
if _is_production_environment() and not _health_ready_public_detail_enabled():
return {
"status": body["status"],
"database": body["database"],
"schema_complete": body["schema_complete"],
}
return body
# Root Endpoint
@app.get("/")
def read_root():
"""Root endpoint - API info"""
out = {
"app": "Shinkan Jinkendo API",
"version": APP_VERSION,
"health": "/health",
}
if _expose_docs:
out["docs"] = "/docs"
return out
# Register routers
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, admin_user_content, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
app.include_router(auth.router)
app.include_router(profiles.router)
app.include_router(exercises.router)
app.include_router(exercise_progression_graphs.router)
app.include_router(clubs.router)
app.include_router(club_memberships.router)
app.include_router(club_join_requests.router)
app.include_router(admin_users.router)
app.include_router(admin_user_content.router)
app.include_router(platform_media_storage.router)
app.include_router(media_assets.router)
app.include_router(media_assets.admin_rights_router)
app.include_router(media_assets.admin_legal_hold_router)
app.include_router(skills.router)
app.include_router(skill_profiles.router)
app.include_router(training_planning.router)
app.include_router(planning_exercise_suggest.router)
app.include_router(dashboard.router)
app.include_router(training_modules.router)
app.include_router(training_framework_programs.router)
app.include_router(catalogs.router)
app.include_router(maturity_models.router)
app.include_router(matrix_stack_bundle.router)
app.include_router(matrix_editor.router)
app.include_router(import_wiki.router)
app.include_router(import_wiki_admin.router)
app.include_router(legal_documents.router)
app.include_router(content_reports.router)
app.include_router(ai_prompts_admin.router)
app.include_router(ai_skill_retrieval_admin.router)
app.include_router(exercise_enrichment_admin.router)
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).
# Notfall/Legacy: ALLOW_PUBLIC_MEDIA_STATIC=1 → wieder öffentlich unter /media/
_media_dir = os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media"))
Path(_media_dir).mkdir(parents=True, exist_ok=True)
if os.getenv("ALLOW_PUBLIC_MEDIA_STATIC", "").strip().lower() in ("1", "true", "yes"):
app.mount("/media", StaticFiles(directory=_media_dir), name="media")
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=True
)