feat: Initial Shinkan setup
- Repository structure created - Core backend files from Mitai (auth, db, db_init) - Shinkan-specific: version.py, models.py, main.py - Documentation: CLAUDE.md, README.md - Environment: .env.example, .gitignore version: 0.1.0 date: 2026-04-21
This commit is contained in:
commit
a426c03598
25
.env.example
Normal file
25
.env.example
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Database
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_NAME=shinkan
|
||||
DB_USER=shinkan_user
|
||||
DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD
|
||||
|
||||
# OpenRouter (KI - optional)
|
||||
OPENROUTER_API_KEY=your_api_key_here
|
||||
OPENROUTER_MODEL=anthropic/claude-sonnet-4
|
||||
|
||||
# SMTP (E-Mail)
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=noreply@jinkendo.de
|
||||
SMTP_PASS=your_smtp_password
|
||||
SMTP_FROM=noreply@jinkendo.de
|
||||
|
||||
# App
|
||||
APP_URL=https://shinkan.jinkendo.de
|
||||
ALLOWED_ORIGINS=https://shinkan.jinkendo.de
|
||||
ENVIRONMENT=production
|
||||
|
||||
# Media Storage
|
||||
MEDIA_DIR=/app/media
|
||||
76
.gitignore
vendored
Normal file
76
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Build output
|
||||
frontend/dist/
|
||||
|
||||
# Data (NEVER commit database or user data)
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
data/
|
||||
photos/
|
||||
uploads/
|
||||
media/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# IDE
|
||||
.vscode/settings.json
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Docker overrides
|
||||
docker-compose.override.yml
|
||||
|
||||
# SSL certificates (never commit)
|
||||
nginx/ssl/
|
||||
nginx/certbot/
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.csr
|
||||
|
||||
# Pytest
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
coverage/
|
||||
|
||||
# Temp
|
||||
tmp/
|
||||
*.tmp
|
||||
|
||||
# Claude: nur ausgewählte Bereiche versionieren
|
||||
.claude/**
|
||||
!.claude/README.md
|
||||
!.claude/docs/
|
||||
!.claude/docs/**/*
|
||||
!.claude/rules/
|
||||
!.claude/rules/**/*
|
||||
!.claude/commands/
|
||||
!.claude/commands/**/*
|
||||
.claude/settings.local.json
|
||||
|
||||
# Cursor MCP
|
||||
.cursor/mcp.json
|
||||
frontend/package-lock.json
|
||||
289
CLAUDE.md
Normal file
289
CLAUDE.md
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
# Shinkan Jinkendo – Entwickler-Kontext für Claude Code
|
||||
|
||||
## Pflicht-Lektüre für Claude Code
|
||||
|
||||
> VOR jeder Implementierung lesen:
|
||||
> | Architektur-Regeln | `.claude/rules/ARCHITECTURE.md` |
|
||||
> | Coding-Regeln | `.claude/rules/CODING_RULES.md` |
|
||||
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
|
||||
> | Setup-Dokument | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` |
|
||||
> | Anforderungen | `.claude/docs/functional/SHINKAN_REQUIREMENTS.md` |
|
||||
|
||||
## Projekt-Übersicht
|
||||
|
||||
**Shinkan Jinkendo** (真観 Jinkendo) – Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung.
|
||||
Teil der **Jinkendo**-App-Familie (人拳道). Domains: shinkan.jinkendo.de
|
||||
|
||||
**WICHTIG:** Shinkan ist KEINE persönliche Tracking-App!
|
||||
|
||||
**Fachlicher Fokus:**
|
||||
- Übungsverwaltung und -suche
|
||||
- Trainingsplanung für Gruppen
|
||||
- Fähigkeiten- und Methodenkataloge
|
||||
- Standardisierung und Wiederverwendung
|
||||
- Freigabe und Governance von Inhalten
|
||||
|
||||
**Primäre Nutzer:** Trainer, Vereinsadmins, Redakteure
|
||||
**Nicht in MVP:** Individuelle Sportler, persönliches Tracking
|
||||
|
||||
## Tech-Stack
|
||||
|
||||
| Komponente | Technologie |
|
||||
|-----------|-------------|
|
||||
| Frontend | React 18 + Vite + PWA (Node 20) |
|
||||
| Backend | FastAPI Python 3.12 |
|
||||
| Datenbank | PostgreSQL 16 Alpine |
|
||||
| Container | Docker + Docker Compose |
|
||||
| Auth | Token-basiert + bcrypt |
|
||||
| KI | OpenRouter API (optional, nicht MVP-kritisch) |
|
||||
|
||||
**Ports:** Prod 3003/8003 · Dev 3098/8098 – nie ändern!
|
||||
|
||||
## Verzeichnisstruktur
|
||||
|
||||
```
|
||||
backend/
|
||||
├── main.py # App-Setup + Router-Registration
|
||||
├── db.py # PostgreSQL Connection Pool (von Mitai)
|
||||
├── db_init.py # DB-Init + Migrations-System (von Mitai)
|
||||
├── auth.py # Hash, Verify, Sessions (von Mitai)
|
||||
├── models.py # Pydantic Models
|
||||
├── version.py # Versionskontrolle
|
||||
├── migrations/ # SQL-Migrationen (XXX_*.sql Pattern)
|
||||
└── routers/ # Router-Module
|
||||
auth · profiles · clubs · groups · skills · methods
|
||||
exercises · training_units · training_programs
|
||||
planning · import_wiki · admin · membership
|
||||
|
||||
frontend/src/
|
||||
├── App.jsx # Root, Auth-Gates, Navigation
|
||||
├── app.css # CSS-Variablen + globale Styles
|
||||
├── config/ # appNav.js · adminNav.js
|
||||
├── layouts/ # AdminShell · RequireAdmin
|
||||
├── context/ # AuthContext · ProfileContext
|
||||
├── pages/ # Alle Screens
|
||||
└── utils/
|
||||
api.js # ALLE API-Calls
|
||||
|
||||
.claude/
|
||||
├── commands/ # Slash-Commands
|
||||
├── docs/
|
||||
│ ├── functional/ # Fachliche Specs
|
||||
│ ├── technical/ # Technische Specs
|
||||
│ └── rules/ # Verbindliche Regeln
|
||||
└── library/ # Auto-generierte Docs
|
||||
```
|
||||
|
||||
## Aktuelle Version: v0.1.0 (Initial Setup)
|
||||
|
||||
**Status:** Initial Setup in Arbeit
|
||||
**Branch:** develop
|
||||
**Nächster Schritt:** Basis-Migrationen + Core-Router
|
||||
|
||||
### Updates (21.04.2026 - Initial Setup)
|
||||
|
||||
- **Repository:** Erstellt auf Gitea
|
||||
- **Basis-Struktur:** Verzeichnisse angelegt
|
||||
- **Von Mitai übernommen:** auth.py, db.py, db_init.py
|
||||
- **Eigene Dateien:** version.py, CLAUDE.md
|
||||
|
||||
## Domänenmodell (MVP Core)
|
||||
|
||||
### Kern-Objekte
|
||||
|
||||
**Organisation:**
|
||||
- `clubs` - Vereine
|
||||
- `divisions` - Sparten (optional)
|
||||
- `training_groups` - Trainingsgruppen
|
||||
|
||||
**Kataloge:**
|
||||
- `skills` - Fähigkeiten (global)
|
||||
- `training_methods` - Trainingsmethoden (global)
|
||||
|
||||
**Übungen:**
|
||||
- `exercises` - Übungen (Kernobjekt)
|
||||
- `exercise_variants` - Übungsvarianten
|
||||
- `exercise_skills` - M:N Übung ↔ Fähigkeit
|
||||
- `exercise_media` - Medien (Bilder, Videos)
|
||||
|
||||
**Trainingsplanung:**
|
||||
- `training_templates` - Vorlagen / Standards
|
||||
- `training_sections` - Trainingsabschnitte
|
||||
- `section_exercises` - Übungen in Abschnitten
|
||||
- `training_units` - Konkrete Trainingseinheiten
|
||||
- `training_programs` - Trainingsprogramme
|
||||
|
||||
**Governance:**
|
||||
- `content_change_requests` - Änderungsanfragen
|
||||
|
||||
**Import:**
|
||||
- `wiki_import_log` - Import-Tracking
|
||||
- `wiki_import_references` - Duplikat-Erkennung
|
||||
|
||||
## Deployment
|
||||
|
||||
```
|
||||
Internet → Fritz!Box (privat.stommer.com) → Synology NAS → Raspberry Pi 5 (192.168.2.49)
|
||||
|
||||
Git Workflow:
|
||||
develop → Auto-Deploy → dev.shinkan.jinkendo.de (shinkan-dev/, Port 3098/8098)
|
||||
main → Auto-Deploy → shinkan.jinkendo.de (shinkan/, Port 3003/8003)
|
||||
|
||||
Gitea: http://192.168.2.144:3000/Lars/shinkan-jinkendo
|
||||
Runner: Raspberry Pi (/home/lars/gitea-runner/)
|
||||
|
||||
Manuell:
|
||||
cd /home/lars/docker/shinkan[-dev]
|
||||
docker compose -f docker-compose[.dev-env].yml build --no-cache && up -d
|
||||
|
||||
Migrations:
|
||||
Werden automatisch beim Container-Start ausgeführt (db_init.py)
|
||||
Nur nummerierte Dateien: backend/migrations/XXX_*.sql
|
||||
```
|
||||
|
||||
## Datenbank-Schema (PostgreSQL 16)
|
||||
|
||||
```
|
||||
-- Von Mitai übernommen
|
||||
profiles – Nutzer (role, pin_hash/bcrypt, email, tier)
|
||||
sessions – Auth-Tokens
|
||||
features – Feature-Definitionen
|
||||
tier_limits – Limits pro Tier
|
||||
subscriptions – Nutzer-Subscriptions
|
||||
|
||||
-- Shinkan-spezifisch
|
||||
clubs – Vereine
|
||||
divisions – Sparten
|
||||
training_groups – Trainingsgruppen
|
||||
skills – Fähigkeiten-Katalog
|
||||
training_methods – Methoden-Katalog
|
||||
exercises – Übungen (Kernobjekt)
|
||||
exercise_variants – Übungsvarianten
|
||||
exercise_skills – M:N Übung ↔ Fähigkeit
|
||||
exercise_media – Medien
|
||||
training_templates – Vorlagen / Standards
|
||||
training_sections – Trainingsabschnitte
|
||||
section_exercises – Übungen in Abschnitten
|
||||
training_units – Trainingseinheiten
|
||||
training_programs – Trainingsprogramme
|
||||
program_units – Programm-Einheiten
|
||||
content_change_requests – Änderungsanfragen
|
||||
wiki_import_log – Import-Tracking
|
||||
wiki_import_references – Duplikat-Erkennung
|
||||
|
||||
Schema-Datei: backend/schema.sql (später)
|
||||
Migrationen: backend/migrations/*.sql (automatisch beim Start)
|
||||
```
|
||||
|
||||
## API & Auth
|
||||
|
||||
```
|
||||
Alle Endpoints: /api/...
|
||||
Auth-Header: X-Auth-Token: <token>
|
||||
Fehler: {"detail": "Fehlermeldung"}
|
||||
|
||||
Auth-Flow:
|
||||
Login → E-Mail + Passwort → Token in localStorage
|
||||
Token → X-Auth-Token Header → require_auth()
|
||||
Profile-Id → immer aus Session, nie aus Header!
|
||||
```
|
||||
|
||||
## Umgebungsvariablen (.env)
|
||||
|
||||
```
|
||||
DB_HOST/PORT/NAME/USER/PASSWORD # PostgreSQL
|
||||
OPENROUTER_API_KEY # KI (optional)
|
||||
OPENROUTER_MODEL=anthropic/claude-sonnet-4
|
||||
SMTP_HOST/PORT/USER/PASS/FROM # E-Mail
|
||||
APP_URL=https://shinkan.jinkendo.de
|
||||
ALLOWED_ORIGINS=https://shinkan.jinkendo.de
|
||||
MEDIA_DIR=/app/media
|
||||
```
|
||||
|
||||
## Kritische Regeln für Claude Code
|
||||
|
||||
### Must-Do:
|
||||
1. `api.js` für ALLE API-Calls nutzen – nie direktes `fetch()` ohne Token
|
||||
2. `session: dict = Depends(require_auth)` als **separater** Parameter
|
||||
3. `bcrypt` für alle Passwort-Operationen
|
||||
4. Neue DB-Spalten nur via Schema-Migration
|
||||
5. `npm install` (nicht npm ci) – kein package-lock.json
|
||||
|
||||
### Bekannte Fallstricke:
|
||||
```python
|
||||
# ❌ FALSCH – führt zu ungeschütztem Endpoint:
|
||||
def endpoint(x: str = Header(default=None, session=Depends(require_auth))):
|
||||
|
||||
# ✅ RICHTIG:
|
||||
def endpoint(x: str = Header(default=None), session: dict = Depends(require_auth)):
|
||||
```
|
||||
|
||||
```python
|
||||
# PostgreSQL Boolean (nicht SQLite 0/1):
|
||||
WHERE active = true # ✅
|
||||
WHERE active = 1 # ❌
|
||||
```
|
||||
|
||||
## Design-System (Kurzreferenz)
|
||||
|
||||
```css
|
||||
/* Farben */
|
||||
--accent: #1D9E75 --accent-dark: #085041 --danger: #D85A30
|
||||
--bg · --surface · --surface2 · --border · --text1 · --text2 · --text3
|
||||
|
||||
/* Klassen */
|
||||
.card · .btn · .btn-primary · .btn-secondary · .btn-full
|
||||
.form-input · .form-label · .form-row · .spinner
|
||||
|
||||
/* Abstände */
|
||||
Seiten-Padding: 16px · Card-Padding: 16-20px · Border-Radius: 12px/8px
|
||||
Bottom-Padding Mobile: 80px (Navigation)
|
||||
```
|
||||
|
||||
## Dokumentations-Struktur
|
||||
|
||||
```
|
||||
.claude/
|
||||
├── docs/
|
||||
│ ├── functional/ ← Fachliche Specs (WAS soll gebaut werden)
|
||||
│ ├── technical/ ← Technische Specs (WIE wird es gebaut)
|
||||
│ ├── working/ ← Arbeitspapiere / Analysen
|
||||
│ └── rules/ ← Verbindliche Regeln
|
||||
└── library/ ← Ergebnis-Dokumentation (WAS wurde gebaut)
|
||||
```
|
||||
|
||||
## Abgrenzung zu Mitai
|
||||
|
||||
**Mitai (身体):**
|
||||
- Persönliches Körper- und Trainings-Tracking
|
||||
- Gewicht, Umfänge, Ernährung, Aktivität
|
||||
- Individuelle Ziele und Fortschritte
|
||||
- KI-Analysen für persönliche Entwicklung
|
||||
|
||||
**Shinkan (真観):**
|
||||
- Trainer- und Vereinsarbeit
|
||||
- Übungsverwaltung und -suche
|
||||
- Trainingsplanung für Gruppen
|
||||
- Fähigkeiten- und Methodenkataloge
|
||||
- Standardisierung und Wiederverwendung
|
||||
|
||||
**Technisch gemeinsam:**
|
||||
- Auth-System
|
||||
- Membership-Basis
|
||||
- Design-System
|
||||
- Docker/Deployment-Infrastruktur
|
||||
|
||||
## Jinkendo App-Familie
|
||||
|
||||
```
|
||||
mitai.jinkendo.de → Körper-Tracker (身体)
|
||||
shinkan.jinkendo.de → Trainingsplanung (真観)
|
||||
miken.jinkendo.de → Meditation (眉間)
|
||||
ikigai.jinkendo.de → Lebenssinn (生き甲斐)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Version:** 0.1.0
|
||||
**Stand:** 21.04.2026
|
||||
**Autor:** Claude Code
|
||||
117
README.md
Normal file
117
README.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Shinkan Jinkendo (真観)
|
||||
|
||||
**Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung**
|
||||
|
||||
Teil der Jinkendo-App-Familie (人拳道)
|
||||
|
||||
## Was ist Shinkan?
|
||||
|
||||
Shinkan ist eine moderne Web- und Mobile-App für Kampfsport-Trainer und Vereine. Im Fokus stehen:
|
||||
|
||||
- **Übungsverwaltung:** Zentrale Übungsbibliothek mit Suche und Filter
|
||||
- **Trainingsplanung:** Effiziente Planung für Gruppen und Termine
|
||||
- **Kataloge:** Fähigkeiten und Trainingsmethoden strukturiert verwalten
|
||||
- **Standardisierung:** Vereinsstandards und wiederverwendbare Vorlagen
|
||||
- **Freigabe:** Gesteuerte Veröffentlichung von Inhalten
|
||||
|
||||
## Nicht in Shinkan
|
||||
|
||||
- Kein persönliches Sportler-Tracking (dafür: Mitai Jinkendo)
|
||||
- Kein Gürtel-Tracking im MVP
|
||||
- Keine individuellen Technik-Fortschritte im MVP
|
||||
|
||||
## Tech-Stack
|
||||
|
||||
- **Frontend:** React 18 + Vite + PWA
|
||||
- **Backend:** FastAPI (Python 3.12)
|
||||
- **Datenbank:** PostgreSQL 16
|
||||
- **Container:** Docker + Docker Compose
|
||||
- **Auth:** Token-basiert + bcrypt
|
||||
|
||||
## Installation (Lokal)
|
||||
|
||||
### Voraussetzungen
|
||||
- Node 20+
|
||||
- Python 3.12+
|
||||
- PostgreSQL 16
|
||||
- Docker + Docker Compose
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Repository clonen
|
||||
git clone http://192.168.2.144:3000/Lars/shinkan-jinkendo.git
|
||||
cd shinkan-jinkendo
|
||||
|
||||
# Environment-Variablen
|
||||
cp .env.example .env
|
||||
# .env anpassen!
|
||||
|
||||
# Backend
|
||||
cd backend
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Frontend
|
||||
cd ../frontend
|
||||
npm install
|
||||
|
||||
# Datenbank
|
||||
docker compose up -d postgres
|
||||
|
||||
# Migrations (automatisch beim Start)
|
||||
cd ../backend
|
||||
python main.py
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Backend (Terminal 1)
|
||||
cd backend
|
||||
source venv/bin/activate
|
||||
uvicorn main:app --reload --port 8098
|
||||
|
||||
# Frontend (Terminal 2)
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Frontend: http://localhost:3098
|
||||
Backend: http://localhost:8098
|
||||
|
||||
### Docker (Empfohlen)
|
||||
|
||||
```bash
|
||||
# Development
|
||||
docker compose -f docker-compose.dev-env.yml up --build
|
||||
|
||||
# Production
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
**Production:** https://shinkan.jinkendo.de
|
||||
**Development:** https://dev.shinkan.jinkendo.de
|
||||
|
||||
Auto-Deploy via Gitea Actions:
|
||||
- `develop` → Dev-Umgebung
|
||||
- `main` → Prod-Umgebung
|
||||
|
||||
## Dokumentation
|
||||
|
||||
- **Setup:** `.claude/docs/working/SHINKAN_PROJECT_SETUP.md`
|
||||
- **Anforderungen:** `.claude/docs/functional/SHINKAN_REQUIREMENTS.md`
|
||||
- **Architektur:** `.claude/rules/ARCHITECTURE.md`
|
||||
|
||||
## Lizenz
|
||||
|
||||
Proprietary – Lars Stommer
|
||||
|
||||
## Kontakt
|
||||
|
||||
- **Entwickler:** Lars Stommer
|
||||
- **E-Mail:** stommer@gmail.com
|
||||
- **Gitea:** http://192.168.2.144:3000/Lars/shinkan-jinkendo
|
||||
379
backend/auth.py
Normal file
379
backend/auth.py
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
"""
|
||||
Authentication and Authorization for Mitai Jinkendo
|
||||
|
||||
Provides password hashing, session management, and auth dependencies
|
||||
for FastAPI endpoints.
|
||||
"""
|
||||
import hashlib
|
||||
import secrets
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import Header, Query, HTTPException
|
||||
import bcrypt
|
||||
|
||||
from db import get_db, get_cursor
|
||||
|
||||
print("[AUTH.PY] Module loaded - require_auth_flexible will be defined")
|
||||
|
||||
|
||||
def hash_pin(pin: str) -> str:
|
||||
"""Hash password with bcrypt. Falls back gracefully from legacy SHA256."""
|
||||
return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_pin(pin: str, stored_hash: str) -> bool:
|
||||
"""Verify password - supports both bcrypt and legacy SHA256."""
|
||||
if not stored_hash:
|
||||
return False
|
||||
# Detect bcrypt hash (starts with $2b$ or $2a$)
|
||||
if stored_hash.startswith('$2'):
|
||||
try:
|
||||
return bcrypt.checkpw(pin.encode(), stored_hash.encode())
|
||||
except Exception:
|
||||
return False
|
||||
# Legacy SHA256 support (auto-upgrade to bcrypt on next login)
|
||||
return stored_hash == hashlib.sha256(pin.encode()).hexdigest()
|
||||
|
||||
|
||||
def make_token() -> str:
|
||||
"""Generate a secure random token for sessions."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def get_session(token: str):
|
||||
"""
|
||||
Get session data for a given token.
|
||||
|
||||
Returns session dict with profile info, or None if invalid/expired.
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"SELECT s.*, p.role, p.name, p.ai_enabled, p.ai_limit_day, p.export_enabled "
|
||||
"FROM sessions s JOIN profiles p ON s.profile_id=p.id "
|
||||
"WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP",
|
||||
(token,)
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def require_auth(x_auth_token: Optional[str] = Header(default=None)):
|
||||
"""
|
||||
FastAPI dependency - requires valid authentication.
|
||||
|
||||
Usage:
|
||||
@app.get("/api/endpoint")
|
||||
def endpoint(session: dict = Depends(require_auth)):
|
||||
profile_id = session['profile_id']
|
||||
...
|
||||
|
||||
Raises:
|
||||
HTTPException 401 if not authenticated
|
||||
"""
|
||||
session = get_session(x_auth_token)
|
||||
if not session:
|
||||
raise HTTPException(401, "Nicht eingeloggt")
|
||||
return session
|
||||
|
||||
|
||||
def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), ssetoken: Optional[str] = Query(default=None)):
|
||||
"""
|
||||
FastAPI dependency - auth via header OR query parameter.
|
||||
|
||||
Used for endpoints accessed by <img> tags and SSE connections that can't send headers.
|
||||
Query parameter is 'ssetoken' to avoid conflicts with endpoint 'token' parameters.
|
||||
|
||||
Usage:
|
||||
@app.get("/api/photos/{id}")
|
||||
def get_photo(id: str, session: dict = Depends(require_auth_flexible)):
|
||||
...
|
||||
|
||||
Call with: ?ssetoken=XXX or Header: X-Auth-Token: XXX
|
||||
|
||||
Raises:
|
||||
HTTPException 401 if not authenticated
|
||||
"""
|
||||
session = get_session(x_auth_token or ssetoken)
|
||||
if not session:
|
||||
raise HTTPException(401, "Nicht eingeloggt")
|
||||
return session
|
||||
|
||||
|
||||
def require_admin(x_auth_token: Optional[str] = Header(default=None)):
|
||||
"""
|
||||
FastAPI dependency - requires admin authentication.
|
||||
|
||||
Usage:
|
||||
@app.put("/api/admin/endpoint")
|
||||
def admin_endpoint(session: dict = Depends(require_admin)):
|
||||
...
|
||||
|
||||
Raises:
|
||||
HTTPException 401 if not authenticated
|
||||
HTTPException 403 if not admin
|
||||
"""
|
||||
session = get_session(x_auth_token)
|
||||
if not session:
|
||||
raise HTTPException(401, "Nicht eingeloggt")
|
||||
if session['role'] != 'admin':
|
||||
raise HTTPException(403, "Nur für Admins")
|
||||
return session
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Feature Access Control (v9c)
|
||||
# ============================================================================
|
||||
|
||||
def get_effective_tier(profile_id: str, conn=None) -> str:
|
||||
"""
|
||||
Get the effective tier for a profile.
|
||||
|
||||
Checks for active access_grants first (from coupons, trials, etc.),
|
||||
then falls back to profile.tier.
|
||||
|
||||
Args:
|
||||
profile_id: User profile ID
|
||||
conn: Optional existing DB connection (to avoid pool exhaustion)
|
||||
|
||||
Returns:
|
||||
tier_id (str): 'free', 'basic', 'premium', or 'selfhosted'
|
||||
"""
|
||||
# Use existing connection if provided, otherwise open new one
|
||||
if conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Check for active access grants (highest priority)
|
||||
cur.execute("""
|
||||
SELECT tier_id
|
||||
FROM access_grants
|
||||
WHERE profile_id = %s
|
||||
AND is_active = true
|
||||
AND valid_from <= CURRENT_TIMESTAMP
|
||||
AND valid_until > CURRENT_TIMESTAMP
|
||||
ORDER BY valid_until DESC
|
||||
LIMIT 1
|
||||
""", (profile_id,))
|
||||
|
||||
grant = cur.fetchone()
|
||||
if grant:
|
||||
return grant['tier_id']
|
||||
|
||||
# Fall back to profile tier
|
||||
cur.execute("SELECT tier FROM profiles WHERE id = %s", (profile_id,))
|
||||
profile = cur.fetchone()
|
||||
return profile['tier'] if profile else 'free'
|
||||
else:
|
||||
# Open new connection if none provided
|
||||
with get_db() as conn:
|
||||
return get_effective_tier(profile_id, conn)
|
||||
|
||||
|
||||
def check_feature_access(profile_id: str, feature_id: str, conn=None) -> dict:
|
||||
"""
|
||||
Check if a profile has access to a feature.
|
||||
|
||||
Access hierarchy:
|
||||
1. User-specific restriction (user_feature_restrictions)
|
||||
2. Tier limit (tier_limits)
|
||||
3. Feature default (features.default_limit)
|
||||
|
||||
Args:
|
||||
profile_id: User profile ID
|
||||
feature_id: Feature ID to check
|
||||
conn: Optional existing DB connection (to avoid pool exhaustion)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'allowed': bool,
|
||||
'limit': int | None, # NULL = unlimited
|
||||
'used': int,
|
||||
'remaining': int | None, # NULL = unlimited
|
||||
'reason': str # 'unlimited', 'within_limit', 'limit_exceeded', 'feature_disabled'
|
||||
}
|
||||
"""
|
||||
# Use existing connection if provided
|
||||
if conn:
|
||||
return _check_impl(profile_id, feature_id, conn)
|
||||
else:
|
||||
with get_db() as conn:
|
||||
return _check_impl(profile_id, feature_id, conn)
|
||||
|
||||
|
||||
def _check_impl(profile_id: str, feature_id: str, conn) -> dict:
|
||||
"""Internal implementation of check_feature_access."""
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Get feature info
|
||||
cur.execute("""
|
||||
SELECT limit_type, reset_period, default_limit
|
||||
FROM features
|
||||
WHERE id = %s AND active = true
|
||||
""", (feature_id,))
|
||||
feature = cur.fetchone()
|
||||
|
||||
if not feature:
|
||||
return {
|
||||
'allowed': False,
|
||||
'limit': None,
|
||||
'used': 0,
|
||||
'remaining': None,
|
||||
'reason': 'feature_not_found'
|
||||
}
|
||||
|
||||
# Priority 1: Check user-specific restriction
|
||||
cur.execute("""
|
||||
SELECT limit_value
|
||||
FROM user_feature_restrictions
|
||||
WHERE profile_id = %s AND feature_id = %s
|
||||
""", (profile_id, feature_id))
|
||||
restriction = cur.fetchone()
|
||||
|
||||
if restriction is not None:
|
||||
limit = restriction['limit_value']
|
||||
else:
|
||||
# Priority 2: Check tier limit
|
||||
tier_id = get_effective_tier(profile_id, conn)
|
||||
cur.execute("""
|
||||
SELECT limit_value
|
||||
FROM tier_limits
|
||||
WHERE tier_id = %s AND feature_id = %s
|
||||
""", (tier_id, feature_id))
|
||||
tier_limit = cur.fetchone()
|
||||
|
||||
if tier_limit is not None:
|
||||
limit = tier_limit['limit_value']
|
||||
else:
|
||||
# Priority 3: Feature default
|
||||
limit = feature['default_limit']
|
||||
|
||||
# For boolean features (limit 0 = disabled, 1 = enabled)
|
||||
if feature['limit_type'] == 'boolean':
|
||||
allowed = limit == 1
|
||||
return {
|
||||
'allowed': allowed,
|
||||
'limit': limit,
|
||||
'used': 0,
|
||||
'remaining': None,
|
||||
'reason': 'enabled' if allowed else 'feature_disabled'
|
||||
}
|
||||
|
||||
# For count-based features
|
||||
# Check current usage
|
||||
cur.execute("""
|
||||
SELECT usage_count, reset_at
|
||||
FROM user_feature_usage
|
||||
WHERE profile_id = %s AND feature_id = %s
|
||||
""", (profile_id, feature_id))
|
||||
usage = cur.fetchone()
|
||||
|
||||
used = usage['usage_count'] if usage else 0
|
||||
|
||||
# Check if reset is needed
|
||||
if usage and usage['reset_at'] and datetime.now() > usage['reset_at']:
|
||||
# Reset usage
|
||||
used = 0
|
||||
next_reset = _calculate_next_reset(feature['reset_period'])
|
||||
cur.execute("""
|
||||
UPDATE user_feature_usage
|
||||
SET usage_count = 0, reset_at = %s, updated = CURRENT_TIMESTAMP
|
||||
WHERE profile_id = %s AND feature_id = %s
|
||||
""", (next_reset, profile_id, feature_id))
|
||||
conn.commit()
|
||||
|
||||
# NULL limit = unlimited
|
||||
if limit is None:
|
||||
return {
|
||||
'allowed': True,
|
||||
'limit': None,
|
||||
'used': used,
|
||||
'remaining': None,
|
||||
'reason': 'unlimited'
|
||||
}
|
||||
|
||||
# 0 limit = disabled
|
||||
if limit == 0:
|
||||
return {
|
||||
'allowed': False,
|
||||
'limit': 0,
|
||||
'used': used,
|
||||
'remaining': 0,
|
||||
'reason': 'feature_disabled'
|
||||
}
|
||||
|
||||
# Check if within limit
|
||||
allowed = used < limit
|
||||
remaining = limit - used if limit else None
|
||||
|
||||
return {
|
||||
'allowed': allowed,
|
||||
'limit': limit,
|
||||
'used': used,
|
||||
'remaining': remaining,
|
||||
'reason': 'within_limit' if allowed else 'limit_exceeded'
|
||||
}
|
||||
|
||||
|
||||
def increment_feature_usage(profile_id: str, feature_id: str) -> None:
|
||||
"""
|
||||
Increment usage counter for a feature.
|
||||
|
||||
Creates usage record if it doesn't exist, with reset_at based on
|
||||
feature's reset_period.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Get feature reset period
|
||||
cur.execute("""
|
||||
SELECT reset_period
|
||||
FROM features
|
||||
WHERE id = %s
|
||||
""", (feature_id,))
|
||||
feature = cur.fetchone()
|
||||
|
||||
if not feature:
|
||||
return
|
||||
|
||||
reset_period = feature['reset_period']
|
||||
next_reset = _calculate_next_reset(reset_period)
|
||||
|
||||
# Upsert usage
|
||||
cur.execute("""
|
||||
INSERT INTO user_feature_usage (profile_id, feature_id, usage_count, reset_at)
|
||||
VALUES (%s, %s, 1, %s)
|
||||
ON CONFLICT (profile_id, feature_id)
|
||||
DO UPDATE SET
|
||||
usage_count = user_feature_usage.usage_count + 1,
|
||||
updated = CURRENT_TIMESTAMP
|
||||
""", (profile_id, feature_id, next_reset))
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _calculate_next_reset(reset_period: str) -> Optional[datetime]:
|
||||
"""
|
||||
Calculate next reset timestamp based on reset period.
|
||||
|
||||
Args:
|
||||
reset_period: 'never', 'daily', 'monthly'
|
||||
|
||||
Returns:
|
||||
datetime or None (for 'never')
|
||||
"""
|
||||
if reset_period == 'never':
|
||||
return None
|
||||
elif reset_period == 'daily':
|
||||
# Reset at midnight
|
||||
tomorrow = datetime.now().date() + timedelta(days=1)
|
||||
return datetime.combine(tomorrow, datetime.min.time())
|
||||
elif reset_period == 'monthly':
|
||||
# Reset at start of next month
|
||||
now = datetime.now()
|
||||
if now.month == 12:
|
||||
return datetime(now.year + 1, 1, 1)
|
||||
else:
|
||||
return datetime(now.year, now.month + 1, 1)
|
||||
else:
|
||||
return None
|
||||
197
backend/db.py
Normal file
197
backend/db.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""
|
||||
PostgreSQL Database Connector for Mitai Jinkendo (v9b)
|
||||
|
||||
Provides connection pooling and helper functions for database operations.
|
||||
Compatible drop-in replacement for the previous SQLite get_db() pattern.
|
||||
"""
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional, Dict, Any, List
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
import psycopg2.pool
|
||||
|
||||
|
||||
# Global connection pool
|
||||
_pool: Optional[psycopg2.pool.SimpleConnectionPool] = None
|
||||
|
||||
|
||||
def init_pool():
|
||||
"""Initialize PostgreSQL connection pool."""
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = psycopg2.pool.SimpleConnectionPool(
|
||||
minconn=1,
|
||||
maxconn=10,
|
||||
host=os.getenv("DB_HOST", "postgres"),
|
||||
port=int(os.getenv("DB_PORT", "5432")),
|
||||
database=os.getenv("DB_NAME", "mitai"),
|
||||
user=os.getenv("DB_USER", "mitai"),
|
||||
password=os.getenv("DB_PASSWORD", "")
|
||||
)
|
||||
print(
|
||||
f"[OK] PostgreSQL connection pool initialized ({os.getenv('DB_HOST', 'postgres')}:{os.getenv('DB_PORT', '5432')})"
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db():
|
||||
"""
|
||||
Context manager for database connections.
|
||||
|
||||
Usage:
|
||||
with get_db() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT * FROM profiles")
|
||||
rows = cur.fetchall()
|
||||
|
||||
Auto-commits on success, auto-rolls back on exception.
|
||||
"""
|
||||
if _pool is None:
|
||||
init_pool()
|
||||
|
||||
conn = _pool.getconn()
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
_pool.putconn(conn)
|
||||
|
||||
|
||||
def get_cursor(conn):
|
||||
"""
|
||||
Get cursor with RealDictCursor for dict-like row access.
|
||||
|
||||
Returns rows as dictionaries: {'column_name': value, ...}
|
||||
Compatible with previous sqlite3.Row behavior.
|
||||
"""
|
||||
return conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
|
||||
def r2d(row) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Convert row to dict (compatibility helper).
|
||||
|
||||
Args:
|
||||
row: RealDictRow from psycopg2
|
||||
|
||||
Returns:
|
||||
Dictionary or None if row is None
|
||||
"""
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def execute_one(conn, query: str, params: tuple = ()) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Execute query and return one row as dict.
|
||||
|
||||
Args:
|
||||
conn: Database connection from get_db()
|
||||
query: SQL query with %s placeholders
|
||||
params: Tuple of parameters
|
||||
|
||||
Returns:
|
||||
Dictionary with column:value pairs, or None if no row found
|
||||
|
||||
Example:
|
||||
profile = execute_one(conn, "SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||
if profile:
|
||||
print(profile['name'])
|
||||
"""
|
||||
with get_cursor(conn) as cur:
|
||||
cur.execute(query, params)
|
||||
row = cur.fetchone()
|
||||
return r2d(row)
|
||||
|
||||
|
||||
def execute_all(conn, query: str, params: tuple = ()) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Execute query and return all rows as list of dicts.
|
||||
|
||||
Args:
|
||||
conn: Database connection from get_db()
|
||||
query: SQL query with %s placeholders
|
||||
params: Tuple of parameters
|
||||
|
||||
Returns:
|
||||
List of dictionaries (one per row)
|
||||
|
||||
Example:
|
||||
weights = execute_all(conn,
|
||||
"SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date DESC",
|
||||
(pid,)
|
||||
)
|
||||
for w in weights:
|
||||
print(w['date'], w['weight'])
|
||||
"""
|
||||
with get_cursor(conn) as cur:
|
||||
cur.execute(query, params)
|
||||
rows = cur.fetchall()
|
||||
return [r2d(r) for r in rows]
|
||||
|
||||
|
||||
def execute_write(conn, query: str, params: tuple = ()) -> None:
|
||||
"""
|
||||
Execute INSERT/UPDATE/DELETE query.
|
||||
|
||||
Args:
|
||||
conn: Database connection from get_db()
|
||||
query: SQL query with %s placeholders
|
||||
params: Tuple of parameters
|
||||
|
||||
Example:
|
||||
execute_write(conn,
|
||||
"UPDATE profiles SET name=%s WHERE id=%s",
|
||||
("New Name", pid)
|
||||
)
|
||||
"""
|
||||
with get_cursor(conn) as cur:
|
||||
cur.execute(query, params)
|
||||
|
||||
|
||||
def init_db():
|
||||
"""
|
||||
Initialize database with required data.
|
||||
|
||||
Ensures critical data exists (e.g., pipeline master prompt).
|
||||
Safe to call multiple times - checks before inserting.
|
||||
Called automatically on app startup.
|
||||
"""
|
||||
try:
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Check if table exists first
|
||||
cur.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'ai_prompts'
|
||||
) as table_exists
|
||||
""")
|
||||
if not cur.fetchone()['table_exists']:
|
||||
print("[WARN] ai_prompts table doesn't exist yet - skipping pipeline prompt creation")
|
||||
return
|
||||
|
||||
# Ensure "pipeline" master prompt exists
|
||||
cur.execute("SELECT COUNT(*) as count FROM ai_prompts WHERE slug='pipeline'")
|
||||
if cur.fetchone()['count'] == 0:
|
||||
cur.execute("""
|
||||
INSERT INTO ai_prompts (slug, name, description, template, active, sort_order)
|
||||
VALUES (
|
||||
'pipeline',
|
||||
'Mehrstufige Gesamtanalyse',
|
||||
'Master-Schalter für die gesamte Pipeline. Deaktiviere diese Analyse, um die Pipeline komplett zu verstecken.',
|
||||
'PIPELINE_MASTER',
|
||||
true,
|
||||
-10
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
print("[OK] Pipeline master prompt created")
|
||||
except Exception as e:
|
||||
print(f"[WARN] Could not create pipeline prompt: {e}")
|
||||
# Don't fail startup - prompt can be created manually
|
||||
245
backend/db_init.py
Normal file
245
backend/db_init.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database initialization script for PostgreSQL.
|
||||
Replaces psql commands in startup.sh with pure Python.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import psycopg2
|
||||
from psycopg2 import OperationalError
|
||||
|
||||
DB_HOST = os.getenv("DB_HOST", "localhost")
|
||||
DB_PORT = os.getenv("DB_PORT", "5432")
|
||||
DB_NAME = os.getenv("DB_NAME", "mitai_dev")
|
||||
DB_USER = os.getenv("DB_USER", "mitai_dev")
|
||||
DB_PASSWORD = os.getenv("DB_PASSWORD", "")
|
||||
|
||||
def get_connection():
|
||||
"""Get PostgreSQL connection."""
|
||||
return psycopg2.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
database=DB_NAME,
|
||||
user=DB_USER,
|
||||
password=DB_PASSWORD
|
||||
)
|
||||
|
||||
def wait_for_postgres(max_retries=30):
|
||||
"""Wait for PostgreSQL to be ready."""
|
||||
print("\nChecking PostgreSQL connection...")
|
||||
for i in range(1, max_retries + 1):
|
||||
try:
|
||||
conn = get_connection()
|
||||
conn.close()
|
||||
print("✓ PostgreSQL ready")
|
||||
return True
|
||||
except OperationalError:
|
||||
print(f" Waiting for PostgreSQL... (attempt {i}/{max_retries})")
|
||||
time.sleep(2)
|
||||
|
||||
print(f"✗ PostgreSQL not ready after {max_retries} attempts")
|
||||
return False
|
||||
|
||||
def check_table_exists(table_name="profiles"):
|
||||
"""Check if a table exists."""
|
||||
try:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema='public' AND table_name=%s
|
||||
""", (table_name,))
|
||||
count = cur.fetchone()[0]
|
||||
cur.close()
|
||||
conn.close()
|
||||
return count > 0
|
||||
except Exception as e:
|
||||
print(f"Error checking table: {e}")
|
||||
return False
|
||||
|
||||
def load_schema(schema_file="/app/schema.sql"):
|
||||
"""Load schema from SQL file."""
|
||||
try:
|
||||
with open(schema_file, 'r') as f:
|
||||
schema_sql = f.read()
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute(schema_sql)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
print("✓ Schema loaded from schema.sql")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"✗ Error loading schema: {e}")
|
||||
return False
|
||||
|
||||
def get_profile_count():
|
||||
"""Get number of profiles in database."""
|
||||
try:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM profiles")
|
||||
count = cur.fetchone()[0]
|
||||
cur.close()
|
||||
conn.close()
|
||||
return count
|
||||
except Exception as e:
|
||||
print(f"Error getting profile count: {e}")
|
||||
return -1
|
||||
|
||||
def ensure_migration_table():
|
||||
"""Create migration tracking table if it doesn't exist."""
|
||||
try:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(255) UNIQUE NOT NULL,
|
||||
applied_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error creating migration table: {e}")
|
||||
return False
|
||||
|
||||
def get_applied_migrations():
|
||||
"""Get list of already applied migrations."""
|
||||
try:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT filename FROM schema_migrations ORDER BY filename")
|
||||
migrations = [row[0] for row in cur.fetchall()]
|
||||
cur.close()
|
||||
conn.close()
|
||||
return migrations
|
||||
except Exception as e:
|
||||
print(f"Error getting applied migrations: {e}")
|
||||
return []
|
||||
|
||||
def apply_migration(filepath, filename):
|
||||
"""Apply a single migration file."""
|
||||
try:
|
||||
with open(filepath, 'r') as f:
|
||||
migration_sql = f.read()
|
||||
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Execute migration
|
||||
cur.execute(migration_sql)
|
||||
|
||||
# Record migration
|
||||
cur.execute(
|
||||
"INSERT INTO schema_migrations (filename) VALUES (%s)",
|
||||
(filename,)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
print(f" ✓ Applied: {filename}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" ✗ Failed to apply {filename}: {e}")
|
||||
return False
|
||||
|
||||
def run_migrations(migrations_dir="/app/migrations"):
|
||||
"""Run all pending migrations."""
|
||||
import glob
|
||||
import re
|
||||
|
||||
if not os.path.exists(migrations_dir):
|
||||
print("✓ No migrations directory found")
|
||||
return True
|
||||
|
||||
# Ensure migration tracking table exists
|
||||
if not ensure_migration_table():
|
||||
return False
|
||||
|
||||
# Get already applied migrations
|
||||
applied = get_applied_migrations()
|
||||
|
||||
# Get all migration files (only numbered migrations like 001_*.sql)
|
||||
all_files = sorted(glob.glob(os.path.join(migrations_dir, "*.sql")))
|
||||
migration_pattern = re.compile(r'^\d{3}_.*\.sql$')
|
||||
migration_files = [f for f in all_files if migration_pattern.match(os.path.basename(f))]
|
||||
|
||||
if not migration_files:
|
||||
print("✓ No migration files found")
|
||||
return True
|
||||
|
||||
# Apply pending migrations
|
||||
pending = []
|
||||
for filepath in migration_files:
|
||||
filename = os.path.basename(filepath)
|
||||
if filename not in applied:
|
||||
pending.append((filepath, filename))
|
||||
|
||||
if not pending:
|
||||
print(f"✓ All {len(applied)} migrations already applied")
|
||||
return True
|
||||
|
||||
print(f" Found {len(pending)} pending migration(s)...")
|
||||
for filepath, filename in pending:
|
||||
if not apply_migration(filepath, filename):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("═══════════════════════════════════════════════════════════")
|
||||
print("MITAI JINKENDO - Database Initialization (v9c)")
|
||||
print("═══════════════════════════════════════════════════════════")
|
||||
|
||||
# Wait for PostgreSQL
|
||||
if not wait_for_postgres():
|
||||
sys.exit(1)
|
||||
|
||||
# Check schema
|
||||
print("\nChecking database schema...")
|
||||
if not check_table_exists("profiles"):
|
||||
print(" Schema not found, initializing...")
|
||||
if not load_schema():
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("✓ Schema already exists")
|
||||
|
||||
# Run migrations
|
||||
print("\nRunning database migrations...")
|
||||
if not run_migrations():
|
||||
print("✗ Migration failed")
|
||||
sys.exit(1)
|
||||
|
||||
# Check for migration
|
||||
print("\nChecking for SQLite data migration...")
|
||||
sqlite_db = "/app/data/bodytrack.db"
|
||||
profile_count = get_profile_count()
|
||||
|
||||
if os.path.exists(sqlite_db) and profile_count == 0:
|
||||
print(" SQLite database found and PostgreSQL is empty")
|
||||
print(" Starting automatic migration...")
|
||||
# Import and run migration
|
||||
try:
|
||||
from migrate_to_postgres import main as migrate
|
||||
migrate()
|
||||
except Exception as e:
|
||||
print(f"✗ Migration failed: {e}")
|
||||
sys.exit(1)
|
||||
elif os.path.exists(sqlite_db) and profile_count > 0:
|
||||
print(f"⚠ SQLite DB exists but PostgreSQL already has {profile_count} profiles")
|
||||
print(" Skipping migration (already migrated)")
|
||||
elif not os.path.exists(sqlite_db):
|
||||
print("✓ No SQLite database found (fresh install or already migrated)")
|
||||
else:
|
||||
print("✓ No migration needed")
|
||||
|
||||
print("\n✓ Database initialization complete")
|
||||
78
backend/main.py
Normal file
78
backend/main.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""
|
||||
Shinkan Jinkendo - Main Application Entry Point
|
||||
|
||||
Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
import os
|
||||
|
||||
from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS
|
||||
from db_init import init_db
|
||||
|
||||
# Initialize FastAPI app
|
||||
app = FastAPI(
|
||||
title="Shinkan Jinkendo API",
|
||||
description="Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung",
|
||||
version=APP_VERSION
|
||||
)
|
||||
|
||||
# CORS Configuration
|
||||
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:3098").split(",")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=ALLOWED_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Initialize Database (runs migrations automatically)
|
||||
init_db()
|
||||
|
||||
# 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}
|
||||
|
||||
# Root Endpoint
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
"""Root endpoint - API info"""
|
||||
return {
|
||||
"app": "Shinkan Jinkendo API",
|
||||
"version": APP_VERSION,
|
||||
"docs": "/docs",
|
||||
"health": "/health"
|
||||
}
|
||||
|
||||
# TODO: Register routers here as they are created
|
||||
# from routers import auth, profiles, clubs, groups, skills, methods, exercises
|
||||
# app.include_router(auth.router, prefix="/api")
|
||||
# app.include_router(profiles.router, prefix="/api")
|
||||
# ... etc
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=True
|
||||
)
|
||||
225
backend/models.py
Normal file
225
backend/models.py
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
"""
|
||||
Pydantic Models for Shinkan Jinkendo API
|
||||
|
||||
Request/Response schemas for all endpoints
|
||||
"""
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Optional, List
|
||||
from datetime import date, time, datetime
|
||||
|
||||
# ============================================================================
|
||||
# Auth & Profiles (von Mitai übernommen)
|
||||
# ============================================================================
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
name: Optional[str] = None
|
||||
|
||||
class ProfileResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
name: Optional[str]
|
||||
role: str
|
||||
tier: str
|
||||
email_verified: bool
|
||||
created_at: datetime
|
||||
|
||||
# ============================================================================
|
||||
# Clubs & Groups
|
||||
# ============================================================================
|
||||
|
||||
class ClubCreate(BaseModel):
|
||||
name: str
|
||||
abbreviation: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
class ClubResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
abbreviation: Optional[str]
|
||||
description: Optional[str]
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
class TrainingGroupCreate(BaseModel):
|
||||
club_id: int
|
||||
division_id: Optional[int] = None
|
||||
name: str
|
||||
focus: Optional[str] = None
|
||||
level: Optional[str] = None
|
||||
age_group: Optional[str] = None
|
||||
weekday: Optional[str] = None
|
||||
time_start: Optional[time] = None
|
||||
time_end: Optional[time] = None
|
||||
location: Optional[str] = None
|
||||
trainer_id: Optional[int] = None
|
||||
co_trainer_ids: Optional[List[int]] = []
|
||||
|
||||
class TrainingGroupResponse(BaseModel):
|
||||
id: int
|
||||
club_id: int
|
||||
name: str
|
||||
focus: Optional[str]
|
||||
level: Optional[str]
|
||||
age_group: Optional[str]
|
||||
weekday: Optional[str]
|
||||
time_start: Optional[time]
|
||||
time_end: Optional[time]
|
||||
location: Optional[str]
|
||||
trainer_id: Optional[int]
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
# ============================================================================
|
||||
# Skills & Methods
|
||||
# ============================================================================
|
||||
|
||||
class SkillCreate(BaseModel):
|
||||
name: str
|
||||
category: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
importance: Optional[int] = Field(None, ge=1, le=5)
|
||||
keywords: Optional[List[str]] = []
|
||||
|
||||
class SkillResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
category: Optional[str]
|
||||
description: Optional[str]
|
||||
importance: Optional[int]
|
||||
keywords: Optional[List[str]]
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
class MethodCreate(BaseModel):
|
||||
name: str
|
||||
abbreviation: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
typical_duration: Optional[int] = None
|
||||
typical_group_size: Optional[str] = None
|
||||
related_skills: Optional[List[int]] = []
|
||||
keywords: Optional[List[str]] = []
|
||||
|
||||
class MethodResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
abbreviation: Optional[str]
|
||||
category: Optional[str]
|
||||
description: Optional[str]
|
||||
typical_duration: Optional[int]
|
||||
typical_group_size: Optional[str]
|
||||
related_skills: Optional[List[int]]
|
||||
keywords: Optional[List[str]]
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
# ============================================================================
|
||||
# Exercises (Kernobjekt)
|
||||
# ============================================================================
|
||||
|
||||
class ExerciseCreate(BaseModel):
|
||||
title: str
|
||||
summary: Optional[str] = None
|
||||
goal: str
|
||||
execution: str
|
||||
preparation: Optional[str] = None
|
||||
trainer_notes: Optional[str] = None
|
||||
equipment: Optional[List[str]] = []
|
||||
duration_min: Optional[int] = None
|
||||
duration_max: Optional[int] = None
|
||||
group_size_min: Optional[int] = None
|
||||
group_size_max: Optional[int] = None
|
||||
age_groups: Optional[List[str]] = []
|
||||
focus_area: Optional[str] = None
|
||||
secondary_areas: Optional[List[str]] = []
|
||||
training_character: Optional[str] = None
|
||||
primary_method_id: Optional[int] = None
|
||||
secondary_method_ids: Optional[List[int]] = []
|
||||
visibility: Optional[str] = "private"
|
||||
club_id: Optional[int] = None
|
||||
|
||||
class ExerciseResponse(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
summary: Optional[str]
|
||||
goal: str
|
||||
execution: str
|
||||
preparation: Optional[str]
|
||||
trainer_notes: Optional[str]
|
||||
equipment: Optional[List[str]]
|
||||
duration_min: Optional[int]
|
||||
duration_max: Optional[int]
|
||||
group_size_min: Optional[int]
|
||||
group_size_max: Optional[int]
|
||||
age_groups: Optional[List[str]]
|
||||
focus_area: Optional[str]
|
||||
secondary_areas: Optional[List[str]]
|
||||
training_character: Optional[str]
|
||||
primary_method_id: Optional[int]
|
||||
secondary_method_ids: Optional[List[int]]
|
||||
visibility: str
|
||||
status: str
|
||||
created_by: int
|
||||
club_id: Optional[int]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class ExerciseSkillCreate(BaseModel):
|
||||
exercise_id: int
|
||||
skill_id: int
|
||||
is_primary: bool = False
|
||||
intensity: Optional[int] = Field(None, ge=1, le=5)
|
||||
development_contribution: Optional[str] = None
|
||||
required_level: Optional[int] = None
|
||||
target_level: Optional[int] = None
|
||||
|
||||
# ============================================================================
|
||||
# Training Planning
|
||||
# ============================================================================
|
||||
|
||||
class TrainingUnitCreate(BaseModel):
|
||||
group_id: int
|
||||
date: date
|
||||
time_start: Optional[time] = None
|
||||
time_end: Optional[time] = None
|
||||
derived_from_template_id: Optional[int] = None
|
||||
derived_from_unit_id: Optional[int] = None
|
||||
title: Optional[str] = None
|
||||
goal: Optional[str] = None
|
||||
focus_areas: Optional[List[str]] = []
|
||||
|
||||
class TrainingUnitResponse(BaseModel):
|
||||
id: int
|
||||
group_id: int
|
||||
date: date
|
||||
time_start: Optional[time]
|
||||
time_end: Optional[time]
|
||||
title: Optional[str]
|
||||
goal: Optional[str]
|
||||
focus_areas: Optional[List[str]]
|
||||
completion_status: str
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
# ============================================================================
|
||||
# Import
|
||||
# ============================================================================
|
||||
|
||||
class WikiImportRequest(BaseModel):
|
||||
import_type: str # skill, method, exercise
|
||||
wiki_url: Optional[str] = None
|
||||
dry_run: bool = False
|
||||
|
||||
class WikiImportResponse(BaseModel):
|
||||
import_status: str
|
||||
items_total: int
|
||||
items_imported: int
|
||||
items_failed: int
|
||||
error_log: Optional[List[str]]
|
||||
12
backend/requirements.txt
Normal file
12
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.29.0
|
||||
anthropic==0.26.0
|
||||
python-multipart==0.0.9
|
||||
Pillow==10.3.0
|
||||
aiofiles==23.2.1
|
||||
pydantic==2.7.1
|
||||
bcrypt==4.1.3
|
||||
slowapi==0.1.9
|
||||
psycopg2-binary==2.9.9
|
||||
python-dateutil==2.9.0
|
||||
tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows
|
||||
38
backend/version.py
Normal file
38
backend/version.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.1.0"
|
||||
BUILD_DATE = "2026-04-21"
|
||||
DB_SCHEMA_VERSION = "20260421"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"auth": "1.0.0",
|
||||
"profiles": "1.0.0",
|
||||
"clubs": "0.1.0",
|
||||
"groups": "0.1.0",
|
||||
"skills": "0.1.0",
|
||||
"methods": "0.1.0",
|
||||
"exercises": "0.1.0",
|
||||
"training_units": "0.1.0",
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.1.0",
|
||||
"import_wiki": "0.1.0",
|
||||
"admin": "1.0.0",
|
||||
"membership": "1.0.0",
|
||||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"date": "2026-04-21",
|
||||
"changes": [
|
||||
"Initial MVP Setup",
|
||||
"Feature: Übungsverwaltung (Kern-Modul)",
|
||||
"Feature: Fähigkeiten- und Methodenkataloge",
|
||||
"Feature: Trainingsplanung für Gruppen",
|
||||
"Feature: Trainingsabschnitte mit Kombinations-Flag",
|
||||
"Feature: MediaWiki-Import (einseitig)",
|
||||
"Feature: Freigabelogik (privat/Verein/offiziell)",
|
||||
"Infrastructure: Auth + Membership von Mitai übernommen",
|
||||
]
|
||||
}
|
||||
]
|
||||
Loading…
Reference in New Issue
Block a user