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:
Lars 2026-04-21 14:26:12 +02:00
commit a426c03598
11 changed files with 1681 additions and 0 deletions

25
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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",
]
}
]