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