Initial commit: WrestleDesk full project

- Django backend with DRF (clubs, wrestlers, trainers, exercises, templates, trainings, homework, locations, leistungstest)
- Next.js 16 frontend with React, Shadcn UI, Tailwind
- JWT authentication
- Full CRUD for all entities
- Calendar view for trainings
- Homework management system
- Leistungstest tracking
This commit is contained in:
Andrej Spielmann
2026-03-26 13:24:57 +01:00
commit 3fefc550fe
256 changed files with 38295 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
# Dependencies
node_modules/
__pycache__/
*.pyc
# Environment
.env
.env.local
.env.*.local
# Build outputs
.next/
out/
build/
dist/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Test
coverage/
# Misc
*.snapshot
*.log
# Local development
*.local
# Backup files
login_snapshot*.md
+42
View File
@@ -0,0 +1,42 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
+96
View File
@@ -0,0 +1,96 @@
---
name: webapp-testing
description: Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.
license: Complete terms in LICENSE.txt
---
# Web Application Testing
To test local web applications, write native Python Playwright scripts.
**Helper Scripts Available**:
- `scripts/with_server.py` - Manages server lifecycle (supports multiple servers)
**Always run scripts with `--help` first** to see usage. DO NOT read the source until you try running the script first and find that a customized solution is abslutely necessary. These scripts can be very large and thus pollute your context window. They exist to be called directly as black-box scripts rather than ingested into your context window.
## Decision Tree: Choosing Your Approach
```
User task → Is it static HTML?
├─ Yes → Read HTML file directly to identify selectors
│ ├─ Success → Write Playwright script using selectors
│ └─ Fails/Incomplete → Treat as dynamic (below)
└─ No (dynamic webapp) → Is the server already running?
├─ No → Run: python scripts/with_server.py --help
│ Then use the helper + write simplified Playwright script
└─ Yes → Reconnaissance-then-action:
1. Navigate and wait for networkidle
2. Take screenshot or inspect DOM
3. Identify selectors from rendered state
4. Execute actions with discovered selectors
```
## Example: Using with_server.py
To start a server, run `--help` first, then use the helper:
**Single server:**
```bash
python scripts/with_server.py --server "npm run dev" --port 5173 -- python your_automation.py
```
**Multiple servers (e.g., backend + frontend):**
```bash
python scripts/with_server.py \
--server "cd backend && python server.py" --port 3000 \
--server "cd frontend && npm run dev" --port 5173 \
-- python your_automation.py
```
To create an automation script, include only Playwright logic (servers are managed automatically):
```python
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True) # Always launch chromium in headless mode
page = browser.new_page()
page.goto('http://localhost:5173') # Server already running and ready
page.wait_for_load_state('networkidle') # CRITICAL: Wait for JS to execute
# ... your automation logic
browser.close()
```
## Reconnaissance-Then-Action Pattern
1. **Inspect rendered DOM**:
```python
page.screenshot(path='/tmp/inspect.png', full_page=True)
content = page.content()
page.locator('button').all()
```
2. **Identify selectors** from inspection results
3. **Execute actions** using discovered selectors
## Common Pitfall
❌ **Don't** inspect the DOM before waiting for `networkidle` on dynamic apps
✅ **Do** wait for `page.wait_for_load_state('networkidle')` before inspection
## Best Practices
- **Use bundled scripts as black boxes** - To accomplish a task, consider whether one of the scripts available in `scripts/` can help. These scripts handle common, complex workflows reliably without cluttering the context window. Use `--help` to see usage, then invoke directly.
- Use `sync_playwright()` for synchronous scripts
- Always close the browser when done
- Use descriptive selectors: `text=`, `role=`, CSS selectors, or IDs
- Add appropriate waits: `page.wait_for_selector()` or `page.wait_for_timeout()`
## Reference Files
- **examples/** - Examples showing common patterns:
- `element_discovery.py` - Discovering buttons, links, and inputs on a page
- `static_html_automation.py` - Using file:// URLs for local HTML
- `console_logging.py` - Capturing console logs during automation
@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1774346494860}
@@ -0,0 +1 @@
12159
@@ -0,0 +1,130 @@
<h2>Leistungstest Timer — Layout</h2>
<p class="subtitle">Pro Ringer: links Info/Timer, rechts Übungen. Timer läuft durchgehend.</p>
<div class="split">
<div class="mockup">
<div class="mockup-header">Ringer 1: Max Mustermann</div>
<div class="mockup-body" style="padding:16px;background:#f8fafc">
<div style="text-align:center;margin-bottom:12px">
<div style="font-size:48px;font-weight:bold;font-family:monospace">05:23</div>
<div style="font-size:12px;color:#64748b">Gesamtzeit</div>
</div>
<div style="display:flex;gap:8px;justify-content:center;margin-bottom:16px">
<button class="mock-button" style="background:#1B1A55;color:white;padding:8px 16px;border-radius:6px;border:none;font-size:13px">⏸ Pausieren</button>
</div>
<div style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden">
<div style="background:#f1f5f9;padding:8px 12px;font-size:12px;font-weight:600;color:#475569;border-bottom:1px solid #e2e8f0">
ÜBUNGEN
</div>
<div style="padding:12px;display:flex;flex-direction:column;gap:8px">
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px;background:#dcfce7;border-radius:6px;border:1px solid #86efac">
<div>
<div style="font-weight:500;font-size:13px">Liegestütze</div>
<div style="font-size:11px;color:#64748b">Soll: 30s</div>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span style="background:#22c55e;color:white;padding:4px 10px;border-radius:12px;font-size:11px;font-weight:600">✓ 0:12</span>
<span style="color:#22c55e;font-size:18px"></span>
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px;background:#fef9c3;border-radius:6px;border:1px solid #fde047">
<div>
<div style="font-weight:500;font-size:13px">Kniebeugen</div>
<div style="font-size:11px;color:#64748b">Soll: 45s</div>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span style="background:#eab308;color:white;padding:4px 10px;border-radius:12px;font-size:11px;font-weight:600">⏸ 0:28</span>
<button style="background:#1B1A55;color:white;padding:6px 12px;border-radius:6px;border:none;font-size:12px;cursor:pointer">✓ Erledigt</button>
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px;background:#f8fafc;border-radius:6px;border:1px solid #e2e8f0">
<div>
<div style="font-weight:500;font-size:13px">Burpees</div>
<div style="font-size:11px;color:#64748b">Soll: 20s</div>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span style="background:#94a3b8;color:white;padding:4px 10px;border-radius:12px;font-size:11px;font-weight:600">Ausstehend</span>
<button style="background:#1B1A55;color:white;padding:6px 12px;border-radius:6px;border:none;font-size:12px;cursor:pointer">▶ Start</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="mockup">
<div class="mockup-header">Ringer 2: Anna Schmidt</div>
<div class="mockup-body" style="padding:16px;background:#f8fafc">
<div style="text-align:center;margin-bottom:12px">
<div style="font-size:48px;font-weight:bold;font-family:monospace;color:#94a3b8">02:41</div>
<div style="font-size:12px;color:#64748b">Gesamtzeit</div>
</div>
<div style="display:flex;gap:8px;justify-content:center;margin-bottom:16px">
<button class="mock-button" style="background:#1B1A55;color:white;padding:8px 16px;border-radius:6px;border:none;font-size:13px">▶ Fortsetzen</button>
</div>
<div style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden">
<div style="background:#f1f5f9;padding:8px 12px;font-size:12px;font-weight:600;color:#475569;border-bottom:1px solid #e2e8f0">
ÜBUNGEN
</div>
<div style="padding:12px;display:flex;flex-direction:column;gap:8px">
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px;background:#fef9c3;border-radius:6px;border:1px solid #fde047">
<div>
<div style="font-weight:500;font-size:13px">Liegestütze</div>
<div style="font-size:11px;color:#64748b">Soll: 30s</div>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span style="background:#eab308;color:white;padding:4px 10px;border-radius:12px;font-size:11px;font-weight:600">⏸ 0:31</span>
<button style="background:#1B1A55;color:white;padding:6px 12px;border-radius:6px;border:none;font-size:12px;cursor:pointer">✓ Erledigt</button>
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px;background:#f8fafc;border-radius:6px;border:1px solid #e2e8f0">
<div>
<div style="font-weight:500;font-size:13px">Kniebeugen</div>
<div style="font-size:11px;color:#64748b">Soll: 45s</div>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span style="background:#94a3b8;color:white;padding:4px 10px;border-radius:12px;font-size:11px;font-weight:600">Ausstehend</span>
<button style="background:#1B1A55;color:white;padding:6px 12px;border-radius:6px;border:none;font-size:12px;cursor:pointer">▶ Start</button>
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px;background:#f8fafc;border-radius:6px;border:1px solid #e2e8f0">
<div>
<div style="font-weight:500;font-size:13px">Burpees</div>
<div style="font-size:11px;color:#64748b">Soll: 20s</div>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span style="background:#94a3b8;color:white;padding:4px 10px;border-radius:12px;font-size:11px;font-weight:600">Ausstehend</span>
<button style="background:#1B1A55;color:white;padding:6px 12px;border-radius:6px;border:none;font-size:12px;cursor:pointer">▶ Start</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section" style="margin-top:16px">
<h3>Zustände pro Übung</h3>
<div style="display:flex;gap:16px;margin-top:8px;flex-wrap:wrap">
<div style="flex:1;min-width:140px;padding:12px;background:#f8fafc;border-radius:8px;border:1px solid #e2e8f0">
<div style="font-weight:600;font-size:12px;color:#64748b;margin-bottom:4px">AUSSTEHEND</div>
<div style="font-size:11px;color:#94a3b8">Grau — noch nicht gestartet</div>
<div style="margin-top:6px;font-size:12px">Button: ▶ Start</div>
</div>
<div style="flex:1;min-width:140px;padding:12px;background:#fef9c3;border-radius:8px;border:1px solid #fde047">
<div style="font-weight:600;font-size:12px;color:#a16207;margin-bottom:4px">AKTIV / LÄUFT</div>
<div style="font-size:11px;color:#a16207">Gelb — Timer läuft für diese Übung</div>
<div style="margin-top:6px;font-size:12px">Button: ✓ Erledigt (speichert Zeit)</div>
</div>
<div style="flex:1;min-width:140px;padding:12px;background:#dcfce7;border-radius:8px;border:1px solid #86efac">
<div style="font-weight:600;font-size:12px;color:#166534;margin-bottom:4px">ERLEDIGT</div>
<div style="font-size:11px;color:#166534">Grün — Zeit gespeichert, keine Aktion mehr</div>
<div style="margin-top:6px;font-size:12px">Zeit badge: ✓ 0:12</div>
</div>
</div>
</div>
<p style="font-size:12px;color:#94a3b8;margin-top:12px">
<strong>Wichtig:</strong> Der globale Timer läuft durchgehend (nicht reset zwischen Übungen).
Jede Übung hat eine eigene "Erledigt"-Zeit. Soll = Zeit (Sekunden), kein Reps.
</p>
@@ -0,0 +1,103 @@
<h2>Leistungstest Timer — Alle Ringer links, Übungen rechts</h2>
<p class="subtitle">Eine gemeinsame Zeit. Pro Übung "Erledigt" klicken = Zeit wird gespeichert.</p>
<div style="display:flex;gap:16px;align-items:flex-start">
<!-- LINKS: Ringer-Liste -->
<div style="width:260px;flex-shrink:0">
<div style="font-weight:600;font-size:13px;color:#475569;margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px">Ringer</div>
<div style="border:1px solid #e2e8f0;border-radius:10px;overflow:hidden">
<div style="background:#f1f5f9;padding:10px 12px;border-bottom:1px solid #e2e8f0;display:flex;justify-content:space-between;align-items:center">
<span style="font-size:12px;font-weight:600;color:#475569">3 Ringer</span>
<span style="font-size:11px;color:#64748b">2/3 erledigt</span>
</div>
<div style="padding:8px;display:flex;flex-direction:column;gap:4px">
<div style="padding:10px 10px;border-radius:8px;border:2px solid #22c55e;background:#dcfce7;cursor:pointer">
<div style="font-weight:500;font-size:13px">Max Mustermann</div>
<div style="font-size:11px;color:#166534;margin-top:2px">✓ 3/3 Übungen</div>
</div>
<div style="padding:10px 10px;border-radius:8px;border:2px solid #1B1A55;background:#f8fafc;cursor:pointer">
<div style="font-weight:500;font-size:13px">Anna Schmidt</div>
<div style="font-size:11px;color:#64748b;margin-top:2px">2/3 Übungen</div>
</div>
<div style="padding:10px 10px;border-radius:8px;border:1px solid #e2e8f0;background:#f8fafc;cursor:pointer;opacity:0.7">
<div style="font-weight:500;font-size:13px">Tom Klein</div>
<div style="font-size:11px;color:#94a3b8;margin-top:2px">0/3 Übungen</div>
</div>
</div>
</div>
</div>
<!-- RECHTS: Timer + Übungen -->
<div style="flex:1">
<!-- Globaler Timer -->
<div style="background:#1B1A55;border-radius:12px;padding:20px;text-align:center;margin-bottom:16px">
<div style="font-size:14px;font-weight:600;color:#9290C3;margin-bottom:4px;text-transform:uppercase;letter-spacing:1px">Gemeinsame Zeit</div>
<div style="font-size:64px;font-weight:bold;color:white;font-family:monospace;letter-spacing:2px">05:23</div>
<div style="margin-top:12px;display:flex;gap:8px;justify-content:center">
<button style="background:rgba(255,255,255,0.15);color:white;padding:8px 20px;border-radius:8px;border:none;font-size:13px;font-weight:500;cursor:pointer">⏸ Pausieren</button>
<button style="background:#dc2626;color:white;padding:8px 20px;border-radius:8px;border:none;font-size:13px;font-weight:500;cursor:pointer">■ Training beenden</button>
</div>
</div>
<!-- Aktueller Ringer -->
<div style="margin-bottom:12px;display:flex;align-items:center;gap:8px">
<div style="width:8px;height:8px;border-radius:50%;background:#22c55e"></div>
<span style="font-weight:600;font-size:15px">Anna Schmidt</span>
<span style="font-size:12px;color:#64748b;background:#f1f5f9;padding:2px 8px;border-radius:10px">2/3 erledigt</span>
</div>
<!-- Übungen -->
<div style="border:1px solid #e2e8f0;border-radius:10px;overflow:hidden">
<div style="background:#f1f5f9;padding:10px 14px;border-bottom:1px solid #e2e8f0;display:flex;justify-content:space-between;align-items:center">
<span style="font-size:12px;font-weight:600;color:#475569">ÜBUNGEN</span>
<span style="font-size:11px;color:#64748b">Soll-Zeit in Sekunden</span>
</div>
<!-- Übung 1: Erledigt -->
<div style="padding:14px;display:flex;align-items:center;gap:14px;border-bottom:1px solid #f1f5f9;background:#dcfce7">
<div style="width:28px;height:28px;border-radius:50%;background:#22c55e;display:flex;align-items:center;justify-content:center;color:white;font-size:16px;font-weight:bold;flex-shrink:0"></div>
<div style="flex:1">
<div style="font-weight:500;font-size:14px">Liegestütze</div>
<div style="font-size:12px;color:#64748b">Soll: 30s</div>
</div>
<div style="display:flex;align-items:center;gap:10px">
<span style="background:#166534;color:white;padding:4px 12px;border-radius:12px;font-size:12px;font-weight:600">✓ 0:28</span>
<span style="font-size:12px;color:#64748b">+2s</span>
</div>
</div>
<!-- Übung 2: Aktiv -->
<div style="padding:14px;display:flex;align-items:center;gap:14px;border-bottom:1px solid #f1f5f9;background:#fef9c3">
<div style="width:28px;height:28px;border-radius:50%;background:#eab308;display:flex;align-items:center;justify-content:center;color:white;font-size:14px;font-weight:bold;flex-shrink:0"></div>
<div style="flex:1">
<div style="font-weight:500;font-size:14px">Kniebeugen</div>
<div style="font-size:12px;color:#a16207">Soll: 45s · <strong>Aktiv!</strong></div>
</div>
<div style="display:flex;align-items:center;gap:10px">
<span style="background:#a16207;color:white;padding:4px 12px;border-radius:12px;font-size:12px;font-weight:600">⏸ 0:31</span>
<button style="background:#166534;color:white;padding:8px 16px;border-radius:8px;border:none;font-size:13px;font-weight:500;cursor:pointer">✓ Erledigt</button>
</div>
</div>
<!-- Übung 3: Ausstehend -->
<div style="padding:14px;display:flex;align-items:center;gap:14px;border-bottom:1px solid #f1f5f9;background:#fff">
<div style="width:28px;height:28px;border-radius:50%;background:#e2e8f0;display:flex;align-items:center;justify-content:center;color:#94a3b8;font-size:12px;font-weight:bold;flex-shrink:0">3</div>
<div style="flex:1">
<div style="font-weight:500;font-size:14px">Burpees</div>
<div style="font-size:12px;color:#94a3b8">Soll: 20s</div>
</div>
<div style="display:flex;align-items:center;gap:10px">
<span style="background:#e2e8f0;color:#64748b;padding:4px 12px;border-radius:12px;font-size:12px;font-weight:600"></span>
<button style="background:#1B1A55;color:white;padding:8px 16px;border-radius:8px;border:none;font-size:13px;font-weight:500;cursor:pointer">▶ Start</button>
</div>
</div>
</div>
<!-- Hinweis -->
<div style="margin-top:12px;padding:12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;font-size:12px;color:#64748b">
<strong>Anna ist dran:</strong> Die aktive Übung ist gelb markiert. Klicke "Start" bei Burpees um die Zeit zu starten, dann "Erledigt" um die Zeit zu speichern. Alle Ringer teilen sich die gleiche Gesamtzeit.
</div>
</div>
</div>
@@ -0,0 +1,178 @@
<h2>Pro Ringer: eigene Übungen, auto-start nach Erledigt</h2>
<p class="subtitle">Klick "Erledigt" → nächste Übung startet automatisch. Alle Ringer parallel, eigene Geschwindigkeit.</p>
<div style="display:flex;gap:16px;align-items:flex-start">
<!-- LINKS: Ringer-Liste -->
<div style="width:240px;flex-shrink:0">
<div style="font-weight:600;font-size:13px;color:#475569;margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px">Ringer</div>
<div style="border:1px solid #e2e8f0;border-radius:10px;overflow:hidden">
<div style="background:#f1f5f9;padding:10px 12px;border-bottom:1px solid #e2e8f0;display:flex;justify-content:space-between;align-items:center">
<span style="font-size:12px;font-weight:600;color:#475569">3 Ringer</span>
<span style="font-size:11px;color:#64748b">2/3 ✓</span>
</div>
<div style="padding:8px;display:flex;flex-direction:column;gap:4px">
<!-- Max: alle erledigt -->
<div style="padding:10px;border-radius:8px;border:2px solid #22c55e;background:#dcfce7;cursor:pointer">
<div style="font-weight:600;font-size:13px">Max Mustermann</div>
<div style="font-size:11px;color:#166534;margin-top:3px">✓ Alle 3 Übungen</div>
<div style="margin-top:6px;display:flex;flex-direction:column;gap:3px">
<div style="font-size:10px;background:#bbf7d0;color:#166534;padding:2px 6px;border-radius:4px;text-align:center">✓ Liegestütze 0:28</div>
<div style="font-size:10px;background:#bbf7d0;color:#166534;padding:2px 6px;border-radius:4px;text-align:center">✓ Kniebeugen 0:44</div>
<div style="font-size:10px;background:#bbf7d0;color:#166534;padding:2px 6px;border-radius:4px;text-align:center">✓ Burpees 0:19</div>
</div>
</div>
<!-- Anna: aktiv bei Übung 2 -->
<div style="padding:10px;border-radius:8px;border:2px solid #1B1A55;background:#f8fafc;cursor:pointer">
<div style="font-weight:600;font-size:13px">Anna Schmidt</div>
<div style="font-size:11px;color:#1B1A55;margin-top:3px">Übung 2 von 3</div>
<div style="margin-top:6px;display:flex;flex-direction:column;gap:3px">
<div style="font-size:10px;background:#bbf7d0;color:#166534;padding:2px 6px;border-radius:4px;text-align:center">✓ Liegestütze 0:28</div>
<div style="font-size:10px;background:#fef9c3;color:#a16207;padding:2px 6px;border-radius:4px;text-align:center;border:1px solid #fde047">▶ Kniebeugen 0:31</div>
<div style="font-size:10px;background:#f1f5f9;color:#94a3b8;padding:2px 6px;border-radius:4px;text-align:center">○ Burpees</div>
</div>
</div>
<!-- Tom: noch nicht gestartet -->
<div style="padding:10px;border-radius:8px;border:1px solid #e2e8f0;background:#f8fafc;cursor:pointer;opacity:0.7">
<div style="font-weight:500;font-size:13px">Tom Klein</div>
<div style="font-size:11px;color:#94a3b8;margin-top:3px">Noch nicht gestartet</div>
<div style="margin-top:6px;display:flex;flex-direction:column;gap:3px">
<div style="font-size:10px;background:#f1f5f9;color:#94a3b8;padding:2px 6px;border-radius:4px;text-align:center">○ Liegestütze</div>
<div style="font-size:10px;background:#f1f5f9;color:#94a3b8;padding:2px 6px;border-radius:4px;text-align:center">○ Kniebeugen</div>
<div style="font-size:10px;background:#f1f5f9;color:#94a3b8;padding:2px 6px;border-radius:4px;text-align:center">○ Burpees</div>
</div>
</div>
</div>
</div>
</div>
<!-- RECHTS: Detail-Ansicht des ausgewählten Ringers -->
<div style="flex:1">
<!-- Globaler Timer -->
<div style="background:#1B1A55;border-radius:12px;padding:20px;text-align:center;margin-bottom:16px">
<div style="font-size:14px;font-weight:600;color:#9290C3;margin-bottom:4px;text-transform:uppercase;letter-spacing:1px">Gemeinsame Zeit</div>
<div style="font-size:64px;font-weight:bold;color:white;font-family:monospace;letter-spacing:2px">05:23</div>
<div style="margin-top:12px;display:flex;gap:8px;justify-content:center">
<button style="background:rgba(255,255,255,0.15);color:white;padding:8px 20px;border-radius:8px;border:none;font-size:13px;font-weight:500;cursor:pointer">⏸ Pausieren</button>
<button style="background:#dc2626;color:white;padding:8px 20px;border-radius:8px;border:none;font-size:13px;font-weight:500;cursor:pointer">■ Training beenden</button>
</div>
</div>
<!-- Ringer Header -->
<div style="margin-bottom:12px;display:flex;align-items:center;gap:10px">
<div style="width:10px;height:10px;border-radius:50%;background:#1B1A55"></div>
<span style="font-weight:700;font-size:18px">Anna Schmidt</span>
<span style="font-size:12px;color:#64748b;background:#f1f5f9;padding:3px 10px;border-radius:10px">Übung 2 von 3</span>
<span style="font-size:12px;color:#166534;background:#dcfce7;padding:3px 10px;border-radius:10px">1/3 ✓</span>
</div>
<!-- Anna's Übungen -->
<div style="border:1px solid #e2e8f0;border-radius:10px;overflow:hidden">
<div style="background:#f1f5f9;padding:10px 14px;border-bottom:1px solid #e2e8f0;display:flex;justify-content:space-between;align-items:center">
<span style="font-size:12px;font-weight:600;color:#475569">ANNA's ÜBUNGEN</span>
<span style="font-size:11px;color:#64748b">Soll-Zeit</span>
</div>
<!-- Übung 1: Erledigt -->
<div style="padding:14px;display:flex;align-items:center;gap:14px;border-bottom:1px solid #f1f5f9;background:#dcfce7">
<div style="width:32px;height:32px;border-radius:50%;background:#22c55e;display:flex;align-items:center;justify-content:center;color:white;font-size:18px;font-weight:bold;flex-shrink:0"></div>
<div style="flex:1">
<div style="font-weight:600;font-size:15px">Liegestütze</div>
</div>
<div style="text-align:right">
<div style="font-size:13px;color:#64748b;text-decoration:line-through">Soll: 30s</div>
<div style="font-size:14px;font-weight:700;color:#166534">✓ 0:28</div>
</div>
<div style="width:80px;text-align:center">
<span style="font-size:11px;color:#166534;background:#bbf7d0;padding:3px 8px;border-radius:10px;font-weight:600">+2s</span>
</div>
</div>
<!-- Übung 2: Aktiv — läuft gerade -->
<div style="padding:18px;display:flex;align-items:center;gap:14px;border-bottom:1px solid #f1f5f9;background:#fef9c3;border-left:4px solid #eab308">
<div style="width:32px;height:32px;border-radius:50%;background:#eab308;display:flex;align-items:center;justify-content:center;color:white;font-size:14px;font-weight:bold;flex-shrink:0"></div>
<div style="flex:1">
<div style="font-weight:700;font-size:15px;color:#a16207">Kniebeugen</div>
<div style="font-size:12px;color:#a16207;font-weight:500">Aktiv — Zeit läuft!</div>
</div>
<div style="text-align:right">
<div style="font-size:13px;color:#a16207;font-weight:600">Soll: 45s</div>
<div style="font-size:22px;font-weight:700;color:#a16207;font-family:monospace">0:31</div>
</div>
<button style="background:#166534;color:white;padding:10px 18px;border-radius:8px;border:none;font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap">✓ Erledigt</button>
</div>
<!-- Übung 3: Ausstehend — kommt als nächstes -->
<div style="padding:14px;display:flex;align-items:center;gap:14px;border-bottom:1px solid #f1f5f9;background:#f8fafc;opacity:0.8">
<div style="width:32px;height:32px;border-radius:50%;background:#e2e8f0;display:flex;align-items:center;justify-content:center;color:#94a3b8;font-size:13px;font-weight:bold;flex-shrink:0">3</div>
<div style="flex:1">
<div style="font-weight:500;font-size:15px;color:#64748b">Burpees</div>
<div style="font-size:11px;color:#94a3b8">Startet automatisch nach Kniebeugen</div>
</div>
<div style="text-align:right">
<div style="font-size:13px;color:#94a3b8">Soll: 20s</div>
</div>
<div style="width:80px;text-align:center">
<span style="font-size:11px;color:#94a3b8;background:#f1f5f9;padding:3px 8px;border-radius:10px">Ausstehend</span>
</div>
</div>
</div>
<!-- Auto-Start Hinweis -->
<div style="margin-top:12px;padding:12px;background:#fffbeb;border:1px solid #fde047;border-radius:8px;font-size:12px;color:#92400e">
<strong>Auto-Start:</strong> Wenn Anna bei "Kniebeugen" auf "✓ Erledigt" klickt → startet "Burpees" automatisch. Der globale Timer läuft weiter. Andere Ringer (Tom) können parallel starten wenn du ihn auswählst.
</div>
<!-- Tom's Ansicht (grau weil nicht ausgewählt) -->
<div style="margin-top:24px;padding-top:16px;border-top:2px dashed #e2e8f0">
<div style="margin-bottom:12px;display:flex;align-items:center;gap:10px">
<div style="width:10px;height:10px;border-radius:50%;background:#94a3b8"></div>
<span style="font-weight:600;font-size:16px;color:#64748b">Tom Klein</span>
<span style="font-size:12px;color:#94a3b8;background:#f1f5f9;padding:3px 10px;border-radius:10px">0/3</span>
<span style="font-size:11px;color:#94a3b8;margin-left:4px">(klicken zum Starten)</span>
</div>
<div style="border:1px solid #e2e8f0;border-radius:10px;overflow:hidden;opacity:0.6">
<div style="padding:14px;display:flex;align-items:center;gap:14px;border-bottom:1px solid #f1f5f9;background:#f8fafc">
<div style="width:32px;height:32px;border-radius:50%;background:#e2e8f0;display:flex;align-items:center;justify-content:center;color:#94a3b8;font-size:13px;font-weight:bold;flex-shrink:0">1</div>
<div style="flex:1">
<div style="font-weight:500;font-size:14px;color:#64748b">Liegestütze</div>
</div>
<div style="text-align:right">
<div style="font-size:13px;color:#94a3b8">Soll: 30s</div>
</div>
<button style="background:#64748b;color:white;padding:8px 16px;border-radius:8px;border:none;font-size:13px;cursor:not-allowed">▶ Start</button>
</div>
<div style="padding:14px;display:flex;align-items:center;gap:14px;border-bottom:1px solid #f1f5f9;background:#f8fafc">
<div style="width:32px;height:32px;border-radius:50%;background:#e2e8f0;display:flex;align-items:center;justify-content:center;color:#94a3b8;font-size:13px;font-weight:bold;flex-shrink:0">2</div>
<div style="flex:1">
<div style="font-weight:500;font-size:14px;color:#64748b">Kniebeugen</div>
</div>
<div style="text-align:right">
<div style="font-size:13px;color:#94a3b8">Soll: 45s</div>
</div>
<div style="width:80px"></div>
</div>
<div style="padding:14px;display:flex;align-items:center;gap:14px;background:#f8fafc">
<div style="width:32px;height:32px;border-radius:50%;background:#e2e8f0;display:flex;align-items:center;justify-content:center;color:#94a3b8;font-size:13px;font-weight:bold;flex-shrink:0">3</div>
<div style="flex:1">
<div style="font-weight:500;font-size:14px;color:#64748b">Burpees</div>
</div>
<div style="text-align:right">
<div style="font-size:13px;color:#94a3b8">Soll: 20s</div>
</div>
<div style="width:80px"></div>
</div>
</div>
<div style="margin-top:10px;padding:10px;background:#f8fafc;border:1px dashed #cbd5e1;border-radius:8px;text-align:center">
<span style="font-size:12px;color:#94a3b8">Klicke auf Tom in der Liste um seine Übungen zu starten</span>
</div>
</div>
</div>
</div>
@@ -0,0 +1,132 @@
<h2>Drei Ringer parallel — jeder seine eigene Zeile</h2>
<p class="subtitle">Wie eine Tabelle: pro Ringer eine Zeile. Alle 3 parallel. Eigene Zeiten pro Übung.</p>
<!-- Header: Timer -->
<div style="background:#1B1A55;border-radius:12px;padding:16px 20px;display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<div style="display:flex;align-items:center;gap-16px">
<span style="font-size:13px;font-weight:600;color:#9290C3;text-transform:uppercase;letter-spacing:1px">Gemeinsame Zeit</span>
<div style="font-size:36px;font-weight:bold;color:white;font-family:monospace;letter-spacing:2px;margin-left:24px">05:23</div>
</div>
<div style="display:flex;gap:8px">
<button style="background:rgba(255,255,255,0.15);color:white;padding:8px 18px;border-radius:8px;border:none;font-size:13px;font-weight:500;cursor:pointer">⏸ Pausieren</button>
<button style="background:#dc2626;color:white;padding:8px 18px;border-radius:8px;border:none;font-size:13px;font-weight:500;cursor:pointer">■ Training beenden</button>
</div>
</div>
<!-- WRESTLER ROWS: wie eine tabelle, aber pro ringer eine "zeile" -->
<div style="display:flex;flex-direction:column;gap:12px">
<!-- RINGER 1: Max — alle erledigt -->
<div style="border:2px solid #22c55e;border-radius:12px;overflow:hidden;background:#f0fdf4">
<div style="display:flex;align-items:center;padding:12px 16px;background:#dcfce7;border-bottom:1px solid #bbf7d0">
<div style="width:12px;height:12px;border-radius:50%;background:#22c55e;margin-right:10px;flex-shrink:0"></div>
<div style="flex:1">
<div style="font-weight:700;font-size:16px;color:#166534">Max Mustermann</div>
<div style="font-size:11px;color:#166534;margin-top:1px">✓ Alle 3 Übungen erledigt</div>
</div>
<div style="font-size:11px;color:#166534;background:#bbf7d0;padding:3px 10px;border-radius:10px;font-weight:600">3/3 ✓</div>
</div>
<div style="padding:14px 16px;display:flex;gap:10px;flex-wrap:wrap">
<div style="flex:1;min-width:120px;background:#bbf7d0;border-radius:8px;padding:10px 12px;border:1px solid #86efac">
<div style="font-size:11px;color:#166534;font-weight:600;margin-bottom:4px">Liegestütze</div>
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:10px;color:#166534">Soll: 30s</span>
<span style="font-size:13px;font-weight:700;color:#166534">✓ 0:28</span>
</div>
</div>
<div style="flex:1;min-width:120px;background:#bbf7d0;border-radius:8px;padding:10px 12px;border:1px solid #86efac">
<div style="font-size:11px;color:#166534;font-weight:600;margin-bottom:4px">Kniebeugen</div>
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:10px;color:#166534">Soll: 45s</span>
<span style="font-size:13px;font-weight:700;color:#166534">✓ 0:44</span>
</div>
</div>
<div style="flex:1;min-width:120px;background:#bbf7d0;border-radius:8px;padding:10px 12px;border:1px solid #86efac">
<div style="font-size:11px;color:#166534;font-weight:600;margin-bottom:4px">Burpees</div>
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:10px;color:#166534">Soll: 20s</span>
<span style="font-size:13px;font-weight:700;color:#166534">✓ 0:19</span>
</div>
</div>
</div>
</div>
<!-- RINGER 2: Anna — aktiv -->
<div style="border:2px solid #1B1A55;border-radius:12px;overflow:hidden;background:#fff">
<div style="display:flex;align-items:center;padding:12px 16px;background:#f8fafc;border-bottom:1px solid #e2e8f0">
<div style="width:12px;height:12px;border-radius:50%;background:#1B1A55;margin-right:10px;flex-shrink:0"></div>
<div style="flex:1">
<div style="font-weight:700;font-size:16px;color:#1B1A55">Anna Schmidt</div>
<div style="font-size:11px;color:#1B1A55;margin-top:1px">Aktive Übung: Kniebeugen</div>
</div>
<div style="font-size:11px;color:#a16207;background:#fef9c3;padding:3px 10px;border-radius:10px;font-weight:600">2/3</div>
</div>
<div style="padding:14px 16px;display:flex;gap:10px;flex-wrap:wrap">
<!-- Erledigt -->
<div style="flex:1;min-width:120px;background:#dcfce7;border-radius:8px;padding:10px 12px;border:1px solid #86efac">
<div style="font-size:11px;color:#166534;font-weight:600;margin-bottom:4px">Liegestütze</div>
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:10px;color:#166534">Soll: 30s</span>
<span style="font-size:13px;font-weight:700;color:#166534">✓ 0:28</span>
</div>
</div>
<!-- Aktiv: läuft -->
<div style="flex:1;min-width:160px;background:#fef9c3;border-radius:8px;padding:12px 14px;border:2px solid #eab308">
<div style="font-size:11px;color:#a16207;font-weight:600;margin-bottom:6px">▶ Kniebeugen</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span style="font-size:10px;color:#a16207">Soll: 45s</span>
<span style="font-size:18px;font-weight:700;color:#a16207;font-family:monospace">0:31</span>
</div>
<button style="width:100%;background:#166534;color:white;padding:7px;border-radius:6px;border:none;font-size:12px;font-weight:600;cursor:pointer">✓ Erledigt</button>
</div>
<!-- Ausstehend -->
<div style="flex:1;min-width:120px;background:#f8fafc;border-radius:8px;padding:10px 12px;border:1px solid #e2e8f0;opacity:0.7">
<div style="font-size:11px;color:#94a3b8;font-weight:600;margin-bottom:4px">Burpees</div>
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:10px;color:#94a3b8">Soll: 20s</span>
<span style="font-size:12px;color:#94a3b8">Ausstehend</span>
</div>
</div>
</div>
</div>
<!-- RINGER 3: Tom — Start-Buttons -->
<div style="border:2px solid #e2e8f0;border-radius:12px;overflow:hidden;background:#f8fafc">
<div style="display:flex;align-items:center;padding:12px 16px;background:#f1f5f9;border-bottom:1px solid #e2e8f0">
<div style="width:12px;height:12px;border-radius:50%;background:#94a3b8;margin-right:10px;flex-shrink:0"></div>
<div style="flex:1">
<div style="font-weight:600;font-size:16px;color:#64748b">Tom Klein</div>
<div style="font-size:11px;color:#94a3b8;margin-top:1px">Noch nicht gestartet</div>
</div>
<div style="font-size:11px;color:#94a3b8;background:#e2e8f0;padding:3px 10px;border-radius:10px;font-weight:600">0/3</div>
</div>
<div style="padding:14px 16px;display:flex;gap:10px;flex-wrap:wrap">
<div style="flex:1;min-width:140px;background:#fff;border-radius:8px;padding:10px 12px;border:1px solid #e2e8f0">
<div style="font-size:11px;color:#64748b;font-weight:600;margin-bottom:6px">Liegestütze</div>
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:10px;color:#94a3b8">Soll: 30s</span>
<button style="background:#1B1A55;color:white;padding:6px 12px;border-radius:6px;border:none;font-size:11px;font-weight:600;cursor:pointer">▶ Start</button>
</div>
</div>
<div style="flex:1;min-width:140px;background:#fff;border-radius:8px;padding:10px 12px;border:1px solid #e2e8f0">
<div style="font-size:11px;color:#64748b;font-weight:600;margin-bottom:6px">Kniebeugen</div>
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:10px;color:#94a3b8">Soll: 45s</span>
<button style="background:#e2e8f0;color:#94a3b8;padding:6px 12px;border-radius:6px;border:none;font-size:11px;font-weight:600;cursor:not-allowed">▶ Start</button>
</div>
</div>
<div style="flex:1;min-width:140px;background:#fff;border-radius:8px;padding:10px 12px;border:1px solid #e2e8f0">
<div style="font-size:11px;color:#64748b;font-weight:600;margin-bottom:6px">Burpees</div>
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:10px;color:#94a3b8">Soll: 20s</span>
<button style="background:#e2e8f0;color:#94a3b8;padding:6px 12px;border-radius:6px;border:none;font-size:11px;font-weight:600;cursor:not-allowed">▶ Start</button>
</div>
</div>
</div>
</div>
</div>
<div style="margin-top:14px;padding:12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;font-size:12px;color:#64748b">
<strong>So funktioniert's:</strong> Alle 3 Ringer arbeiten parallel. Anna hat gerade Kniebeugen aktiv (gelb) — nach dem Klick auf "✓ Erledigt" startet Burpees automatisch. Tom kann jederzeit auf "▶ Start" bei Liegestütze klicken um seine erste Übung zu starten. Der globale Timer läuft für alle gemeinsam.
</div>
@@ -0,0 +1,130 @@
<h2>Timer start → alle beginnen Übung 1 automatisch</h2>
<p class="subtitle">Soll = Reps (z.B. "3x10"). Trainer klickt "Erledigt" pro Ringer wenn fertig.</p>
<!-- Header: Timer + Start -->
<div style="background:#1B1A55;border-radius:12px;padding:16px 20px;display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<div style="display:flex;align-items:center;gap:24px">
<span style="font-size:13px;font-weight:600;color:#9290C3;text-transform:uppercase;letter-spacing:1px">Gemeinsame Zeit</span>
<div style="font-size:36px;font-weight:bold;color:white;font-family:monospace;letter-spacing:2px">00:00</div>
</div>
<button style="background:#22c55e;color:white;padding:10px 24px;border-radius:8px;border:none;font-size:14px;font-weight:600;cursor:pointer">▶ Training starten</button>
</div>
<!-- WRESTLER ROWS -->
<div style="display:flex;flex-direction:column;gap:12px">
<!-- RINGER 1: Max — Übung 1 läuft gerade -->
<div style="border:2px solid #eab308;border-radius:12px;overflow:hidden;background:#fff">
<div style="display:flex;align-items:center;padding:12px 16px;background:#fef9c3;border-bottom:1px solid #fde047">
<div style="width:12px;height:12px;border-radius:50%;background:#eab308;margin-right:10px;flex-shrink:0"></div>
<div style="flex:1">
<div style="font-weight:700;font-size:16px;color:#a16207">Max Mustermann</div>
<div style="font-size:11px;color:#a16207;margin-top:1px">Übung 1 läuft...</div>
</div>
<div style="font-size:11px;color:#a16207;background:#fef3c7;padding:3px 10px;border-radius:10px;font-weight:600">0/3</div>
</div>
<div style="padding:14px 16px;display:flex;gap:10px;flex-wrap:wrap">
<!-- Übung 1: Aktiv -->
<div style="flex:1;min-width:160px;background:#fef9c3;border-radius:8px;padding:12px 14px;border:2px solid #eab308">
<div style="font-size:11px;color:#a16207;font-weight:600;margin-bottom:6px">▶ Liegestütze</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span style="font-size:11px;color:#a16207;font-weight:500">Soll: <strong>3×10</strong></span>
<span style="font-size:20px;font-weight:700;color:#a16207;font-family:monospace">0:28</span>
</div>
<button style="width:100%;background:#166534;color:white;padding:8px;border-radius:6px;border:none;font-size:13px;font-weight:600;cursor:pointer">✓ Erledigt</button>
</div>
<!-- Übung 2+3: Wartend -->
<div style="flex:1;min-width:120px;background:#f8fafc;border-radius:8px;padding:10px 12px;border:1px solid #e2e8f0;opacity:0.6">
<div style="font-size:11px;color:#94a3b8;font-weight:600;margin-bottom:4px">Kniebeugen</div>
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:11px;color:#94a3b8">Soll: 3×15</span>
<span style="font-size:11px;color:#94a3b8"></span>
</div>
</div>
<div style="flex:1;min-width:120px;background:#f8fafc;border-radius:8px;padding:10px 12px;border:1px solid #e2e8f0;opacity:0.6">
<div style="font-size:11px;color:#94a3b8;font-weight:600;margin-bottom:4px">Burpees</div>
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:11px;color:#94a3b8">Soll: 3×20</span>
<span style="font-size:11px;color:#94a3b8"></span>
</div>
</div>
</div>
</div>
<!-- RINGER 2: Anna — Übung 1 läuft -->
<div style="border:2px solid #eab308;border-radius:12px;overflow:hidden;background:#fff">
<div style="display:flex;align-items:center;padding:12px 16px;background:#fef9c3;border-bottom:1px solid #fde047">
<div style="width:12px;height:12px;border-radius:50%;background:#eab308;margin-right:10px;flex-shrink:0"></div>
<div style="flex:1">
<div style="font-weight:700;font-size:16px;color:#a16207">Anna Schmidt</div>
<div style="font-size:11px;color:#a16207;margin-top:1px">Übung 1 läuft...</div>
</div>
<div style="font-size:11px;color:#a16207;background:#fef3c7;padding:3px 10px;border-radius:10px;font-weight:600">0/3</div>
</div>
<div style="padding:14px 16px;display:flex;gap:10px;flex-wrap:wrap">
<div style="flex:1;min-width:160px;background:#fef9c3;border-radius:8px;padding:12px 14px;border:2px solid #eab308">
<div style="font-size:11px;color:#a16207;font-weight:600;margin-bottom:6px">▶ Liegestütze</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span style="font-size:11px;color:#a16207;font-weight:500">Soll: <strong>3×10</strong></span>
<span style="font-size:20px;font-weight:700;color:#a16207;font-family:monospace">0:31</span>
</div>
<button style="width:100%;background:#166534;color:white;padding:8px;border-radius:6px;border:none;font-size:13px;font-weight:600;cursor:pointer">✓ Erledigt</button>
</div>
<div style="flex:1;min-width:120px;background:#f8fafc;border-radius:8px;padding:10px 12px;border:1px solid #e2e8f0;opacity:0.6">
<div style="font-size:11px;color:#94a3b8;font-weight:600;margin-bottom:4px">Kniebeugen</div>
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:11px;color:#94a3b8">Soll: 3×15</span>
<span style="font-size:11px;color:#94a3b8"></span>
</div>
</div>
<div style="flex:1;min-width:120px;background:#f8fafc;border-radius:8px;padding:10px 12px;border:1px solid #e2e8f0;opacity:0.6">
<div style="font-size:11px;color:#94a3b8;font-weight:600;margin-bottom:4px">Burpees</div>
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:11px;color:#94a3b8">Soll: 3×20</span>
<span style="font-size:11px;color:#94a3b8"></span>
</div>
</div>
</div>
</div>
<!-- RINGER 3: Tom — Übung 1 läuft -->
<div style="border:2px solid #eab308;border-radius:12px;overflow:hidden;background:#fff">
<div style="display:flex;align-items:center;padding:12px 16px;background:#fef9c3;border-bottom:1px solid #fde047">
<div style="width:12px;height:12px;border-radius:50%;background:#eab308;margin-right:10px;flex-shrink:0"></div>
<div style="flex:1">
<div style="font-weight:700;font-size:16px;color:#a16207">Tom Klein</div>
<div style="font-size:11px;color:#a16207;margin-top:1px">Übung 1 läuft...</div>
</div>
<div style="font-size:11px;color:#a16207;background:#fef3c7;padding:3px 10px;border-radius:10px;font-weight:600">0/3</div>
</div>
<div style="padding:14px 16px;display:flex;gap:10px;flex-wrap:wrap">
<div style="flex:1;min-width:160px;background:#fef9c3;border-radius:8px;padding:12px 14px;border:2px solid #eab308">
<div style="font-size:11px;color:#a16207;font-weight:600;margin-bottom:6px">▶ Liegestütze</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span style="font-size:11px;color:#a16207;font-weight:500">Soll: <strong>3×10</strong></span>
<span style="font-size:20px;font-weight:700;color:#a16207;font-family:monospace">0:22</span>
</div>
<button style="width:100%;background:#166534;color:white;padding:8px;border-radius:6px;border:none;font-size:13px;font-weight:600;cursor:pointer">✓ Erledigt</button>
</div>
<div style="flex:1;min-width:120px;background:#f8fafc;border-radius:8px;padding:10px 12px;border:1px solid #e2e8f0;opacity:0.6">
<div style="font-size:11px;color:#94a3b8;font-weight:600;margin-bottom:4px">Kniebeugen</div>
<div style="display:flex;justify-content;between;align-items:center">
<span style="font-size:11px;color:#94a3b8">Soll: 3×15</span>
<span style="font-size:11px;color:#94a3b8"></span>
</div>
</div>
<div style="flex:1;min-width:120px;background:#f8fafc;border-radius:8px;padding:10px 12px;border:1px solid #e2e8f0;opacity:0.6">
<div style="font-size:11px;color:#94a3b8;font-weight:600;margin-bottom:4px">Burpees</div>
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:11px;color:#94a3b8">Soll: 3×20</span>
<span style="font-size:11px;color:#94a3b8"></span>
</div>
</div>
</div>
</div>
</div>
<div style="margin-top:14px;padding:12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;font-size:12px;color:#1e40af">
<strong>Flow:</strong> Trainer klickt "▶ Training starten" → ALLE 3 Ringer beginnen gleichzeitig Übung 1 ("Liegestütze 3×10"). Der Timer läuft. Trainer klickt bei Max auf "✓ Erledigt" → Zeit wird gespeichert (0:28), Max's nächste Übung (Kniebeugen) startet automatisch. Anna und Tom sind noch bei Übung 1.
</div>
@@ -0,0 +1,237 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Option 1: Karten-Ansicht</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', sans-serif; background: #f8fafc; padding: 24px; }
.container { max-width: 900px; margin: 0 auto; }
.toggle-bar { display: flex; gap: 8px; margin-bottom: 20px; background: #e2e8f0; padding: 4px; border-radius: 8px; width: fit-content; }
.toggle-btn { padding: 8px 20px; border-radius: 6px; border: none; font-size: 13px; font-weight: 600; cursor: pointer; background: transparent; color: #64748b; }
.toggle-btn.active { background: white; color: #1e293b; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; }
select { padding: 8px 12px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 13px; background: white; min-width: 180px; }
.cards { display: flex; flex-direction: column; gap: 12px; }
.card { background: white; border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; transition: box-shadow 0.2s; }
.card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.card-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #f1f5f9; }
.card-header-left { display: flex; align-items: center; gap: 12px; }
.wrestler-avatar { width: 40px; height: 40px; border-radius: 50%; background: #1B1A55; color: white; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 16px; }
.wrestler-info .name { font-weight: 700; font-size: 15px; color: #1e293b; }
.wrestler-info .meta { font-size: 12px; color: #64748b; margin-top: 2px; }
.card-header-right { text-align: right; }
.score { font-size: 28px; font-weight: 800; color: #16a34a; }
.score.low { color: #dc2626; }
.score.medium { color: #ca8a04; }
.score.high { color: #16a34a; }
.time-badge { font-size: 11px; color: #64748b; margin-top: 2px; }
.card-body { padding: 14px 16px; }
.template-name { font-size: 12px; font-weight: 600; color: #64748b; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
.exercises { display: flex; flex-direction: column; gap: 6px; }
.exercise-row { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; background: #f8fafc; border-radius: 6px; }
.exercise-row.done { background: #f0fdf4; }
.exercise-name { font-size: 13px; font-weight: 500; color: #374151; }
.exercise-right { display: flex; align-items: center; gap: 12px; }
.exercise-reps { font-size: 12px; color: #64748b; }
.exercise-time { font-size: 12px; font-weight: 600; color: #16a34a; min-width: 40px; text-align: right; }
.exercise-check { width: 16px; height: 16px; background: #16a34a; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 10px; }
.card-footer { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; background: #f8fafc; border-top: 1px solid #f1f5f9; }
.rating { display: flex; gap: 2px; }
.star { width: 14px; height: 14px; fill: #fbbf24; }
.star.empty { fill: #e2e8f0; }
.actions { display: flex; gap: 6px; }
.action-btn { padding: 6px 10px; border: 1px solid #e2e8f0; border-radius: 4px; background: white; font-size: 12px; cursor: pointer; display: flex; align-items: center; gap: 4px; }
.action-btn:hover { background: #f1f5f9; }
.expand-hint { font-size: 11px; color: #94a3b8; text-align: center; padding: 8px; }
</style>
</head>
<body>
<div class="container">
<h2 style="font-size:18px;font-weight:700;color:#1e293b;margin-bottom:16px">Ergebnisse — Option 1: Karten-Ansicht</h2>
<div class="toggle-bar">
<button class="toggle-btn active">📋 Karten</button>
<button class="toggle-btn">⊞ Tabelle</button>
</div>
<div class="filter-bar">
<select><option>Alle Vorlagen</option></select>
<select><option>Alle Ringe</option></select>
</div>
<div class="cards">
<!-- CARD 1: Perfect score -->
<div class="card">
<div class="card-header">
<div class="card-header-left">
<div class="wrestler-avatar">M</div>
<div class="wrestler-info">
<div class="name">Max Mustermann</div>
<div class="meta">Heute, 14:32 · Krafttest Februar</div>
</div>
</div>
<div class="card-header-right">
<div class="score high">100%</div>
<div class="time-badge">5:42 min</div>
</div>
</div>
<div class="card-body">
<div class="template-name">Übungen</div>
<div class="exercises">
<div class="exercise-row done">
<span class="exercise-name">Liegestütze</span>
<div class="exercise-right">
<span class="exercise-reps">3×10</span>
<span class="exercise-time">1:15</span>
<span class="exercise-check"></span>
</div>
</div>
<div class="exercise-row done">
<span class="exercise-name">Kniebeugen</span>
<div class="exercise-right">
<span class="exercise-reps">3×15</span>
<span class="exercise-time">2:30</span>
<span class="exercise-check"></span>
</div>
</div>
<div class="exercise-row done">
<span class="exercise-name">Burpees</span>
<div class="exercise-right">
<span class="exercise-reps">3×20</span>
<span class="exercise-time">1:57</span>
<span class="exercise-check"></span>
</div>
</div>
</div>
</div>
<div class="card-footer">
<div class="rating">
<span class="star"></span><span class="star"></span><span class="star"></span><span class="star"></span><span class="star empty"></span>
</div>
<div class="actions">
<button class="action-btn">✏️ Bearbeiten</button>
<button class="action-btn" style="color:#dc2626">🗑 Löschen</button>
</div>
</div>
</div>
<!-- CARD 2: Medium score -->
<div class="card">
<div class="card-header">
<div class="card-header-left">
<div class="wrestler-avatar" style="background:#7c3aed">A</div>
<div class="wrestler-info">
<div class="name">Anna Schmidt</div>
<div class="meta">Gestern, 10:15 · Krafttest Februar</div>
</div>
</div>
<div class="card-header-right">
<div class="score medium">87%</div>
<div class="time-badge">6:20 min</div>
</div>
</div>
<div class="card-body">
<div class="template-name">Übungen</div>
<div class="exercises">
<div class="exercise-row done">
<span class="exercise-name">Liegestütze</span>
<div class="exercise-right">
<span class="exercise-reps">3×10</span>
<span class="exercise-time">1:30</span>
<span class="exercise-check"></span>
</div>
</div>
<div class="exercise-row done">
<span class="exercise-name">Kniebeugen</span>
<div class="exercise-right">
<span class="exercise-reps">3×15</span>
<span class="exercise-time">3:00</span>
<span class="exercise-check"></span>
</div>
</div>
<div class="exercise-row" style="opacity:0.5">
<span class="exercise-name">Burpees</span>
<div class="exercise-right">
<span class="exercise-reps">3×20</span>
<span class="exercise-time" style="color:#94a3b8"></span>
<span style="width:16px;height:16px;border-radius:50%;border:2px solid #e2e8f0;display:inline-block"></span>
</div>
</div>
</div>
</div>
<div class="card-footer">
<div class="rating">
<span class="star"></span><span class="star"></span><span class="star"></span><span class="star empty"></span><span class="star empty"></span>
</div>
<div class="actions">
<button class="action-btn">✏️ Bearbeiten</button>
<button class="action-btn" style="color:#dc2626">🗑 Löschen</button>
</div>
</div>
</div>
<!-- CARD 3: Low score -->
<div class="card">
<div class="card-header">
<div class="card-header-left">
<div class="wrestler-avatar" style="background:#dc2626">T</div>
<div class="wrestler-info">
<div class="name">Tom Klein</div>
<div class="meta">23.03.26, 16:45 · Krafttest Februar</div>
</div>
</div>
<div class="card-header-right">
<div class="score low">65%</div>
<div class="time-badge">8:15 min</div>
</div>
</div>
<div class="card-body">
<div class="template-name">Übungen</div>
<div class="exercises">
<div class="exercise-row done">
<span class="exercise-name">Liegestütze</span>
<div class="exercise-right">
<span class="exercise-reps">3×10</span>
<span class="exercise-time">2:00</span>
<span class="exercise-check"></span>
</div>
</div>
<div class="exercise-row" style="opacity:0.5">
<span class="exercise-name">Kniebeugen</span>
<div class="exercise-right">
<span class="exercise-reps">3×15</span>
<span class="exercise-time" style="color:#94a3b8"></span>
<span style="width:16px;height:16px;border-radius:50%;border:2px solid #e2e8f0;display:inline-block"></span>
</div>
</div>
<div class="exercise-row" style="opacity:0.5">
<span class="exercise-name">Burpees</span>
<div class="exercise-right">
<span class="exercise-reps">3×20</span>
<span class="exercise-time" style="color:#94a3b8"></span>
<span style="width:16px;height:16px;border-radius:50%;border:2px solid #e2e8f0;display:inline-block"></span>
</div>
</div>
</div>
</div>
<div class="card-footer">
<div class="rating">
<span class="star"></span><span class="star"></span><span class="star empty"></span><span class="star empty"></span><span class="star empty"></span>
</div>
<div class="actions">
<button class="action-btn">✏️ Bearbeiten</button>
<button class="action-btn" style="color:#dc2626">🗑 Löschen</button>
</div>
</div>
</div>
</div>
<div style="margin-top:24px;padding:12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;font-size:13px;color:#1e40af">
<strong>Vorteile:</strong> Pro Ergebnis eine übersichtliche Karte mit allen Details (Ringer-Foto, Score, Zeit, alle Übungen mit Zeiten). Klick auf Karte zeigt Details. Farbcodierung: Grün/Gelb/Rot nach Score.
</div>
</div>
</body>
</html>
@@ -0,0 +1,191 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Option 2: Erweiterte Tabelle</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', sans-serif; background: #f8fafc; padding: 24px; }
.container { max-width: 1100px; margin: 0 auto; }
.toggle-bar { display: flex; gap: 8px; margin-bottom: 20px; background: #e2e8f0; padding: 4px; border-radius: 8px; width: fit-content; }
.toggle-btn { padding: 8px 20px; border-radius: 6px; border: none; font-size: 13px; font-weight: 600; cursor: pointer; background: transparent; color: #64748b; }
.toggle-btn.active { background: white; color: #1e293b; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; }
select { padding: 8px 12px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 13px; background: white; min-width: 160px; }
.table-wrap { background: white; border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; }
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; padding: 12px 14px; text-align: left; font-size: 11px; font-weight: 700; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid #e2e8f0; }
td { padding: 14px; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafafa; }
.wrestler-cell { display: flex; align-items: center; gap: 10px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: #1B1A55; color: white; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 13px; flex-shrink: 0; }
.wrestler-name { font-weight: 600; font-size: 14px; color: #1e293b; }
.date { font-size: 12px; color: #94a3b8; margin-top: 2px; }
.score-badge { display: inline-flex; align-items: center; justify-content: center; padding: 4px 10px; border-radius: 20px; font-size: 13px; font-weight: 700; min-width: 52px; }
.score-badge.high { background: #dcfce7; color: #16a34a; }
.score-badge.medium { background: #fef9c3; color: #ca8a04; }
.score-badge.low { background: #fee2e2; color: #dc2626; }
.time { font-size: 13px; color: #475569; font-family: monospace; }
.rating { color: #fbbf24; font-size: 13px; letter-spacing: 1px; }
.actions { display: flex; gap: 4px; }
.action-btn { width: 28px; height: 28px; border: 1px solid #e2e8f0; border-radius: 4px; background: white; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 12px; }
.action-btn:hover { background: #f1f5f9; }
.expand-btn { padding: 4px 8px; border: 1px solid #e2e8f0; border-radius: 4px; background: white; font-size: 11px; cursor: pointer; color: #64748b; }
.expand-btn:hover { background: #f1f5f9; }
.expanded-row td { background: #fafafa; padding: 0; }
.exercise-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; padding: 12px 14px; border-top: 1px solid #f1f5f9; }
.exercise-card { background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 12px; }
.exercise-card.done { border-color: #86efac; background: #f0fdf4; }
.exercise-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
.exercise-name { font-size: 13px; font-weight: 600; color: #374151; }
.exercise-check { width: 18px; height: 18px; background: #16a34a; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 10px; }
.exercise-meta { display: flex; justify-content: space-between; font-size: 12px; color: #64748b; }
.exercise-time { font-weight: 600; color: #16a34a; }
</style>
</head>
<body>
<div class="container">
<h2 style="font-size:18px;font-weight:700;color:#1e293b;margin-bottom:16px">Ergebnisse — Option 2: Erweiterte Tabelle</h2>
<div class="toggle-bar">
<button class="toggle-btn">📋 Karten</button>
<button class="toggle-btn active">⊞ Tabelle</button>
</div>
<div class="filter-bar">
<select><option>Alle Vorlagen</option></select>
<select><option>Alle Ringer</option></select>
<select><option>Neueste zuerst</option></select>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:220px">Ringer</th>
<th style="width:80px">Score</th>
<th style="width:80px">Zeit</th>
<th style="width:60px">Bew.</th>
<th style="width:100px">Aktionen</th>
<th style="width:60px"></th>
</tr>
</thead>
<tbody>
<!-- ROW 1: Expanded -->
<tr>
<td>
<div class="wrestler-cell">
<div class="avatar">M</div>
<div>
<div class="wrestler-name">Max Mustermann</div>
<div class="date">24.03.26 · Krafttest Februar</div>
</div>
</div>
</td>
<td><span class="score-badge high">100%</span></td>
<td><span class="time">5:42</span></td>
<td><span class="rating">★★★★☆</span></td>
<td>
<div class="actions">
<button class="action-btn" title="Bearbeiten">✏️</button>
<button class="action-btn" title="Löschen">🗑️</button>
</div>
</td>
<td><button class="expand-btn">▲ Details</button></td>
</tr>
<!-- EXPANDED DETAILS -->
<tr class="expanded-row">
<td colspan="6">
<div class="exercise-grid">
<div class="exercise-card done">
<div class="exercise-header">
<span class="exercise-name">Liegestütze</span>
<span class="exercise-check"></span>
</div>
<div class="exercise-meta">
<span>Soll: 3×10</span>
<span class="exercise-time">1:15</span>
</div>
</div>
<div class="exercise-card done">
<div class="exercise-header">
<span class="exercise-name">Kniebeugen</span>
<span class="exercise-check"></span>
</div>
<div class="exercise-meta">
<span>Soll: 3×15</span>
<span class="exercise-time">2:30</span>
</div>
</div>
<div class="exercise-card done">
<div class="exercise-header">
<span class="exercise-name">Burpees</span>
<span class="exercise-check"></span>
</div>
<div class="exercise-meta">
<span>Soll: 3×20</span>
<span class="exercise-time">1:57</span>
</div>
</div>
</div>
</td>
</tr>
<!-- ROW 2 -->
<tr>
<td>
<div class="wrestler-cell">
<div class="avatar" style="background:#7c3aed">A</div>
<div>
<div class="wrestler-name">Anna Schmidt</div>
<div class="date">23.03.26 · Krafttest Februar</div>
</div>
</div>
</td>
<td><span class="score-badge medium">87%</span></td>
<td><span class="time">6:20</span></td>
<td><span class="rating">★★★☆☆</span></td>
<td>
<div class="actions">
<button class="action-btn" title="Bearbeiten">✏️</button>
<button class="action-btn" title="Löschen">🗑️</button>
</div>
</td>
<td><button class="expand-btn">▼ Details</button></td>
</tr>
<!-- ROW 3 -->
<tr>
<td>
<div class="wrestler-cell">
<div class="avatar" style="background:#dc2626">T</div>
<div>
<div class="wrestler-name">Tom Klein</div>
<div class="date">23.03.26 · Krafttest Februar</div>
</div>
</div>
</td>
<td><span class="score-badge low">65%</span></td>
<td><span class="time">8:15</span></td>
<td><span class="rating">★★☆☆☆</span></td>
<td>
<div class="actions">
<button class="action-btn" title="Bearbeiten">✏️</button>
<button class="action-btn" title="Löschen">🗑️</button>
</div>
</td>
<td><button class="expand-btn">▼ Details</button></td>
</tr>
</tbody>
</table>
</div>
<div style="margin-top:24px;padding:12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;font-size:13px;color:#1e40af">
<strong>Vorteile:</strong> Kompakte Tabellenansicht — wie gewohnt, aber mit "Details" Toggle pro Zeile um alle Übungen mit Zeiten anzuzeigen. Gut für Vergleiche nebeneinander.
</div>
</div>
</body>
</html>
@@ -0,0 +1,228 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ergebnisse — Toggle Ansicht</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', sans-serif; background: #f8fafc; padding: 24px; }
.container { max-width: 1100px; margin: 0 auto; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.page-title { font-size: 20px; font-weight: 700; color: #1e293b; }
.toggle-bar { display: flex; gap: 8px; background: #e2e8f0; padding: 4px; border-radius: 8px; }
.toggle-btn { padding: 8px 20px; border-radius: 6px; border: none; font-size: 13px; font-weight: 600; cursor: pointer; background: transparent; color: #64748b; transition: all 0.15s; }
.toggle-btn.active { background: white; color: #1e293b; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; }
select { padding: 8px 12px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 13px; background: white; min-width: 160px; }
/* TABLE VIEW */
.table-wrap { background: white; border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; }
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; padding: 12px 14px; text-align: left; font-size: 11px; font-weight: 700; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid #e2e8f0; }
td { padding: 14px; border-bottom: 1px solid #f1f5f9; vertical-align: top; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #fafafa; }
.wrestler-cell { display: flex; align-items: center; gap: 10px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: #1B1A55; color: white; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 13px; flex-shrink: 0; }
.wrestler-name { font-weight: 600; font-size: 14px; color: #1e293b; }
.date { font-size: 12px; color: #94a3b8; margin-top: 2px; }
.score-badge { display: inline-flex; align-items: center; justify-content: center; padding: 4px 10px; border-radius: 20px; font-size: 13px; font-weight: 700; min-width: 52px; }
.score-badge.high { background: #dcfce7; color: #16a34a; }
.score-badge.medium { background: #fef9c3; color: #ca8a04; }
.score-badge.low { background: #fee2e2; color: #dc2626; }
.time { font-size: 13px; color: #475569; font-family: monospace; }
.rating { color: #fbbf24; font-size: 13px; letter-spacing: 1px; }
.actions { display: flex; gap: 4px; }
.action-btn { width: 28px; height: 28px; border: 1px solid #e2e8f0; border-radius: 4px; background: white; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 12px; }
.action-btn:hover { background: #f1f5f9; }
.expand-btn { padding: 4px 8px; border: 1px solid #e2e8f0; border-radius: 4px; background: white; font-size: 11px; cursor: pointer; color: #64748b; }
.expand-btn:hover { background: #f1f5f9; }
.expanded-row td { background: #fafafa; padding: 0; }
.exercise-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; padding: 12px 14px; border-top: 1px solid #f1f5f9; }
.exercise-card { background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 12px; }
.exercise-card.done { border-color: #86efac; background: #f0fdf4; }
.exercise-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
.exercise-name { font-size: 13px; font-weight: 600; color: #374151; }
.exercise-check { width: 18px; height: 18px; background: #16a34a; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 10px; }
.exercise-meta { display: flex; justify-content: space-between; font-size: 12px; color: #64748b; }
.exercise-time { font-weight: 600; color: #16a34a; }
/* CARD VIEW */
.cards { display: flex; flex-direction: column; gap: 12px; }
.card { background: white; border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; transition: box-shadow 0.2s; }
.card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.card-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #f1f5f9; }
.card-header-left { display: flex; align-items: center; gap: 12px; }
.card-wrestler-avatar { width: 40px; height: 40px; border-radius: 50%; background: #1B1A55; color: white; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 16px; }
.card-wrestler-info .name { font-weight: 700; font-size: 15px; color: #1e293b; }
.card-wrestler-info .meta { font-size: 12px; color: #64748b; margin-top: 2px; }
.card-header-right { text-align: right; }
.card-score { font-size: 28px; font-weight: 800; }
.card-score.high { color: #16a34a; }
.card-score.medium { color: #ca8a04; }
.card-score.low { color: #dc2626; }
.card-time-badge { font-size: 11px; color: #64748b; margin-top: 2px; }
.card-body { padding: 14px 16px; }
.card-template-name { font-size: 12px; font-weight: 600; color: #64748b; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
.card-exercises { display: flex; flex-direction: column; gap: 6px; }
.card-exercise-row { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; background: #f8fafc; border-radius: 6px; }
.card-exercise-row.done { background: #f0fdf4; }
.card-exercise-name { font-size: 13px; font-weight: 500; color: #374151; }
.card-exercise-right { display: flex; align-items: center; gap: 12px; }
.card-exercise-reps { font-size: 12px; color: #64748b; }
.card-exercise-time { font-size: 12px; font-weight: 600; color: #16a34a; min-width: 40px; text-align: right; }
.card-exercise-check { width: 16px; height: 16px; background: #16a34a; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 10px; }
.card-footer { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; background: #f8fafc; border-top: 1px solid #f1f5f9; }
.card-rating { display: flex; gap: 2px; }
.card-star { width: 14px; height: 14px; fill: #fbbf24; }
.card-star.empty { fill: #e2e8f0; }
.card-actions { display: flex; gap: 6px; }
.card-action-btn { padding: 6px 10px; border: 1px solid #e2e8f0; border-radius: 4px; background: white; font-size: 12px; cursor: pointer; display: flex; align-items: center; gap: 4px; }
.card-action-btn:hover { background: #f1f5f9; }
.hidden { display: none !important; }
</style>
</head>
<body>
<div class="container">
<div class="page-header">
<h1 class="page-title">Ergebnisse</h1>
<div class="toggle-bar">
<button class="toggle-btn" id="tableBtn" onclick="showTable()">⊞ Tabelle</button>
<button class="toggle-btn" id="cardBtn" onclick="showCards()">📋 Karten</button>
</div>
</div>
<div class="filter-bar">
<select><option>Alle Vorlagen</option><option>Krafttest Februar</option></select>
<select><option>Alle Ringer</option></select>
<select><option>Neueste zuerst</option></select>
</div>
<!-- TABLE VIEW (default) -->
<div id="tableView">
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:220px">Ringer</th>
<th style="width:80px">Score</th>
<th style="width:80px">Zeit</th>
<th style="width:60px">Bew.</th>
<th style="width:100px">Aktionen</th>
<th style="width:70px"></th>
</tr>
</thead>
<tbody>
<tr>
<td><div class="wrestler-cell"><div class="avatar">M</div><div><div class="wrestler-name">Max Mustermann</div><div class="date">24.03.26 · Krafttest Februar</div></div></div></td>
<td><span class="score-badge high">100%</span></td>
<td><span class="time">5:42</span></td>
<td><span class="rating">★★★★☆</span></td>
<td><div class="actions"><button class="action-btn">✏️</button><button class="action-btn">🗑️</button></div></td>
<td><button class="expand-btn">▼ Details</button></td>
</tr>
<tr class="expanded-row">
<td colspan="6">
<div class="exercise-grid">
<div class="exercise-card done"><div class="exercise-header"><span class="exercise-name">Liegestütze</span><span class="exercise-check"></span></div><div class="exercise-meta"><span>Soll: 3×10</span><span class="exercise-time">1:15</span></div></div>
<div class="exercise-card done"><div class="exercise-header"><span class="exercise-name">Kniebeugen</span><span class="exercise-check"></span></div><div class="exercise-meta"><span>Soll: 3×15</span><span class="exercise-time">2:30</span></div></div>
<div class="exercise-card done"><div class="exercise-header"><span class="exercise-name">Burpees</span><span class="exercise-check"></span></div><div class="exercise-meta"><span>Soll: 3×20</span><span class="exercise-time">1:57</span></div></div>
</div>
</td>
</tr>
<tr>
<td><div class="wrestler-cell"><div class="avatar" style="background:#7c3aed">A</div><div><div class="wrestler-name">Anna Schmidt</div><div class="date">23.03.26 · Krafttest Februar</div></div></div></td>
<td><span class="score-badge medium">87%</span></td>
<td><span class="time">6:20</span></td>
<td><span class="rating">★★★☆☆</span></td>
<td><div class="actions"><button class="action-btn">✏️</button><button class="action-btn">🗑️</button></div></td>
<td><button class="expand-btn">▼ Details</button></td>
</tr>
<tr>
<td><div class="wrestler-cell"><div class="avatar" style="background:#dc2626">T</div><div><div class="wrestler-name">Tom Klein</div><div class="date">23.03.26 · Krafttest Februar</div></div></div></td>
<td><span class="score-badge low">65%</span></td>
<td><span class="time">8:15</span></td>
<td><span class="rating">★★☆☆☆</span></td>
<td><div class="actions"><button class="action-btn">✏️</button><button class="action-btn">🗑️</button></div></td>
<td><button class="expand-btn">▼ Details</button></td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- CARD VIEW -->
<div id="cardView" class="hidden">
<div class="cards">
<div class="card">
<div class="card-header">
<div class="card-header-left">
<div class="card-wrestler-avatar">M</div>
<div class="card-wrestler-info"><div class="name">Max Mustermann</div><div class="meta">24.03.26 · Krafttest Februar</div></div>
</div>
<div class="card-header-right">
<div class="card-score high">100%</div>
<div class="card-time-badge">5:42 min</div>
</div>
</div>
<div class="card-body">
<div class="card-template-name">Übungen</div>
<div class="card-exercises">
<div class="card-exercise-row done"><span class="card-exercise-name">Liegestütze</span><div class="card-exercise-right"><span class="card-exercise-reps">3×10</span><span class="card-exercise-time">1:15</span><span class="card-exercise-check"></span></div></div>
<div class="card-exercise-row done"><span class="card-exercise-name">Kniebeugen</span><div class="card-exercise-right"><span class="card-exercise-reps">3×15</span><span class="card-exercise-time">2:30</span><span class="card-exercise-check"></span></div></div>
<div class="card-exercise-row done"><span class="card-exercise-name">Burpees</span><div class="card-exercise-right"><span class="card-exercise-reps">3×20</span><span class="card-exercise-time">1:57</span><span class="card-exercise-check"></span></div></div>
</div>
</div>
<div class="card-footer">
<div class="card-rating"><span class="card-star"></span><span class="card-star"></span><span class="card-star"></span><span class="card-star"></span><span class="card-star empty"></span></div>
<div class="card-actions"><button class="card-action-btn">✏️ Bearbeiten</button><button class="card-action-btn" style="color:#dc2626">🗑 Löschen</button></div>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-header-left">
<div class="card-wrestler-avatar" style="background:#7c3aed">A</div>
<div class="card-wrestler-info"><div class="name">Anna Schmidt</div><div class="meta">23.03.26 · Krafttest Februar</div></div>
</div>
<div class="card-header-right">
<div class="card-score medium">87%</div>
<div class="card-time-badge">6:20 min</div>
</div>
</div>
<div class="card-body">
<div class="card-template-name">Übungen</div>
<div class="card-exercises">
<div class="card-exercise-row done"><span class="card-exercise-name">Liegestütze</span><div class="card-exercise-right"><span class="card-exercise-reps">3×10</span><span class="card-exercise-time">1:30</span><span class="card-exercise-check"></span></div></div>
<div class="card-exercise-row done"><span class="card-exercise-name">Kniebeugen</span><div class="card-exercise-right"><span class="card-exercise-reps">3×15</span><span class="card-exercise-time">3:00</span><span class="card-exercise-check"></span></div></div>
<div class="card-exercise-row done"><span class="card-exercise-name">Burpees</span><div class="card-exercise-right"><span class="card-exercise-reps">3×20</span><span class="card-exercise-time">1:50</span><span class="card-exercise-check"></span></div></div>
</div>
</div>
<div class="card-footer">
<div class="card-rating"><span class="card-star"></span><span class="card-star"></span><span class="card-star"></span><span class="card-star empty"></span><span class="card-star empty"></span></div>
<div class="card-actions"><button class="card-action-btn">✏️ Bearbeiten</button><button class="card-action-btn" style="color:#dc2626">🗑 Löschen</button></div>
</div>
</div>
</div>
</div>
<script>
function showTable() {
document.getElementById('tableView').classList.remove('hidden');
document.getElementById('cardView').classList.add('hidden');
document.getElementById('tableBtn').classList.add('active');
document.getElementById('cardBtn').classList.remove('active');
}
function showCards() {
document.getElementById('tableView').classList.add('hidden');
document.getElementById('cardView').classList.remove('hidden');
document.getElementById('tableBtn').classList.remove('active');
document.getElementById('cardBtn').classList.add('active');
}
document.getElementById('cardBtn').classList.add('active');
showCards();
</script>
</div>
</body>
</html>
+1198
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
Alles klar, ich habe die Punkte **Trainernotizen zu Kämpfen** und die **feinere Unterteilung des Technik-Katalogs** (Stand vs. Boden) in das Konzept aufgenommen.
Hier ist die nun vollständige und finale Fassung deines Projekt-Plans:
---
## Projekt-Konzept: Digitale Verwaltungs-Plattform für den Ringsport (v4)
### 1. Stammdaten- & Mitgliederverwaltung
* **Detaillierte Ringer-Profile:** Erfassung von Fotos, Geburtsdaten und Scans von Ringerpässen.
* **Gewichts-Historie:** Dokumentation des Körpergewichts über Zeiträume hinweg zur optimalen Wettkampfvorbereitung.
* **Wettkampf-Archiv & Analyse:** Erfassung von Siegen/Niederlagen inklusive Verknüpfung zu **YouTube-Videos**. Zu jedem Kampf können spezifische **Trainernotizen** (z. B. Technik-Kritik oder Verbesserungspotenziale) hinterlegt werden.
* **Organisation:** Einteilung in Kinder/Erwachsene und Zuweisung zu verschiedenen Partner-Clubs.
### 2. Trainingsplanung & Übungskatalog
* **Kategorisierter Übungskatalog:** Zentrale Datenbank für Übungen, unterteilt in:
* **Allgemein:** Kraft, Ausdauer, Aufwärmen.
* **Spezifische Technik:** Unterteilung in **Standkampf** und **Bodenkampf**.
* **Modulare Trainingsvorlagen:** Schnelles Erstellen von Trainingseinheiten (Zeit- oder Wiederholungs-basiert).
* **Ressourcenplanung:** Zuweisung von Trainern und verschiedenen Trainingsorten.
* **Ansichts-Switch:** Volle Flexibilität zwischen Kalender- und Listenansicht bei der Planung und Ansicht.
### 3. Tracking & Hausaufgaben
* **Anwesenheits-Tracking:** Digitale Erfassung der Teilnehmer pro Trainingseinheit.
* **Hausaufgaben-System:** Individuelle oder gruppenbezogene Aufgaben mit Status-Verfolgung (offen/erledigt).
### 4. Dashboard & Reporting
* **Zentrales Dashboard:**
* **Übersicht:** Nächstes Training (Wann & Wo) sowie anstehende Geburtstage (heute + nächste 7 Tage).
* **Hausaufgaben-Monitor:** Sofortige Anzeige aller noch nicht erledigten Aufgaben.
* **Datenschutz:** Dashboard-Ansicht bleibt frei von sensiblen persönlichen Daten.
* **Statistiken:** Auswertung der Trainingsbeteiligung und Hausaufgaben-Erfolge pro Ringer.
* **Export:** Alle Berichte können als **PDF oder CSV** zur externen Verwendung ausgegeben werden.
* **Filter-System:** Leistungsstarke, globale Filter auf allen Verwaltungsseiten.
### 5. Design & Technik
* **High-End UI/UX:** Fokus auf ein modernes Design mit flüssigen Animationen.
* **Rollenmanagement:** Reine Administrations-/Trainer-Plattform (kein Login für Ringer erforderlich).
---
**Damit steht das Grundgerüst!**
+73
View File
@@ -0,0 +1,73 @@
### ⚙️ Backend: Django & REST API
*Das Fundament für Sicherheit, Datenstruktur und Exporte.*
#### Core & Infrastruktur
* **Python:** Die Programmiersprache.
* **Django:** Das Web-Framework (Batteries included).
* **PostgreSQL:** Die Datenbank (zuverlässig und performant für komplexe Datenrelationen).
* **django-environ:** Für die sichere Trennung von Zugangsdaten (Secret Keys, DB-Passwörter) vom Code.
* **gunicorn** & **whitenoise:** Notwendig für den stabilen Betrieb (Deployment) auf einem Server.
#### API & Authentifizierung
* **djangorestframework (DRF):** Macht aus Django eine leistungsfähige REST API.
* **django-cors-headers:** Erlaubt deinem Next.js-Frontend, sicher mit dem Backend zu kommunizieren.
* **djangorestframework-simplejwt:** Schützt die API-Endpunkte, damit nur eingeloggte Trainer/Admins Daten sehen können.
* **drf-spectacular:** Generiert automatisch eine Swagger-Dokumentation. Das Frontend weiß so immer exakt, wie die Datenstruktur aussieht.
#### Daten-Handling & Features
* **django-filter:** Ermöglicht es, Ringer nach Club, Gruppe oder Anwesenheit über die API zu filtern.
* **django-import-export:** Ermöglicht dir, Ringer-Listen oder Trainings-Statistiken direkt im Admin-Bereich als **CSV oder PDF** zu exportieren.
#### Medien & Bilder (Ringerpässe, Fotos)
* **Pillow:** Basis-Bibliothek für Bildverarbeitung in Python.
* **django-resized:** Zwingt hochgeladene Fotos automatisch auf eine vordefinierte Größe, um Speicherplatz zu sparen und Ladezeiten zu verkürzen.
* **django-cleanup:** Löscht automatisch alte Bilddateien vom Server, wenn ein Ringer-Profil gelöscht oder das Foto geändert wird.
#### Admin-Oberfläche ("Geiles Design" für Trainer)
* **django-unfold:** Verwandelt das standardmäßige, altbackene Django-Admin in ein modernes, responsives Interface im Material Design. Da Trainer hier arbeiten, ist das essenziell.
---
### 🎨 Frontend: Next.js & UI
*Die Oberfläche für schnelle Navigation, Filter und flüssige Animationen.*
#### Core & Infrastruktur
* **Node.js:** Die Laufzeitumgebung.
* **Next.js (mit TypeScript):** Das React-Framework für Performance und Typsicherheit. Wir nutzen den modernen **App Router**.
#### UI, Styling & Animationen
* **Tailwind CSS:** Utility-First CSS-Framework für extrem schnelles und konsistentes Design.
* **shadcn/ui:** Die Komponenten-Bibliothek (Daten-Tabellen, Dialoge, Formulare), die perfekt aussieht und auf Tailwind basiert.
* **Framer Motion:** Für die "geilen Animationen". Hiermit steuern wir weiche Übergänge, aufklappende Elemente und Status-Änderungen.
* **lucide-react:** Ein Icon-Set, das modern und konsistent wirkt.
#### Datenmanagement & Kommunikation
* **axios:** Der HTTP-Client für die API-Aufrufe an Django.
* **@tanstack/react-query:** Das wichtigste Tool für Performance. Es cached die Daten im Frontend. Wenn ein Trainer filtert oder zwischen Listen-/Kalenderansicht wechselt, sind die Daten **sofort** ohne Ladezeit da.
* **zustand:** Ein leichtgewichtiger State-Manager, um globale Einstellungen (z.B. den aktuell ausgewählten Club) seitenübergreifend zu speichern.
#### Formulare & Validierung
* **react-hook-form:** Veraltet performante Formulare (z.B. beim Erstellen von Übungen oder Trainingsvorlagen).
* **zod:** Validiert die Eingaben im Formular, noch bevor sie ans Backend geschickt werden (z.B. "Gewicht muss eine Zahl sein").
#### Spezifische Features
* **@fullcalendar/react:** Für den Wechsel zwischen Kalender- und Listenansicht.
* **recharts:** Um die Statistiken auf der Reports-Seite schön darzustellen (z.B. Anwesenheits-Kurven).
---
## 🔗 Feature-to-Tech Map
Hier siehst du, wie deine Wünsche konkret technisch umgesetzt werden:
| Deine Anforderung | Technische Umsetzung (Backend/Frontend) |
| :--- | :--- |
| Ringerpässe scannen & Fotos | Django: `Pillow`, `django-resized`, `cleanup` / Next.js: HTML5 Camera API |
| Gewichtshistorie, Geburtstage | Django: Models mit `Date` & `DecimalField` / Next.js: Formulare & Recharts (für Kurven) |
| Trainingsvorlagen (modular) | Django: Komplexe Database Relations (`ForeignKey`, `ManyToManyField`) |
| Kalender/Liste Switch | Django: API liefert Daten / Next.js: `@fullcalendar/react` + State Switch |
| Filtern auf allen Seiten | Django: `django-filter` & REST API / Next.js: `tanstack/react-query` & URL Params |
| Hausaufgaben Status | Django: Model mit Status-Feld / Next.js: shadcn/ui Checkboxen & API-Update |
| "Geile Animationen" | Next.js: `framer-motion` für alle Übergänge |
| CSV/PDF Export | Django: `django-import-export` (Backend-generiert für Stabilität) |
| Dashboard (keine Pers. Daten) | Django: Separater Public-API Endpunkt / Next.js: Eigene Dashboard-Page |
+59
View File
@@ -0,0 +1,59 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
ENV/
env/
.venv/
# Django
*.log
local_settings.py
db.sqlite3
media/
# Static files
staticfiles/
# Environment
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Testing
.coverage
htmlcov/
.pytest_cache/
# Misc
*.bak
View File
+9
View File
@@ -0,0 +1,9 @@
from django.apps import AppConfig
class AuthAppConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'auth_app'
def ready(self):
import auth_app.signals # noqa
@@ -0,0 +1,34 @@
# Generated by Django 4.2.29 on 2026-03-19 13:24
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserPreferences',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('preferred_locale', models.CharField(default='de', max_length=10)),
('default_view', models.CharField(default='list', max_length=10)),
('wrestlers_view', models.CharField(default='list', max_length=10)),
('wrestlers_items_per_page', models.IntegerField(default=10)),
('trainers_view', models.CharField(default='list', max_length=10)),
('trainers_items_per_page', models.IntegerField(default=10)),
('exercises_view', models.CharField(default='list', max_length=10)),
('exercises_items_per_page', models.IntegerField(default=10)),
('trainings_view', models.CharField(default='list', max_length=10)),
('trainings_items_per_page', models.IntegerField(default=10)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='preferences', to=settings.AUTH_USER_MODEL)),
],
),
]
@@ -0,0 +1,33 @@
# Generated by Django 4.2.29 on 2026-03-19 13:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('auth_app', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='userpreferences',
name='exercises_filters',
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name='userpreferences',
name='trainers_filters',
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name='userpreferences',
name='trainings_filters',
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name='userpreferences',
name='wrestlers_filters',
field=models.JSONField(blank=True, default=dict),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 4.2.29 on 2026-03-19 14:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('auth_app', '0002_userpreferences_exercises_filters_and_more'),
]
operations = [
migrations.AddField(
model_name='userpreferences',
name='homework_filters',
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name='userpreferences',
name='homework_items_per_page',
field=models.IntegerField(default=10),
),
migrations.AddField(
model_name='userpreferences',
name='homework_view',
field=models.CharField(default='list', max_length=10),
),
]
@@ -0,0 +1,25 @@
# Generated by Django 4.2.29 on 2026-03-20 14:26
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('clubs', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('auth_app', '0003_userpreferences_homework_filters_and_more'),
]
operations = [
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user_profiles', to='clubs.club')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
],
),
]
+34
View File
@@ -0,0 +1,34 @@
from django.db import models
from django.contrib.auth.models import User
class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
club = models.ForeignKey('clubs.Club', on_delete=models.SET_NULL, null=True, blank=True, related_name='user_profiles')
def __str__(self):
return f"{self.user.username} Profile"
class UserPreferences(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='preferences')
preferred_locale = models.CharField(max_length=10, default='de')
default_view = models.CharField(max_length=10, default='list')
wrestlers_view = models.CharField(max_length=10, default='list')
wrestlers_items_per_page = models.IntegerField(default=10)
wrestlers_filters = models.JSONField(default=dict, blank=True)
trainers_view = models.CharField(max_length=10, default='list')
trainers_items_per_page = models.IntegerField(default=10)
trainers_filters = models.JSONField(default=dict, blank=True)
exercises_view = models.CharField(max_length=10, default='list')
exercises_items_per_page = models.IntegerField(default=10)
exercises_filters = models.JSONField(default=dict, blank=True)
trainings_view = models.CharField(max_length=10, default='list')
trainings_items_per_page = models.IntegerField(default=10)
trainings_filters = models.JSONField(default=dict, blank=True)
homework_view = models.CharField(max_length=10, default='list')
homework_items_per_page = models.IntegerField(default=10)
homework_filters = models.JSONField(default=dict, blank=True)
def __str__(self):
return f"{self.user.username} Preferences"
+64
View File
@@ -0,0 +1,64 @@
from rest_framework import serializers
from django.contrib.auth.models import User
from .models import UserPreferences
class UserSerializer(serializers.ModelSerializer):
club_id = serializers.SerializerMethodField()
club_name = serializers.SerializerMethodField()
class Meta:
model = User
fields = ['id', 'username', 'email', 'first_name', 'last_name', 'club_id', 'club_name']
read_only_fields = ['id']
def get_club_id(self, obj):
if hasattr(obj, 'profile') and obj.profile and obj.profile.club:
return obj.profile.club.id
return None
def get_club_name(self, obj):
if hasattr(obj, 'profile') and obj.profile and obj.profile.club:
return obj.profile.club.name
return None
class LoginSerializer(serializers.Serializer):
username = serializers.CharField()
password = serializers.CharField(write_only=True)
class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, min_length=8)
password_confirm = serializers.CharField(write_only=True)
class Meta:
model = User
fields = ['username', 'email', 'password', 'password_confirm', 'first_name', 'last_name']
def validate_email(self, value):
if User.objects.filter(email=value).exists():
raise serializers.ValidationError('A user with this email already exists')
return value
def validate(self, attrs):
if attrs['password'] != attrs['password_confirm']:
raise serializers.ValidationError({'password_confirm': 'Passwords do not match'})
return attrs
def create(self, validated_data):
validated_data.pop('password_confirm')
user = User.objects.create_user(
username=validated_data['username'],
email=validated_data.get('email', ''),
password=validated_data['password'],
first_name=validated_data.get('first_name', ''),
last_name=validated_data.get('last_name', ''),
)
return user
class UserPreferencesSerializer(serializers.ModelSerializer):
class Meta:
model = UserPreferences
fields = '__all__'
+10
View File
@@ -0,0 +1,10 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth.models import User
from .models import UserProfile
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
if created:
UserProfile.objects.create(user=instance)
+98
View File
@@ -0,0 +1,98 @@
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes, throttle_classes
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import AnonRateThrottle
from rest_framework_simplejwt.tokens import RefreshToken
from django.contrib.auth import authenticate
from .models import UserPreferences
from .serializers import LoginSerializer, RegisterSerializer, UserSerializer, UserPreferencesSerializer
class AuthRateThrottle(AnonRateThrottle):
rate = '5/minute'
@api_view(['POST'])
@permission_classes([AllowAny])
@throttle_classes([AuthRateThrottle])
def login(request):
serializer = LoginSerializer(data=request.data)
if serializer.is_valid():
user = authenticate(
username=serializer.validated_data['username'],
password=serializer.validated_data['password']
)
if user:
refresh = RefreshToken.for_user(user)
return Response({
'access': str(refresh.access_token),
'refresh': str(refresh),
'user': UserSerializer(user).data
})
return Response(
{'detail': 'Invalid credentials'},
status=status.HTTP_401_UNAUTHORIZED
)
return Response({'detail': serializer.errors}, status=status.HTTP_400_BAD_REQUEST)
@api_view(['POST'])
@permission_classes([AllowAny])
@throttle_classes([AuthRateThrottle])
def register(request):
serializer = RegisterSerializer(data=request.data)
if serializer.is_valid():
user = serializer.save()
refresh = RefreshToken.for_user(user)
return Response({
'access': str(refresh.access_token),
'refresh': str(refresh),
'user': UserSerializer(user).data
}, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@api_view(['POST'])
@permission_classes([AllowAny])
@throttle_classes([AuthRateThrottle])
def refresh_token(request):
refresh_token = request.data.get('refresh')
if not refresh_token:
return Response(
{'detail': 'Refresh token required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
refresh = RefreshToken(refresh_token)
return Response({
'access': str(refresh.access_token),
})
except Exception:
return Response(
{'detail': 'Invalid refresh token'},
status=status.HTTP_401_UNAUTHORIZED
)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def me(request):
return Response(UserSerializer(request.user).data)
@api_view(['GET', 'PATCH'])
@permission_classes([IsAuthenticated])
def user_preferences(request):
if request.method == 'GET':
prefs, _ = UserPreferences.objects.get_or_create(user=request.user)
serializer = UserPreferencesSerializer(prefs)
return Response(serializer.data)
elif request.method == 'PATCH':
prefs, _ = UserPreferences.objects.get_or_create(user=request.user)
serializer = UserPreferencesSerializer(prefs, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=400)
View File
+12
View File
@@ -0,0 +1,12 @@
import unfold
from unfold.admin import ModelAdmin as UnfoldModelAdmin
from django.contrib import admin
from .models import Club
@admin.register(Club)
class ClubAdmin(UnfoldModelAdmin):
list_display = ['name', 'short_name', 'is_active', 'created_at']
list_filter = ['is_active']
search_fields = ['name', 'short_name']
readonly_fields = ['created_at', 'updated_at']
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ClubsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'clubs'
@@ -0,0 +1,171 @@
import random
from datetime import date, timedelta
from django.core.management.base import BaseCommand
from clubs.models import Club
from wrestlers.models import Wrestler
from trainers.models import Trainer
from locations.models import Location
from exercises.models import Exercise
from templates.models import TrainingTemplate, TemplateExercise
from trainings.models import Training, Attendance
class Command(BaseCommand):
help = 'Populates database with comprehensive sample data for testing'
def handle(self, *args, **options):
self.stdout.write('Creating comprehensive sample data...')
# Create 3 clubs
clubs = [
Club.objects.create(name='KSV Wiesental', short_name='KSV', is_active=True),
Club.objects.create(name='RSV Mannheim', short_name='RSV', is_active=True),
Club.objects.create(name='AV Germering', short_name='AVG', is_active=True),
]
# 3 Trainers (one per club)
trainers = [
Trainer.objects.create(first_name='Max', last_name='Mueller', club=clubs[0], email='max.mueller@ksv.de', is_active=True),
Trainer.objects.create(first_name='Anna', last_name='Schmidt', club=clubs[1], email='anna.schmidt@rsv.de', is_active=True),
Trainer.objects.create(first_name='Tom', last_name='Bauer', club=clubs[2], email='tom.bauer@avg.de', is_active=True),
]
# 30 Wrestlers (10 per club, distributed across groups)
first_names_m = ['Felix', 'Leon', 'Paul', 'Lukas', 'Jonas', 'Max', 'Tim', 'David', 'Kevin', 'Marco', 'Stefan', 'Daniel', 'Florian', 'Tobias', 'Christian']
first_names_f = ['Emma', 'Sophie', 'Marie', 'Laura', 'Lena', 'Anna', 'Lisa', 'Sarah', 'Julia', 'Laura', 'Nina', 'Lea', 'Laura', 'Lena', 'Laura']
last_names = ['Mueller', 'Schmidt', 'Weber', 'Fischer', 'Klein', 'Bauer', 'Wolf', 'Schulz', 'Neumann', 'Hoffmann', 'Koch', 'Becker', 'Richter', 'Wagner', 'Weiss']
wrestlers = []
for i in range(30):
club_idx = i % 3
group = random.choice(['kids', 'kids', 'youth', 'youth', 'youth', 'adults', 'adults'])
gender = random.choice(['m', 'f'])
first_name = random.choice(first_names_m if gender == 'm' else first_names_f)
last_name = random.choice(last_names)
year_offset = {'kids': 8, 'youth': 4, 'adults': 0}[group]
dob = date(2010 + year_offset + random.randint(-2, 2), random.randint(1, 12), random.randint(1, 28))
weight = random.uniform(25, 90)
w = Wrestler.objects.create(
first_name=first_name,
last_name=last_name,
club=clubs[club_idx],
group=group,
date_of_birth=dob,
gender=gender,
weight_kg=round(weight, 1),
is_active=True
)
wrestlers.append(w)
# 3 Locations (one per club)
locations = [
Location.objects.create(name='Sporthalle Wiesental', address='Ringstrasse 15, 79541 Wiesental', is_active=True),
Location.objects.create(name='Sportzentrum Mannheim', address='Friedrich-Ebert-Strasse 88, 68159 Mannheim', is_active=True),
Location.objects.create(name='Turnhalle Germering', address='Augsburger Strasse 45, 82110 Germering', is_active=True),
]
# 15 Exercises
exercises_data = [
{'name': 'Springseil', 'category': 'warmup', 'exercise_type': 'time', 'default_value': '60'},
{'name': 'Armkreisen', 'category': 'warmup', 'exercise_type': 'time', 'default_value': '60'},
{'name': 'Beinspätze', 'category': 'warmup', 'exercise_type': 'time', 'default_value': '60'},
{'name': 'Liegestuetze', 'category': 'kraft', 'exercise_type': 'reps', 'default_value': '15'},
{'name': 'Kniebeugen', 'category': 'kraft', 'exercise_type': 'reps', 'default_value': '20'},
{'name': 'Sit-ups', 'category': 'kraft', 'exercise_type': 'reps', 'default_value': '25'},
{'name': 'Plank', 'category': 'kraft', 'exercise_type': 'time', 'default_value': '60'},
{'name': 'Doppelbeintechnik', 'category': 'technik', 'exercise_type': 'reps', 'default_value': '10'},
{'name': 'Ausputzer', 'category': 'technik', 'exercise_type': 'reps', 'default_value': '8'},
{'name': 'Beinsäge', 'category': 'technik', 'exercise_type': 'reps', 'default_value': '12'},
{'name': 'Armheber', 'category': 'technik', 'exercise_type': 'reps', 'default_value': '10'},
{'name': 'Tempolauf', 'category': 'ausdauer', 'exercise_type': 'time', 'default_value': '180'},
{'name': 'Seilspringen Station', 'category': 'ausdauer', 'exercise_type': 'time', 'default_value': '120'},
{'name': 'Fangspiel', 'category': 'spiele', 'exercise_type': 'time', 'default_value': '300'},
{'name': 'Dehnung', 'category': 'cool_down', 'exercise_type': 'time', 'default_value': '180'},
]
exercises = [Exercise.objects.create(**data) for data in exercises_data]
# 6 Templates (2 per group)
templates = []
for idx, (group, group_name) in enumerate([('kids', 'Kids'), ('youth', 'Youth'), ('adults', 'Adults')]):
for t_idx in range(2):
t = TrainingTemplate.objects.create(
name=f'{group_name} Training {"A" if t_idx == 0 else "B"}',
description=f'Standard training for {group_name.lower()} group - Part {"A" if t_idx == 0 else "B"}',
category='main',
is_active=True
)
templates.append(t)
# Kids Training A: warmup + kraft + spiele
for i, ex in enumerate([exercises[0], exercises[3], exercises[5], exercises[13]]):
TemplateExercise.objects.create(template=templates[0], exercise=ex, order=i, default_value=ex.default_value)
# Kids Training B: warmup + technik + spiele
for i, ex in enumerate([exercises[1], exercises[7], exercises[9], exercises[13]]):
TemplateExercise.objects.create(template=templates[1], exercise=ex, order=i, default_value=ex.default_value)
# Youth Training A: warmup + technik + kraft
for i, ex in enumerate([exercises[0], exercises[3], exercises[4], exercises[7], exercises[8]]):
TemplateExercise.objects.create(template=templates[2], exercise=ex, order=i, default_value=ex.default_value)
# Youth Training B: technik + ausdauer
for i, ex in enumerate([exercises[7], exercises[8], exercises[9], exercises[11], exercises[14]]):
TemplateExercise.objects.create(template=templates[3], exercise=ex, order=i, default_value=ex.default_value)
# Adults Training A: warmup + kraft + technik
for i, ex in enumerate([exercises[0], exercises[3], exercises[4], exercises[6], exercises[7], exercises[14]]):
TemplateExercise.objects.create(template=templates[4], exercise=ex, order=i, default_value=ex.default_value)
# Adults Training B: kraft + ausdauer + technik
for i, ex in enumerate([exercises[3], exercises[5], exercises[7], exercises[11], exercises[12], exercises[14]]):
TemplateExercise.objects.create(template=templates[5], exercise=ex, order=i, default_value=ex.default_value)
# 30 Trainings spread over 3 months (past, present, future)
today = date.today()
start_date = today - timedelta(days=90) # 3 months ago
trainings = []
group_map = {'kids': [0, 1], 'youth': [2, 3], 'adults': [4, 5]}
for week_offset in range(15): # ~2 trainings per week over 3 months
for day_offset in [0, 3]: # Monday and Thursday
training_date = start_date + timedelta(days=week_offset * 7 + day_offset)
if training_date > today + timedelta(days=14):
continue # Don't create trainings too far in future
group = random.choice(['kids', 'youth', 'adults'])
template = random.choice(group_map[group])
location = random.choice(locations)
is_completed = training_date < today
t = Training.objects.create(
date=training_date,
start_time='17:00' if group == 'kids' else '18:30' if group == 'youth' else '19:30',
end_time='18:30' if group == 'kids' else '20:00' if group == 'youth' else '21:00',
location=location,
group=group,
is_completed=is_completed,
notes=f'Training für {group}'
)
t.trainers.set([random.choice(trainers)])
trainings.append(t)
# Create attendances for completed trainings
for training in trainings:
if training.is_completed:
group_wrestlers = [w for w in wrestlers if w.group == training.group]
num_attendees = min(len(group_wrestlers), random.randint(3, 8))
for w in random.sample(group_wrestlers, num_attendees):
Attendance.objects.create(training=training, wrestler=w)
self.stdout.write(self.style.SUCCESS(
f'Created: {Club.objects.count()} clubs, '
f'{Trainer.objects.count()} trainers, '
f'{Wrestler.objects.count()} wrestlers, '
f'{Location.objects.count()} locations, '
f'{Exercise.objects.count()} exercises, '
f'{TrainingTemplate.objects.count()} templates, '
f'{Training.objects.count()} trainings, '
f'{Attendance.objects.count()} attendances'
))
+29
View File
@@ -0,0 +1,29 @@
# Generated by Django 4.2.29 on 2026-03-19 09:05
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Club',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('short_name', models.CharField(blank=True, max_length=50)),
('logo', models.ImageField(blank=True, null=True, upload_to='clubs/logos/')),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
},
),
]
+16
View File
@@ -0,0 +1,16 @@
from django.db import models
class Club(models.Model):
name = models.CharField(max_length=200)
short_name = models.CharField(max_length=50, blank=True)
logo = models.ImageField(upload_to='clubs/logos/', null=True, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
+14
View File
@@ -0,0 +1,14 @@
from rest_framework import serializers
from .models import Club
class ClubSerializer(serializers.ModelSerializer):
wrestler_count = serializers.SerializerMethodField()
class Meta:
model = Club
fields = '__all__'
read_only_fields = ['created_at', 'updated_at']
def get_wrestler_count(self, obj):
return obj.wrestlers.filter(is_active=True).count()
+30
View File
@@ -0,0 +1,30 @@
from django.test import TestCase
from django.contrib.auth.models import User
from rest_framework.test import APITestCase
from rest_framework import status
from clubs.models import Club
from auth_app.models import UserProfile
class ClubAPITest(APITestCase):
def setUp(self):
self.user = User.objects.create_user(username='testuser', password='testpass123')
self.club = Club.objects.create(name='Test Club')
UserProfile.objects.create(user=self.user, club=self.club)
self.client.force_authenticate(user=self.user)
def test_list_clubs(self):
response = self.client.get('/api/v1/clubs/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_create_club(self):
initial_count = Club.objects.count()
data = {'name': 'Test Club API', 'short_name': 'TC'}
response = self.client.post('/api/v1/clubs/', data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Club.objects.count(), initial_count + 1)
self.assertEqual(Club.objects.last().name, 'Test Club API')
def test_club_str(self):
club = Club.objects.create(name="API Test Club")
self.assertEqual(str(club), "API Test Club")
+17
View File
@@ -0,0 +1,17 @@
from rest_framework import viewsets, filters
from rest_framework.permissions import IsAuthenticated
from django_filters.rest_framework import DjangoFilterBackend
from .models import Club
from .serializers import ClubSerializer
from wrestleDesk.pagination import StandardResultsSetPagination
class ClubViewSet(viewsets.ModelViewSet):
queryset = Club.objects.all()
serializer_class = ClubSerializer
pagination_class = StandardResultsSetPagination
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['is_active']
search_fields = ['name', 'short_name']
ordering_fields = ['name', 'created_at']
+12
View File
@@ -0,0 +1,12 @@
import unfold
from unfold.admin import ModelAdmin as UnfoldModelAdmin
from django.contrib import admin
from .models import Exercise
@admin.register(Exercise)
class ExerciseAdmin(UnfoldModelAdmin):
list_display = ['name', 'category', 'exercise_type', 'is_active']
list_filter = ['category', 'exercise_type', 'is_active']
search_fields = ['name', 'description']
readonly_fields = ['created_at', 'updated_at']
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ExercisesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'exercises'
@@ -0,0 +1,33 @@
# Generated by Django 4.2.29 on 2026-03-19 09:05
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Exercise',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('category', models.CharField(choices=[('warmup', 'Warm-up'), ('kraft', 'Strength'), ('technik', 'Technique'), ('ausdauer', 'Endurance'), ('spiele', 'Games'), ('cool_down', 'Cool-down')], max_length=20)),
('exercise_type', models.CharField(choices=[('reps', 'Repetitions'), ('time', 'Time-based')], default='reps', max_length=10)),
('description', models.TextField(blank=True)),
('default_value', models.CharField(blank=True, help_text='Default reps or seconds', max_length=50)),
('media', models.FileField(blank=True, null=True, upload_to='exercises/media/')),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['category', 'name'],
'indexes': [models.Index(fields=['category'], name='exercises_e_categor_eda76d_idx')],
},
),
]
@@ -0,0 +1,24 @@
# Generated by Django 4.2.29 on 2026-03-20 14:40
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('clubs', '0001_initial'),
('exercises', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='exercise',
name='club',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='exercises', to='clubs.club'),
),
migrations.AddIndex(
model_name='exercise',
index=models.Index(fields=['club'], name='exercises_e_club_id_06152b_idx'),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.29 on 2026-03-22 12:17
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('exercises', '0002_exercise_club_and_more'),
]
operations = [
migrations.AlterField(
model_name='exercise',
name='media',
field=models.FileField(blank=True, null=True, upload_to='exercises/media/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['jpg', 'jpeg', 'png', 'mp4', 'webp'])]),
),
]
+43
View File
@@ -0,0 +1,43 @@
from django.db import models
from django.core.validators import FileExtensionValidator
class Exercise(models.Model):
CATEGORY_CHOICES = [
('warmup', 'Warm-up'),
('kraft', 'Strength'),
('technik', 'Technique'),
('ausdauer', 'Endurance'),
('spiele', 'Games'),
('cool_down', 'Cool-down'),
]
TYPE_CHOICES = [
('reps', 'Repetitions'),
('time', 'Time-based'),
]
name = models.CharField(max_length=200)
category = models.CharField(max_length=20, choices=CATEGORY_CHOICES)
exercise_type = models.CharField(max_length=10, choices=TYPE_CHOICES, default='reps')
club = models.ForeignKey('clubs.Club', on_delete=models.CASCADE, related_name='exercises', null=True, blank=True)
description = models.TextField(blank=True)
default_value = models.CharField(max_length=50, blank=True, help_text='Default reps or seconds')
media = models.FileField(
upload_to='exercises/media/',
null=True, blank=True,
validators=[FileExtensionValidator(allowed_extensions=['jpg', 'jpeg', 'png', 'mp4', 'webp'])]
)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['category', 'name']
indexes = [
models.Index(fields=['category']),
models.Index(fields=['club']),
]
def __str__(self):
return f"{self.name} ({self.get_category_display()})"
+25
View File
@@ -0,0 +1,25 @@
from rest_framework import serializers
from .models import Exercise
class ExerciseSerializer(serializers.ModelSerializer):
category_display = serializers.CharField(source='get_category_display', read_only=True)
class Meta:
model = Exercise
fields = '__all__'
read_only_fields = ['created_at', 'updated_at']
def validate_name(self, value):
queryset = Exercise.objects.filter(name__iexact=value)
if self.instance:
queryset = queryset.exclude(pk=self.instance.pk)
if queryset.exists():
raise serializers.ValidationError("Eine Übung mit diesem Namen existiert bereits.")
return value
def create(self, validated_data):
request = self.context.get('request')
if request and hasattr(request.user, 'profile') and request.user.profile.club:
validated_data['club'] = request.user.profile.club
return super().create(validated_data)
+33
View File
@@ -0,0 +1,33 @@
from django.test import TestCase
from django.contrib.auth.models import User
from rest_framework.test import APITestCase
from rest_framework import status
from exercises.models import Exercise
class ExerciseAPITest(APITestCase):
def setUp(self):
self.user = User.objects.create_user(username='testuser2', password='testpass123')
self.client.force_authenticate(user=self.user)
def test_list_exercises(self):
response = self.client.get('/api/v1/exercises/')
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND])
def test_create_exercise(self):
data = {
'name': 'Push-ups',
'category': 'kraft',
'exercise_type': 'reps',
'default_value': '20'
}
response = self.client.post('/api/v1/exercises/', data)
self.assertIn(response.status_code, [status.HTTP_201_CREATED, status.HTTP_404_NOT_FOUND, status.HTTP_405_METHOD_NOT_ALLOWED])
def test_filter_by_category(self):
Exercise.objects.create(name='Squat', category='kraft', exercise_type='reps', default_value='10')
Exercise.objects.create(name='Run', category='ausdauer', exercise_type='time', default_value='5')
response = self.client.get('/api/v1/exercises/?category=kraft')
if response.status_code == 200:
self.assertEqual(len(response.data['results']), 1)
self.assertEqual(response.data['results'][0]['name'], 'Squat')
+17
View File
@@ -0,0 +1,17 @@
from rest_framework import viewsets, filters
from rest_framework.permissions import IsAuthenticated
from django_filters.rest_framework import DjangoFilterBackend
from .models import Exercise
from .serializers import ExerciseSerializer
from wrestleDesk.pagination import StandardResultsSetPagination
class ExerciseViewSet(viewsets.ModelViewSet):
queryset = Exercise.objects.all()
serializer_class = ExerciseSerializer
pagination_class = StandardResultsSetPagination
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['category', 'exercise_type', 'is_active']
search_fields = ['name', 'description']
ordering_fields = ['name', 'category', 'created_at']
View File
+73
View File
@@ -0,0 +1,73 @@
import unfold
from unfold.admin import ModelAdmin as UnfoldModelAdmin
from django.contrib import admin
from .models import Homework, HomeworkExerciseItem, HomeworkAssignment, HomeworkAssignmentItem, HomeworkStatus, TrainingHomework, TrainingHomeworkExercise, TrainingHomeworkAssignment
class HomeworkExerciseItemInline(admin.TabularInline):
model = HomeworkExerciseItem
extra = 1
raw_id_fields = ['exercise']
@admin.register(Homework)
class HomeworkAdmin(UnfoldModelAdmin):
list_display = ['title', 'club', 'due_date', 'is_active', 'exercise_count']
list_filter = ['is_active', 'club']
search_fields = ['title', 'description']
readonly_fields = ['created_at', 'updated_at']
raw_id_fields = ['club']
inlines = [HomeworkExerciseItemInline]
def exercise_count(self, obj):
return obj.exercise_items.count()
exercise_count.short_description = 'Exercises'
class HomeworkAssignmentItemInline(admin.TabularInline):
model = HomeworkAssignmentItem
extra = 0
raw_id_fields = ['exercise']
readonly_fields = ['completion_date']
@admin.register(HomeworkAssignment)
class HomeworkAssignmentAdmin(UnfoldModelAdmin):
list_display = ['wrestler', 'homework', 'club', 'due_date', 'is_completed_display']
list_filter = ['club']
search_fields = ['wrestler__first_name', 'wrestler__last_name', 'homework__title']
readonly_fields = ['created_at', 'updated_at']
raw_id_fields = ['homework', 'wrestler', 'club']
inlines = [HomeworkAssignmentItemInline]
def is_completed_display(self, obj):
return obj.is_completed
is_completed_display.short_description = 'Completed'
is_completed_display.boolean = True
@admin.register(HomeworkExerciseItem)
class HomeworkExerciseItemAdmin(UnfoldModelAdmin):
list_display = ['homework', 'exercise', 'reps', 'time_minutes', 'order']
list_filter = ['homework']
raw_id_fields = ['homework', 'exercise']
@admin.register(HomeworkStatus)
class HomeworkStatusAdmin(UnfoldModelAdmin):
list_display = ['homework', 'wrestler', 'is_completed', 'completion_date']
list_filter = ['is_completed']
raw_id_fields = ['homework', 'wrestler']
@admin.register(TrainingHomework)
class TrainingHomeworkAdmin(UnfoldModelAdmin):
list_display = ['id', 'training', 'created_at']
list_select_related = ['training']
@admin.register(TrainingHomeworkAssignment)
class TrainingHomeworkAssignmentAdmin(UnfoldModelAdmin):
list_display = ['id', 'wrestler', 'training', 'is_completed', 'created_at']
list_filter = ['is_completed', 'created_at']
list_select_related = ['wrestler', 'training']
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class HomeworkConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'homework'
@@ -0,0 +1,60 @@
# Generated by Django 4.2.29 on 2026-03-19 09:05
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('exercises', '0001_initial'),
('wrestlers', '0001_initial'),
('clubs', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Homework',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('target_group', models.CharField(choices=[('kids', 'Kids'), ('youth', 'Youth'), ('adults', 'Adults'), ('all', 'All')], default='all', max_length=20)),
('due_date', models.DateField(blank=True, null=True)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('club', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='homework_assignments', to='clubs.club')),
('exercises', models.ManyToManyField(related_name='homework_assignments', to='exercises.exercise')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='HomeworkStatus',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_completed', models.BooleanField(default=False)),
('completion_date', models.DateField(blank=True, null=True)),
('notes', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('homework', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='statuses', to='homework.homework')),
('wrestler', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='homework_statuses', to='wrestlers.wrestler')),
],
options={
'unique_together': {('homework', 'wrestler')},
},
),
migrations.AddIndex(
model_name='homework',
index=models.Index(fields=['target_group'], name='homework_ho_target__66652f_idx'),
),
migrations.AddIndex(
model_name='homework',
index=models.Index(fields=['due_date'], name='homework_ho_due_dat_5d3fcb_idx'),
),
]
@@ -0,0 +1,126 @@
# Generated by Django 4.2.29 on 2026-03-19 14:44
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('wrestlers', '0001_initial'),
('clubs', '0001_initial'),
('exercises', '0001_initial'),
('homework', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='HomeworkAssignment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('due_date', models.DateField(blank=True, null=True)),
('notes', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='HomeworkAssignmentItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_completed', models.BooleanField(default=False)),
('completion_date', models.DateField(blank=True, null=True)),
],
),
migrations.CreateModel(
name='HomeworkExerciseItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reps', models.IntegerField(blank=True, null=True)),
('time_minutes', models.IntegerField(blank=True, null=True)),
('order', models.IntegerField(default=0)),
],
options={
'ordering': ['homework', 'order'],
},
),
migrations.RemoveIndex(
model_name='homework',
name='homework_ho_target__66652f_idx',
),
migrations.RemoveField(
model_name='homework',
name='exercises',
),
migrations.RemoveField(
model_name='homework',
name='target_group',
),
migrations.AlterField(
model_name='homework',
name='club',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='homework_templates', to='clubs.club'),
),
migrations.AddIndex(
model_name='homework',
index=models.Index(fields=['club'], name='homework_ho_club_id_126bad_idx'),
),
migrations.AddField(
model_name='homeworkexerciseitem',
name='exercise',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='homework_items', to='exercises.exercise'),
),
migrations.AddField(
model_name='homeworkexerciseitem',
name='homework',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exercise_items', to='homework.homework'),
),
migrations.AddField(
model_name='homeworkassignmentitem',
name='assignment',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='homework.homeworkassignment'),
),
migrations.AddField(
model_name='homeworkassignmentitem',
name='exercise',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignment_items', to='exercises.exercise'),
),
migrations.AddField(
model_name='homeworkassignment',
name='club',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='homework_assignments', to='clubs.club'),
),
migrations.AddField(
model_name='homeworkassignment',
name='homework',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='homework.homework'),
),
migrations.AddField(
model_name='homeworkassignment',
name='wrestler',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='homework_assignments', to='wrestlers.wrestler'),
),
migrations.AlterUniqueTogether(
name='homeworkexerciseitem',
unique_together={('homework', 'exercise')},
),
migrations.AlterUniqueTogether(
name='homeworkassignmentitem',
unique_together={('assignment', 'exercise')},
),
migrations.AddIndex(
model_name='homeworkassignment',
index=models.Index(fields=['wrestler'], name='homework_ho_wrestle_ddebdf_idx'),
),
migrations.AddIndex(
model_name='homeworkassignment',
index=models.Index(fields=['due_date'], name='homework_ho_due_dat_12e964_idx'),
),
migrations.AlterUniqueTogether(
name='homeworkassignment',
unique_together={('homework', 'wrestler')},
),
]
@@ -0,0 +1,21 @@
# Generated by Django 4.2.29 on 2026-03-20 14:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('homework', '0002_homeworkassignment_homeworkassignmentitem_and_more'),
]
operations = [
migrations.AddIndex(
model_name='homeworkassignmentitem',
index=models.Index(fields=['assignment', 'is_completed'], name='homework_ho_assignm_791a15_idx'),
),
migrations.AddIndex(
model_name='homeworkassignmentitem',
index=models.Index(fields=['completion_date'], name='homework_ho_complet_55c380_idx'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 4.2.29 on 2026-03-20 14:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('homework', '0003_add_assignment_item_indexes'),
]
operations = [
migrations.AlterField(
model_name='homeworkexerciseitem',
name='reps',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='homeworkexerciseitem',
name='time_minutes',
field=models.PositiveIntegerField(blank=True, null=True),
),
]
@@ -0,0 +1,67 @@
# Generated by Django 4.2.29 on 2026-03-22 12:17
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('wrestlers', '0002_alter_wrestler_license_scan_alter_wrestler_photo'),
('clubs', '0001_initial'),
('trainings', '0005_training_club_and_more'),
('exercises', '0003_alter_exercise_media'),
('homework', '0004_alter_homeworkexerciseitem_reps_and_more'),
]
operations = [
migrations.CreateModel(
name='TrainingHomework',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('training', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='homework_assignments', to='trainings.training')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='TrainingHomeworkExercise',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reps', models.PositiveIntegerField(blank=True, null=True)),
('time_minutes', models.PositiveIntegerField(blank=True, null=True)),
('order', models.IntegerField(default=0)),
('exercise', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='training_homework_items', to='exercises.exercise')),
('training_homework', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exercises', to='homework.traininghomework')),
],
options={
'ordering': ['training_homework', 'order'],
'unique_together': {('training_homework', 'exercise')},
},
),
migrations.CreateModel(
name='TrainingHomeworkAssignment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('notes', models.TextField(blank=True)),
('is_completed', models.BooleanField(default=False)),
('completion_date', models.DateField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('club', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='training_homework_assignments', to='clubs.club')),
('training_homework', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='homework.traininghomework')),
('wrestler', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='training_homework_assignments', to='wrestlers.wrestler')),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['wrestler'], name='homework_tr_wrestle_63f0d7_idx'), models.Index(fields=['is_completed'], name='homework_tr_is_comp_a157f2_idx'), models.Index(fields=['club'], name='homework_tr_club_id_4648ff_idx')],
'unique_together': {('training_homework', 'wrestler')},
},
),
migrations.AddIndex(
model_name='traininghomework',
index=models.Index(fields=['training'], name='homework_tr_trainin_950ce2_idx'),
),
]
@@ -0,0 +1,66 @@
# Generated by Django 4.2.29 on 2026-03-23 06:45
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('wrestlers', '0002_alter_wrestler_license_scan_alter_wrestler_photo'),
('exercises', '0003_alter_exercise_media'),
('trainings', '0005_training_club_and_more'),
('homework', '0005_traininghomework_traininghomeworkexercise_and_more'),
]
operations = [
migrations.CreateModel(
name='TrainingHomeworkExerciseItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reps', models.PositiveIntegerField(blank=True, null=True)),
('time_minutes', models.PositiveIntegerField(blank=True, null=True)),
('order', models.IntegerField(default=0)),
('is_completed', models.BooleanField(default=False)),
],
options={
'ordering': ['assignment', 'order'],
},
),
migrations.AlterUniqueTogether(
name='traininghomeworkassignment',
unique_together=set(),
),
migrations.AddField(
model_name='traininghomeworkassignment',
name='training',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='homework_assignments', to='trainings.training'),
),
migrations.AlterField(
model_name='traininghomework',
name='training',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='homework_legacy', to='trainings.training'),
),
migrations.AlterUniqueTogether(
name='traininghomeworkassignment',
unique_together={('training', 'wrestler')},
),
migrations.AddIndex(
model_name='traininghomeworkassignment',
index=models.Index(fields=['training'], name='homework_tr_trainin_048980_idx'),
),
migrations.AddField(
model_name='traininghomeworkexerciseitem',
name='assignment',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exercises', to='homework.traininghomeworkassignment'),
),
migrations.AddField(
model_name='traininghomeworkexerciseitem',
name='exercise',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='training_homework_exercises', to='exercises.exercise'),
),
migrations.RemoveField(
model_name='traininghomeworkassignment',
name='training_homework',
),
]
+188
View File
@@ -0,0 +1,188 @@
from django.db import models
class Homework(models.Model):
title = models.CharField(max_length=200)
description = models.TextField(blank=True)
club = models.ForeignKey('clubs.Club', on_delete=models.CASCADE, related_name='homework_templates', null=True, blank=True)
due_date = models.DateField(null=True, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['due_date']),
models.Index(fields=['club']),
]
def __str__(self):
return self.title
class HomeworkExerciseItem(models.Model):
homework = models.ForeignKey(Homework, on_delete=models.CASCADE, related_name='exercise_items')
exercise = models.ForeignKey('exercises.Exercise', on_delete=models.CASCADE, related_name='homework_items')
reps = models.PositiveIntegerField(null=True, blank=True)
time_minutes = models.PositiveIntegerField(null=True, blank=True)
order = models.IntegerField(default=0)
class Meta:
ordering = ['homework', 'order']
unique_together = ['homework', 'exercise']
def __str__(self):
return f"{self.homework.title} - {self.exercise.name}"
class HomeworkAssignment(models.Model):
homework = models.ForeignKey(Homework, on_delete=models.CASCADE, related_name='assignments')
wrestler = models.ForeignKey('wrestlers.Wrestler', on_delete=models.CASCADE, related_name='homework_assignments')
club = models.ForeignKey('clubs.Club', on_delete=models.CASCADE, related_name='homework_assignments', null=True, blank=True)
due_date = models.DateField(null=True, blank=True)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['homework', 'wrestler']
ordering = ['-created_at']
indexes = [
models.Index(fields=['wrestler']),
models.Index(fields=['due_date']),
]
def __str__(self):
return f"{self.wrestler} - {self.homework.title}"
def clean(self):
from wrestlers.models import Wrestler
if self.wrestler and self.homework and self.wrestler.club_id != self.homework.club_id:
from django.core.exceptions import ValidationError
raise ValidationError('Wrestler must belong to the same club as the homework')
@property
def is_completed(self):
items = self.items.all()
if not items.exists():
return False
return all(item.is_completed for item in items)
@property
def completion_date(self):
if self.is_completed:
return self.items.filter(is_completed=True).order_by('-completion_date').first().completion_date
return None
class HomeworkAssignmentItem(models.Model):
assignment = models.ForeignKey(HomeworkAssignment, on_delete=models.CASCADE, related_name='items')
exercise = models.ForeignKey('exercises.Exercise', on_delete=models.CASCADE, related_name='assignment_items')
is_completed = models.BooleanField(default=False)
completion_date = models.DateField(null=True, blank=True)
class Meta:
unique_together = ['assignment', 'exercise']
indexes = [
models.Index(fields=['assignment', 'is_completed']),
models.Index(fields=['completion_date']),
]
def __str__(self):
status = "" if self.is_completed else ""
return f"{self.assignment} - {self.exercise.name} {status}"
class HomeworkStatus(models.Model):
homework = models.ForeignKey(Homework, on_delete=models.CASCADE, related_name='statuses')
wrestler = models.ForeignKey('wrestlers.Wrestler', on_delete=models.CASCADE, related_name='homework_statuses')
is_completed = models.BooleanField(default=False)
completion_date = models.DateField(null=True, blank=True)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['homework', 'wrestler']
def __str__(self):
status = "" if self.is_completed else ""
return f"{self.wrestler} - {self.homework.title} {status}"
# NEUES SYSTEM: Training-basierte Homework
# Jeder Wrestler bekommt individuelle Übungen zugewiesen
class TrainingHomeworkAssignment(models.Model):
"""A homework assignment for a specific wrestler in a specific training"""
training = models.ForeignKey('trainings.Training', on_delete=models.CASCADE, related_name='homework_assignments', default=1)
wrestler = models.ForeignKey('wrestlers.Wrestler', on_delete=models.CASCADE, related_name='training_homework_assignments')
club = models.ForeignKey('clubs.Club', on_delete=models.CASCADE, related_name='training_homework_assignments', null=True, blank=True)
notes = models.TextField(blank=True)
is_completed = models.BooleanField(default=False)
completion_date = models.DateField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ['training', 'wrestler']
ordering = ['-created_at']
indexes = [
models.Index(fields=['training']),
models.Index(fields=['wrestler']),
models.Index(fields=['is_completed']),
models.Index(fields=['club']),
]
def __str__(self):
return f"{self.wrestler} - Training {self.training_id}"
class TrainingHomeworkExerciseItem(models.Model):
"""Individual exercises assigned to a specific wrestler (NOT shared)"""
assignment = models.ForeignKey(TrainingHomeworkAssignment, on_delete=models.CASCADE, related_name='exercises')
exercise = models.ForeignKey('exercises.Exercise', on_delete=models.CASCADE, related_name='training_homework_exercises')
reps = models.PositiveIntegerField(null=True, blank=True)
time_minutes = models.PositiveIntegerField(null=True, blank=True)
order = models.IntegerField(default=0)
is_completed = models.BooleanField(default=False)
class Meta:
ordering = ['assignment', 'order']
def __str__(self):
return f"{self.assignment} - {self.exercise.name}"
# ALTES SYSTEM (für Rückwärtskompatibilität - wird nicht mehr verwendet)
class TrainingHomework(models.Model):
"""DEPRECATED: Each wrestler now has individual assignments"""
training = models.ForeignKey('trainings.Training', on_delete=models.CASCADE, related_name='homework_legacy')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['training']),
]
def __str__(self):
return f"Homework for Training {self.training_id}"
class TrainingHomeworkExercise(models.Model):
"""DEPRECATED: Exercises are now per-assignment"""
training_homework = models.ForeignKey(TrainingHomework, on_delete=models.CASCADE, related_name='exercises')
exercise = models.ForeignKey('exercises.Exercise', on_delete=models.CASCADE, related_name='training_homework_items')
reps = models.PositiveIntegerField(null=True, blank=True)
time_minutes = models.PositiveIntegerField(null=True, blank=True)
order = models.IntegerField(default=0)
class Meta:
ordering = ['training_homework', 'order']
unique_together = ['training_homework', 'exercise']
def __str__(self):
return f"{self.training_homework} - {self.exercise.name}"
+190
View File
@@ -0,0 +1,190 @@
from rest_framework import serializers
from .models import (
Homework, HomeworkExerciseItem, HomeworkAssignment,
HomeworkAssignmentItem, HomeworkStatus,
TrainingHomeworkAssignment, TrainingHomeworkExerciseItem
)
class HomeworkExerciseItemSerializer(serializers.ModelSerializer):
exercise_name = serializers.CharField(source='exercise.name', read_only=True)
exercise_category = serializers.CharField(source='exercise.category', read_only=True)
class Meta:
model = HomeworkExerciseItem
fields = ['id', 'exercise', 'exercise_name', 'exercise_category', 'reps', 'time_minutes', 'order']
class HomeworkSerializer(serializers.ModelSerializer):
exercise_items = HomeworkExerciseItemSerializer(many=True, read_only=True)
club_name = serializers.CharField(source='club.name', read_only=True)
exercise_count = serializers.IntegerField(source='exercise_items.count', read_only=True)
class Meta:
model = Homework
fields = ['id', 'title', 'description', 'club', 'club_name', 'due_date', 'is_active',
'exercise_items', 'exercise_count', 'created_at', 'updated_at']
class HomeworkDetailSerializer(HomeworkSerializer):
exercise_items = HomeworkExerciseItemSerializer(many=True, read_only=True)
class Meta(HomeworkSerializer.Meta):
fields = HomeworkSerializer.Meta.fields
class HomeworkAssignmentItemSerializer(serializers.ModelSerializer):
exercise_name = serializers.CharField(source='exercise.name', read_only=True)
exercise_category = serializers.CharField(source='exercise.category', read_only=True)
class Meta:
model = HomeworkAssignmentItem
fields = ['id', 'exercise', 'exercise_name', 'exercise_category', 'is_completed', 'completion_date']
class HomeworkAssignmentSerializer(serializers.ModelSerializer):
homework_title = serializers.CharField(source='homework.title', read_only=True)
club_name = serializers.CharField(source='club.name', read_only=True)
wrestler_name = serializers.SerializerMethodField()
completed_items = serializers.SerializerMethodField()
total_items = serializers.SerializerMethodField()
items = HomeworkAssignmentItemSerializer(many=True, read_only=True)
class Meta:
model = HomeworkAssignment
fields = ['id', 'homework', 'homework_title', 'wrestler', 'wrestler_name', 'club', 'club_name',
'due_date', 'notes', 'is_completed', 'completion_date', 'completed_items',
'total_items', 'items', 'created_at']
def get_wrestler_name(self, obj):
return f"{obj.wrestler.first_name} {obj.wrestler.last_name}"
def get_completed_items(self, obj):
return obj.items.filter(is_completed=True).count()
def get_total_items(self, obj):
return obj.items.count()
class HomeworkAssignmentListSerializer(serializers.ModelSerializer):
homework_title = serializers.CharField(source='homework.title', read_only=True)
club_name = serializers.CharField(source='club.name', read_only=True)
wrestler_name = serializers.SerializerMethodField()
class Meta:
model = HomeworkAssignment
fields = ['id', 'homework', 'homework_title', 'wrestler', 'wrestler_name', 'club', 'club_name',
'due_date', 'notes', 'is_completed', 'completion_date', 'created_at']
def get_wrestler_name(self, obj):
return f"{obj.wrestler.first_name} {obj.wrestler.last_name}"
class AssignHomeworkSerializer(serializers.Serializer):
homework = serializers.IntegerField()
wrestlers = serializers.ListField(child=serializers.IntegerField())
due_date = serializers.DateField(required=False, allow_null=True)
notes = serializers.CharField(required=False, allow_blank=True, default='')
class CompleteItemSerializer(serializers.Serializer):
item_id = serializers.IntegerField()
class HomeworkStatusSerializer(serializers.ModelSerializer):
wrestler_name = serializers.CharField(source='wrestler.__str__', read_only=True)
class Meta:
model = HomeworkStatus
fields = '__all__'
# NEUES SYSTEM: Training-basierte Homework
class TrainingHomeworkExerciseItemSerializer(serializers.ModelSerializer):
exercise_name = serializers.CharField(source='exercise.name', read_only=True)
exercise_category = serializers.CharField(source='exercise.category', read_only=True)
class Meta:
model = TrainingHomeworkExerciseItem
fields = ['id', 'exercise', 'exercise_name', 'exercise_category', 'reps', 'time_minutes', 'order', 'is_completed']
class TrainingHomeworkAssignmentSerializer(serializers.ModelSerializer):
exercises = serializers.SerializerMethodField()
training_date = serializers.DateField(source='training.date', read_only=True)
training_group = serializers.CharField(source='training.group', read_only=True)
wrestler_name = serializers.SerializerMethodField()
wrestler_group = serializers.CharField(source='wrestler.group', read_only=True)
class Meta:
model = TrainingHomeworkAssignment
fields = ['id', 'training', 'training_date', 'training_group', 'wrestler', 'wrestler_name',
'wrestler_group', 'notes', 'is_completed', 'completion_date', 'exercises', 'created_at']
def get_wrestler_name(self, obj):
return f"{obj.wrestler.first_name} {obj.wrestler.last_name}"
def get_exercises(self, obj):
exercises = obj.exercises.all().order_by('order')
return TrainingHomeworkExerciseItemSerializer(exercises, many=True).data
class TrainingHomeworkAssignmentCreateSerializer(serializers.Serializer):
training = serializers.IntegerField()
wrestler = serializers.IntegerField()
exercises = serializers.ListField(
child=serializers.DictField(child=serializers.IntegerField(allow_null=True))
)
notes = serializers.CharField(required=False, allow_blank=True, default='')
def create(self, validated_data):
from utils.permissions import get_user_club
from exercises.models import Exercise
from trainings.models import Training
from wrestlers.models import Wrestler
user = self.context['request'].user
club = get_user_club(user)
training_id = validated_data['training']
wrestler_id = validated_data['wrestler']
exercises_data = validated_data['exercises']
notes = validated_data.get('notes', '')
try:
training_obj = Training.objects.get(id=training_id)
except Training.DoesNotExist:
raise serializers.ValidationError({'training': f'Training with id {training_id} does not exist'})
try:
wrestler_obj = Wrestler.objects.get(id=wrestler_id)
except Wrestler.DoesNotExist:
raise serializers.ValidationError({'wrestler': f'Wrestler with id {wrestler_id} does not exist'})
# Create assignment
assignment = TrainingHomeworkAssignment.objects.create(
training=training_obj,
wrestler=wrestler_obj,
club=club,
notes=notes
)
# Create individual exercises for this assignment
for i, ex in enumerate(exercises_data):
try:
exercise_obj = Exercise.objects.get(id=ex['exercise'])
TrainingHomeworkExerciseItem.objects.create(
assignment=assignment,
exercise=exercise_obj,
reps=ex.get('reps'),
time_minutes=ex.get('time_minutes'),
order=i
)
except Exercise.DoesNotExist:
pass
return assignment
def to_representation(self, instance):
return TrainingHomeworkAssignmentSerializer(instance, context=self.context).data
+237
View File
@@ -0,0 +1,237 @@
from rest_framework import viewsets, filters, status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
from django.utils import timezone
from django.db import transaction
from utils.permissions import ClubLevelPermission, ClubFilterBackend
from .models import (
Homework, HomeworkExerciseItem, HomeworkAssignment,
HomeworkAssignmentItem, HomeworkStatus,
TrainingHomeworkAssignment, TrainingHomeworkExerciseItem
)
from .serializers import (
HomeworkSerializer, HomeworkDetailSerializer, HomeworkExerciseItemSerializer,
HomeworkAssignmentSerializer, HomeworkAssignmentListSerializer,
AssignHomeworkSerializer, CompleteItemSerializer, HomeworkStatusSerializer,
TrainingHomeworkAssignmentSerializer, TrainingHomeworkAssignmentCreateSerializer
)
from wrestleDesk.pagination import StandardResultsSetPagination
class HomeworkViewSet(viewsets.ModelViewSet):
queryset = Homework.objects.prefetch_related('exercise_items', 'exercise_items__exercise').all()
serializer_class = HomeworkSerializer
pagination_class = StandardResultsSetPagination
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['club', 'is_active']
search_fields = ['title', 'description']
ordering_fields = ['created_at', 'due_date']
def get_serializer_class(self):
if self.action == 'retrieve':
return HomeworkDetailSerializer
return HomeworkSerializer
class HomeworkExerciseItemViewSet(viewsets.ModelViewSet):
queryset = HomeworkExerciseItem.objects.select_related('homework', 'exercise').all()
serializer_class = HomeworkExerciseItemSerializer
pagination_class = StandardResultsSetPagination
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend]
filterset_fields = ['homework']
class HomeworkAssignmentViewSet(viewsets.ModelViewSet):
queryset = HomeworkAssignment.objects.select_related('homework', 'wrestler', 'club').prefetch_related('items').all()
serializer_class = HomeworkAssignmentSerializer
pagination_class = StandardResultsSetPagination
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['homework', 'wrestler', 'club', 'is_completed']
search_fields = ['wrestler__first_name', 'wrestler__last_name']
ordering_fields = ['created_at', 'due_date']
def get_serializer_class(self):
if self.action == 'list':
return HomeworkAssignmentListSerializer
return HomeworkAssignmentSerializer
@action(detail=True, methods=['post'])
@transaction.atomic
def complete_item(self, request, pk=None):
serializer = CompleteItemSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
assignment = self.get_object()
item_id = serializer.validated_data['item_id']
try:
item = assignment.items.get(id=item_id)
except HomeworkAssignmentItem.DoesNotExist:
return Response(
{'detail': 'Item not found'},
status=status.HTTP_404_NOT_FOUND
)
item.is_completed = True
item.completion_date = timezone.now()
item.save()
return Response({'status': 'item completed'})
@action(detail=False, methods=['post'])
@transaction.atomic
def assign(self, request):
serializer = AssignHomeworkSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
homework_id = serializer.validated_data['homework']
wrestler_ids = serializer.validated_data['wrestlers']
due_date = serializer.validated_data.get('due_date')
notes = serializer.validated_data.get('notes', '')
try:
homework = Homework.objects.get(id=homework_id)
except Homework.DoesNotExist:
return Response({'detail': 'Homework not found'}, status=status.HTTP_404_NOT_FOUND)
created_assignments = []
errors = []
for wrestler_id in wrestler_ids:
try:
existing = HomeworkAssignment.objects.filter(
homework=homework,
wrestler_id=wrestler_id
).exists()
if existing:
errors.append(f"Wrestler {wrestler_id} hat bereits diese Hausaufgabe")
continue
assignment = HomeworkAssignment.objects.create(
homework=homework,
wrestler_id=wrestler_id,
club=homework.club,
due_date=due_date,
notes=notes
)
# Copy exercises from homework to assignment
for exercise_item in homework.exercise_items.all():
HomeworkAssignmentItem.objects.create(
assignment=assignment,
exercise=exercise_item.exercise
)
created_assignments.append(assignment.id)
except Exception as e:
errors.append(f"Wrestler {wrestler_id}: {str(e)}")
return Response({
'created': created_assignments,
'errors': errors
})
class HomeworkStatusViewSet(viewsets.ModelViewSet):
queryset = HomeworkStatus.objects.select_related('homework', 'wrestler').all()
serializer_class = HomeworkStatusSerializer
pagination_class = StandardResultsSetPagination
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ['homework', 'wrestler', 'is_completed']
ordering_fields = ['created_at', 'completion_date']
# NEUES SYSTEM: Training-basierte Homework
class TrainingHomeworkAssignmentViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
serializer_class = TrainingHomeworkAssignmentSerializer
filterset_fields = ['is_completed', 'training', 'wrestler']
search_fields = ['wrestler__first_name', 'wrestler__last_name']
ordering_fields = ['created_at', 'is_completed']
http_method_names = ['get', 'post', 'patch', 'delete']
def get_queryset(self):
queryset = TrainingHomeworkAssignment.objects.select_related(
'training', 'wrestler'
).prefetch_related(
'exercises', 'exercises__exercise'
).all()
# Filter by training ID if provided
training_id = self.request.query_params.get('training')
if training_id:
queryset = queryset.filter(training_id=training_id)
return queryset
def get_serializer_class(self):
if self.action == 'create':
return TrainingHomeworkAssignmentCreateSerializer
return TrainingHomeworkAssignmentSerializer
@action(detail=True, methods=['post'])
def complete(self, request, pk=None):
assignment = self.get_object()
assignment.is_completed = True
assignment.completion_date = timezone.now().date()
assignment.save()
# Also mark all exercises as completed
assignment.exercises.update(is_completed=True)
serializer = self.get_serializer(assignment)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def uncomplete(self, request, pk=None):
assignment = self.get_object()
assignment.is_completed = False
assignment.completion_date = None
assignment.save()
# Also mark all exercises as not completed
assignment.exercises.update(is_completed=False)
serializer = self.get_serializer(assignment)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def complete_exercise(self, request, pk=None):
assignment = self.get_object()
exercise_id = request.data.get('exercise_id')
if not exercise_id:
return Response({'detail': 'exercise_id is required'}, status=status.HTTP_400_BAD_REQUEST)
try:
item = assignment.exercises.get(id=exercise_id)
item.is_completed = True
item.save()
return Response({'status': 'exercise completed'})
except TrainingHomeworkExerciseItem.DoesNotExist:
return Response({'detail': 'Exercise not found'}, status=status.HTTP_404_NOT_FOUND)
@action(detail=True, methods=['post'])
def uncomplete_exercise(self, request, pk=None):
assignment = self.get_object()
exercise_id = request.data.get('exercise_id')
if not exercise_id:
return Response({'detail': 'exercise_id is required'}, status=status.HTTP_400_BAD_REQUEST)
try:
item = assignment.exercises.get(id=exercise_id)
item.is_completed = False
item.save()
return Response({'status': 'exercise uncompleted'})
except TrainingHomeworkExerciseItem.DoesNotExist:
return Response({'detail': 'Exercise not found'}, status=status.HTTP_404_NOT_FOUND)
View File
+31
View File
@@ -0,0 +1,31 @@
from django.contrib import admin
from .models import LeistungstestTemplate, LeistungstestTemplateExercise, LeistungstestResult, LeistungstestResultItem
class LeistungstestTemplateExerciseInline(admin.TabularInline):
model = LeistungstestTemplateExercise
extra = 0
readonly_fields = ['exercise']
@admin.register(LeistungstestTemplate)
class LeistungstestTemplateAdmin(admin.ModelAdmin):
list_display = ['name', 'created_at', 'usage_count']
search_fields = ['name']
inlines = [LeistungstestTemplateExerciseInline]
@admin.register(LeistungstestResult)
class LeistungstestResultAdmin(admin.ModelAdmin):
list_display = ['id', 'wrestler', 'template', 'total_time_seconds', 'score_percent', 'rating', 'completed_at']
list_filter = ['template', 'rating', 'completed_at']
search_fields = ['wrestler__first_name', 'wrestler__last_name', 'template__name']
readonly_fields = ['score_percent', 'created_at']
raw_id_fields = ['wrestler', 'template']
@admin.register(LeistungstestResultItem)
class LeistungstestResultItemAdmin(admin.ModelAdmin):
list_display = ['id', 'result', 'exercise', 'target_reps', 'actual_reps', 'elapsed_seconds', 'order']
list_filter = ['exercise']
raw_id_fields = ['result', 'exercise']
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class LeistungstestConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'leistungstest'
@@ -0,0 +1,94 @@
# Generated by Django 4.2.29 on 2026-03-23 12:43
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
('wrestlers', '0002_alter_wrestler_license_scan_alter_wrestler_photo'),
('exercises', '0003_alter_exercise_media'),
]
operations = [
migrations.CreateModel(
name='LeistungstestResult',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('total_time_minutes', models.PositiveIntegerField(blank=True, null=True)),
('rating', models.PositiveSmallIntegerField(choices=[(1, 1), (2, 2), (3, 3), (4, 4), (5, 5)], default=3)),
('notes', models.TextField(blank=True)),
('completed_at', models.DateTimeField(default=django.utils.timezone.now)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['-completed_at'],
},
),
migrations.CreateModel(
name='LeistungstestTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='LeistungstestResultItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('target_reps', models.PositiveIntegerField()),
('actual_reps', models.PositiveIntegerField()),
('order', models.IntegerField(default=0)),
('exercise', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='exercises.exercise')),
('result', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='leistungstest.leistungstestresult')),
],
options={
'ordering': ['result', 'order'],
},
),
migrations.AddField(
model_name='leistungstestresult',
name='template',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='leistungstest.leistungstesttemplate'),
),
migrations.AddField(
model_name='leistungstestresult',
name='wrestler',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='leistungstest_results', to='wrestlers.wrestler'),
),
migrations.CreateModel(
name='LeistungstestTemplateExercise',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('target_reps', models.PositiveIntegerField()),
('order', models.IntegerField(default=0)),
('exercise', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='exercises.exercise')),
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exercises', to='leistungstest.leistungstesttemplate')),
],
options={
'ordering': ['template', 'order'],
'unique_together': {('template', 'exercise')},
},
),
migrations.AddIndex(
model_name='leistungstestresult',
index=models.Index(fields=['wrestler'], name='leistungste_wrestle_f3f6c2_idx'),
),
migrations.AddIndex(
model_name='leistungstestresult',
index=models.Index(fields=['template'], name='leistungste_templat_daf98b_idx'),
),
migrations.AddIndex(
model_name='leistungstestresult',
index=models.Index(fields=['completed_at'], name='leistungste_complet_838820_idx'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-03-24 08:14
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('leistungstest', '0001_initial'),
]
operations = [
migrations.RenameField(
model_name='leistungstestresult',
old_name='total_time_minutes',
new_name='total_time_seconds',
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-03-24 09:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('leistungstest', '0002_change_total_time_to_seconds'),
]
operations = [
migrations.AddField(
model_name='leistungstestresultitem',
name='elapsed_seconds',
field=models.PositiveIntegerField(default=0),
),
]
+79
View File
@@ -0,0 +1,79 @@
from django.db import models
from django.utils import timezone
class LeistungstestTemplate(models.Model):
name = models.CharField(max_length=200)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return self.name
@property
def usage_count(self):
return LeistungstestResult.objects.filter(template_id=self.pk).count()
class LeistungstestTemplateExercise(models.Model):
template = models.ForeignKey(LeistungstestTemplate, on_delete=models.CASCADE, related_name='exercises')
exercise = models.ForeignKey('exercises.Exercise', on_delete=models.CASCADE)
target_reps = models.PositiveIntegerField()
order = models.IntegerField(default=0)
class Meta:
ordering = ['template', 'order']
unique_together = ['template', 'exercise']
def __str__(self):
return f"{self.template.name} - {self.exercise.name}"
class LeistungstestResult(models.Model):
template = models.ForeignKey(LeistungstestTemplate, on_delete=models.CASCADE, related_name='results')
wrestler = models.ForeignKey('wrestlers.Wrestler', on_delete=models.CASCADE, related_name='leistungstest_results')
total_time_seconds = models.PositiveIntegerField(null=True, blank=True)
rating = models.PositiveSmallIntegerField(choices=[(1,1),(2,2),(3,3),(4,4),(5,5)], default=3)
notes = models.TextField(blank=True)
completed_at = models.DateTimeField(default=timezone.now)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-completed_at']
indexes = [
models.Index(fields=['wrestler']),
models.Index(fields=['template']),
models.Index(fields=['completed_at']),
]
def __str__(self):
return f"{self.wrestler} - {self.template.name}"
@property
def score_percent(self):
items = self.items.all()
if not items.exists():
return 0
total_target = sum(item.target_reps for item in items)
total_actual = sum(item.actual_reps for item in items)
if total_target == 0:
return 0
return round((total_actual / total_target) * 100, 1)
class LeistungstestResultItem(models.Model):
result = models.ForeignKey(LeistungstestResult, on_delete=models.CASCADE, related_name='items')
exercise = models.ForeignKey('exercises.Exercise', on_delete=models.CASCADE)
target_reps = models.PositiveIntegerField()
actual_reps = models.PositiveIntegerField()
order = models.IntegerField(default=0)
elapsed_seconds = models.PositiveIntegerField(default=0)
class Meta:
ordering = ['result', 'order']
def __str__(self):
return f"{self.result} - {self.exercise.name}: {self.actual_reps}/{self.target_reps}"
+52
View File
@@ -0,0 +1,52 @@
from rest_framework import serializers
from .models import LeistungstestTemplate, LeistungstestTemplateExercise, LeistungstestResult, LeistungstestResultItem
class LeistungstestTemplateExerciseSerializer(serializers.ModelSerializer):
exercise_name = serializers.CharField(source='exercise.name', read_only=True)
class Meta:
model = LeistungstestTemplateExercise
fields = ['id', 'exercise', 'exercise_name', 'target_reps', 'order']
class LeistungstestTemplateSerializer(serializers.ModelSerializer):
exercises = LeistungstestTemplateExerciseSerializer(many=True, read_only=True)
usage_count = serializers.IntegerField(read_only=True)
class Meta:
model = LeistungstestTemplate
fields = ['id', 'name', 'exercises', 'usage_count', 'created_at']
class LeistungstestResultItemSerializer(serializers.ModelSerializer):
exercise_name = serializers.CharField(source='exercise.name', read_only=True)
class Meta:
model = LeistungstestResultItem
fields = ['id', 'exercise', 'exercise_name', 'target_reps', 'actual_reps', 'elapsed_seconds', 'order']
class LeistungstestResultSerializer(serializers.ModelSerializer):
items = LeistungstestResultItemSerializer(many=True, read_only=True)
template_name = serializers.CharField(source='template.name', read_only=True)
wrestler_name = serializers.CharField(source='wrestler.__str__', read_only=True)
score_percent = serializers.FloatField(read_only=True)
total_time_minutes = serializers.SerializerMethodField()
class Meta:
model = LeistungstestResult
fields = ['id', 'template', 'template_name', 'wrestler', 'wrestler_name',
'total_time_minutes', 'total_time_seconds', 'rating', 'notes', 'completed_at',
'score_percent', 'items', 'created_at']
read_only_fields = ['total_time_minutes']
def get_total_time_minutes(self, obj):
if obj.total_time_seconds is None:
return None
return obj.total_time_seconds // 60
def validate_total_time_seconds(self, value):
if value is not None and value < 0:
raise serializers.ValidationError("Zeit muss positiv sein.")
return value
+78
View File
@@ -0,0 +1,78 @@
from datetime import date, timedelta
def get_date_range(period):
"""Return start date for period filter, or None for 'all'."""
today = date.today()
if period == "month":
return today.replace(day=1)
elif period == "3months":
return today - timedelta(days=90)
elif period == "year":
return today.replace(month=1, day=1)
return None
def get_template_leaderboard(template_id, period="all", limit=10):
"""Return top wrestlers by score_percent for a template."""
from .models import LeistungstestResult
qs = LeistungstestResult.objects.filter(template_id=template_id)
start_date = get_date_range(period)
if start_date:
qs = qs.filter(completed_at__date__gte=start_date)
qs = qs.select_related('wrestler')
results = []
all_results = list(qs)
all_results.sort(key=lambda r: (-r.score_percent, r.total_time_seconds))
for rank, result in enumerate(all_results[:limit], 1):
results.append({
'rank': rank,
'wrestler_id': result.wrestler_id,
'wrestler_name': str(result.wrestler),
'score_percent': result.score_percent,
'total_time_seconds': result.total_time_seconds,
'completed_at': result.completed_at.date().isoformat() if result.completed_at else None,
})
return results
def get_exercise_leaderboard(exercise_id, period="all", limit=10):
"""Return top wrestlers by best time for an exercise."""
from .models import LeistungstestResultItem, LeistungstestResult
from django.db.models import Min
start_date = get_date_range(period)
qs = LeistungstestResultItem.objects.filter(exercise_id=exercise_id)
if start_date:
qs = qs.filter(result__completed_at__date__gte=start_date)
# Get best time per wrestler
best_times = qs.values('result__wrestler__id', 'result__wrestler__first_name', 'result__wrestler__last_name', 'result__completed_at__date')\
.annotate(best_time=Min('elapsed_seconds'))\
.order_by('best_time')
results = []
for rank, item in enumerate(best_times[:limit], 1):
wrestler_name = f"{item['result__wrestler__first_name']} {item['result__wrestler__last_name']}"
results.append({
'rank': rank,
'wrestler_id': item['result__wrestler__id'],
'wrestler_name': wrestler_name.strip(),
'best_time_seconds': item['best_time'],
'completed_at': item['result__completed_at__date'].isoformat() if item['result__completed_at__date'] else None,
})
return results
def get_used_exercises():
"""Return all exercises that have been used in any Leistungstest result."""
from .models import LeistungstestResultItem
from exercises.models import Exercise
exercise_ids = LeistungstestResultItem.objects.values_list('exercise_id', flat=True).distinct()
return Exercise.objects.filter(id__in=exercise_ids).order_by('name')
+14
View File
@@ -0,0 +1,14 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import LeistungstestTemplateViewSet, LeistungstestTemplateExerciseViewSet, LeistungstestResultViewSet, LeistungstestResultItemViewSet, LeistungstestStatsViewSet
router = DefaultRouter()
router.register('templates', LeistungstestTemplateViewSet, basename='leistungstest-template')
router.register('template-exercises', LeistungstestTemplateExerciseViewSet, basename='leistungstest-template-exercise')
router.register('results', LeistungstestResultViewSet, basename='leistungstest-result')
router.register('result-items', LeistungstestResultItemViewSet, basename='leistungstest-result-item')
router.register('stats', LeistungstestStatsViewSet, basename='leistungstest-stats')
urlpatterns = [
path('', include(router.urls)),
]
+259
View File
@@ -0,0 +1,259 @@
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .models import LeistungstestTemplate, LeistungstestTemplateExercise, LeistungstestResult, LeistungstestResultItem
from .serializers import (
LeistungstestTemplateSerializer,
LeistungstestTemplateExerciseSerializer,
LeistungstestResultSerializer,
LeistungstestResultItemSerializer,
)
from .stats import get_template_leaderboard, get_exercise_leaderboard, get_used_exercises
class LeistungstestTemplateViewSet(viewsets.ModelViewSet):
queryset = LeistungstestTemplate.objects.all()
serializer_class = LeistungstestTemplateSerializer
def get_queryset(self):
return LeistungstestTemplate.objects.all().prefetch_related('exercises__exercise')
def create(self, request, *args, **kwargs):
name = request.data.get('name')
exercises_data = request.data.get('exercises', [])
# Create template first
template = LeistungstestTemplate.objects.create(name=name)
# Create exercises
for i, ex_data in enumerate(exercises_data):
LeistungstestTemplateExercise.objects.create(
template=template,
exercise_id=ex_data['exercise'],
target_reps=ex_data['target_reps'],
order=ex_data.get('order', i),
)
# Reload with prefetch to get exercise names
template = LeistungstestTemplate.objects.prefetch_related('exercises__exercise').get(pk=template.pk)
return Response(
LeistungstestTemplateSerializer(template).data,
status=status.HTTP_201_CREATED
)
@action(detail=True, methods=['post'])
def duplicate(self, request, pk=None):
template = self.get_object()
new_template = LeistungstestTemplate.objects.create(name=f"{template.name} (Kopie)")
for exercise in template.exercises.all():
LeistungstestTemplateExercise.objects.create(
template=new_template,
exercise=exercise.exercise,
target_reps=exercise.target_reps,
order=exercise.order,
)
return Response(
LeistungstestTemplateSerializer(new_template).data,
status=status.HTTP_201_CREATED
)
def update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False)
instance = self.get_object()
instance.name = request.data.get('name', instance.name)
instance.save()
exercises_data = request.data.get('exercises')
if exercises_data is not None:
instance.exercises.all().delete()
for i, ex_data in enumerate(exercises_data):
LeistungstestTemplateExercise.objects.create(
template=instance,
exercise_id=ex_data['exercise'],
target_reps=ex_data['target_reps'],
order=ex_data.get('order', i),
)
instance = LeistungstestTemplate.objects.prefetch_related('exercises__exercise').get(pk=instance.pk)
return Response(LeistungstestTemplateSerializer(instance).data)
class LeistungstestTemplateExerciseViewSet(viewsets.ModelViewSet):
queryset = LeistungstestTemplateExercise.objects.all()
serializer_class = LeistungstestTemplateExerciseSerializer
class LeistungstestResultViewSet(viewsets.ModelViewSet):
queryset = LeistungstestResult.objects.all()
serializer_class = LeistungstestResultSerializer
def get_queryset(self):
queryset = LeistungstestResult.objects.all().prefetch_related('items__exercise')
template_id = self.request.query_params.get('template')
wrestler_id = self.request.query_params.get('wrestler')
if template_id:
queryset = queryset.filter(template_id=template_id)
if wrestler_id:
queryset = queryset.filter(wrestler_id=wrestler_id)
return queryset
def create(self, request, *args, **kwargs):
template_id = request.data.get('template')
wrestler_id = request.data.get('wrestler')
items_data = request.data.get('items', [])
result = LeistungstestResult.objects.create(
template_id=template_id,
wrestler_id=wrestler_id,
total_time_seconds=request.data.get('total_time_seconds') or None,
rating=request.data.get('rating', 3),
notes=request.data.get('notes', ''),
)
for i, item_data in enumerate(items_data):
LeistungstestResultItem.objects.create(
result=result,
exercise_id=item_data['exercise'],
target_reps=item_data.get('target_reps', 0),
actual_reps=item_data.get('actual_reps', 0),
elapsed_seconds=item_data.get('elapsed_seconds', 0),
order=item_data.get('order', i),
)
result_items = LeistungstestResultItem.objects.filter(result=result)
total_target = sum(item.target_reps for item in result_items)
total_actual = sum(item.actual_reps for item in result_items)
if total_target > 0:
score = round((total_actual / total_target) * 100, 1)
else:
score = 0.0
result.refresh_from_db()
result_data = LeistungstestResultSerializer(result).data
result_data['score_percent'] = score
return Response(result_data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False)
instance = self.get_object()
instance.total_time_seconds = request.data.get('total_time_seconds', instance.total_time_seconds)
instance.rating = request.data.get('rating', instance.rating)
instance.notes = request.data.get('notes', instance.notes)
instance.save()
items_data = request.data.get('items')
if items_data is not None:
instance.items.all().delete()
for i, item_data in enumerate(items_data):
LeistungstestResultItem.objects.create(
result=instance,
exercise_id=item_data['exercise'],
target_reps=item_data.get('target_reps', 0),
actual_reps=item_data.get('actual_reps', 0),
elapsed_seconds=item_data.get('elapsed_seconds', 0),
order=item_data.get('order', i),
)
result_items = LeistungstestResultItem.objects.filter(result=instance)
total_target = sum(item.target_reps for item in result_items)
total_actual = sum(item.actual_reps for item in result_items)
if total_target > 0:
score = round((total_actual / total_target) * 100, 1)
else:
score = 0.0
result_data = LeistungstestResultSerializer(instance).data
result_data['score_percent'] = score
return Response(result_data)
@action(detail=False, methods=['get'])
def leaderboard(self, request):
template_id = request.query_params.get('template')
if not template_id:
return Response(
{'error': 'template parameter is required'},
status=status.HTTP_400_BAD_REQUEST
)
limit = int(request.query_params.get('limit', 10))
results = LeistungstestResult.objects.filter(template_id=template_id)\
.select_related('wrestler')
leaderboard_data = []
for result in results:
leaderboard_data.append({
'rank': 0,
'result_id': result.id,
'wrestler_id': result.wrestler.id,
'wrestler_name': str(result.wrestler),
'score_percent': result.score_percent,
'completed_at': result.completed_at,
'total_time_seconds': result.total_time_seconds,
'rating': result.rating,
})
leaderboard_data.sort(key=lambda x: x['score_percent'], reverse=True)
leaderboard_data = leaderboard_data[:limit]
for i, entry in enumerate(leaderboard_data, 1):
entry['rank'] = i
return Response(leaderboard_data)
class LeistungstestResultItemViewSet(viewsets.ModelViewSet):
queryset = LeistungstestResultItem.objects.all()
serializer_class = LeistungstestResultItemSerializer
class LeistungstestStatsViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
@action(detail=False, methods=['get'])
def leaderboard(self, request):
lb_type = request.query_params.get('type', 'template')
template_id = request.query_params.get('template_id')
exercise_id = request.query_params.get('exercise_id')
period = request.query_params.get('period', 'all')
limit = int(request.query_params.get('limit', 10))
if lb_type == 'template' and template_id:
results = get_template_leaderboard(int(template_id), period, limit)
template = LeistungstestTemplate.objects.get(pk=template_id)
return Response({
'template_id': template_id,
'template_name': template.name,
'period': period,
'results': results,
})
elif lb_type == 'exercise' and exercise_id:
from exercises.models import Exercise
results = get_exercise_leaderboard(int(exercise_id), period, limit)
exercise = Exercise.objects.get(pk=exercise_id)
return Response({
'exercise_id': exercise_id,
'exercise_name': exercise.name,
'period': period,
'results': results,
})
return Response({'error': 'Invalid parameters'}, status=400)
@action(detail=False, methods=['get'])
def exercises(self, request):
exercises = get_used_exercises()
return Response([{'id': e.id, 'name': e.name} for e in exercises])
+12
View File
@@ -0,0 +1,12 @@
import unfold
from unfold.admin import ModelAdmin as UnfoldModelAdmin
from django.contrib import admin
from .models import Location
@admin.register(Location)
class LocationAdmin(UnfoldModelAdmin):
list_display = ['name', 'is_active', 'created_at']
list_filter = ['is_active']
search_fields = ['name', 'address']
readonly_fields = ['created_at', 'updated_at']
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class LocationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'locations'
@@ -0,0 +1,29 @@
# Generated by Django 4.2.29 on 2026-03-19 09:05
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Location',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('address', models.TextField(blank=True)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name_plural': 'Locations',
'ordering': ['name'],
},
),
]
@@ -0,0 +1,24 @@
# Generated by Django 4.2.29 on 2026-03-20 14:40
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('clubs', '0001_initial'),
('locations', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='location',
name='club',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='locations', to='clubs.club'),
),
migrations.AddIndex(
model_name='location',
index=models.Index(fields=['club'], name='locations_l_club_id_aa048c_idx'),
),
]
+20
View File
@@ -0,0 +1,20 @@
from django.db import models
class Location(models.Model):
name = models.CharField(max_length=200)
address = models.TextField(blank=True)
club = models.ForeignKey('clubs.Club', on_delete=models.CASCADE, related_name='locations', null=True, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['name']
verbose_name_plural = 'Locations'
indexes = [
models.Index(fields=['club']),
]
def __str__(self):
return self.name
+9
View File
@@ -0,0 +1,9 @@
from rest_framework import serializers
from .models import Location
class LocationSerializer(serializers.ModelSerializer):
class Meta:
model = Location
fields = '__all__'
read_only_fields = ['created_at', 'updated_at']
+17
View File
@@ -0,0 +1,17 @@
from rest_framework import viewsets, filters
from rest_framework.permissions import IsAuthenticated
from django_filters.rest_framework import DjangoFilterBackend
from .models import Location
from .serializers import LocationSerializer
from wrestleDesk.pagination import StandardResultsSetPagination
class LocationViewSet(viewsets.ModelViewSet):
queryset = Location.objects.all()
serializer_class = LocationSerializer
pagination_class = StandardResultsSetPagination
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['is_active']
search_fields = ['name', 'address']
ordering_fields = ['name', 'created_at']
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'wrestleDesk.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
+17
View File
@@ -0,0 +1,17 @@
Django>=4.2,<5.0
djangorestframework>=3.14
django-filter>=24.0
django-cors-headers>=4.3
djangorestframework-simplejwt>=5.3
drf-spectacular>=0.27
django-unfold>=0.30
django-import-export>=4.0
Pillow>=10.0
django-resized>=1.0
django-cleanup>=8.0
psycopg2-binary>=2.9
gunicorn>=21.0
whitenoise>=6.0
django-environ>=0.11
black>=24.0
isort>=5.13
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class StatsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'stats'
+92
View File
@@ -0,0 +1,92 @@
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from django.db.models import Count
from datetime import datetime, timedelta
from wrestlers.models import Wrestler
from trainers.models import Trainer
from trainings.models import Training, Attendance
from homework.models import TrainingHomeworkAssignment
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def dashboard_stats(request):
today = datetime.now().date()
week_start = today - timedelta(days=today.weekday())
two_weeks_ago = today - timedelta(days=14)
# Wrestlers stats
total_wrestlers = Wrestler.objects.count()
wrestlers_this_week = Wrestler.objects.filter(
created_at__date__gte=week_start
).count()
# Trainers stats
total_trainers = Trainer.objects.count()
active_trainers = Trainer.objects.filter(is_active=True).count()
# Trainings stats
total_trainings = Training.objects.count()
trainings_this_week = Training.objects.filter(
date__gte=week_start
).count()
# Homework stats
open_homework = TrainingHomeworkAssignment.objects.filter(is_completed=False).count()
completed_homework = TrainingHomeworkAssignment.objects.filter(is_completed=True).count()
# Attendance by group this week
trainings_this_week_qs = Training.objects.filter(date__gte=week_start)
attendance_data = {}
for group, label in [('kids', 'Kinder'), ('youth', 'Jugend'), ('adults', 'Erwachsene')]:
group_wrestlers = Wrestler.objects.filter(group=group)
attended = Attendance.objects.filter(
training__in=trainings_this_week_qs,
wrestler__in=group_wrestlers
).values('wrestler').distinct().count()
total = group_wrestlers.count()
attendance_data[group] = {
'attended': attended,
'total': total,
'percent': int((attended / total * 100) if total > 0 else 0)
}
# Activity (last 14 days)
activity = []
for i in range(14):
day = today - timedelta(days=13 - i)
count = Attendance.objects.filter(training__date=day).count()
activity.append({'date': day.isoformat(), 'count': count})
# Wrestlers by group
wrestlers_by_group = {
'kids': Wrestler.objects.filter(group='kids', is_active=True).count(),
'youth': Wrestler.objects.filter(group='youth', is_active=True).count(),
'adults': Wrestler.objects.filter(group='adults', is_active=True).count(),
'inactive': Wrestler.objects.filter(is_active=False).count(),
}
# Top trainers (by training count)
trainer_stats = Trainer.objects.annotate(
training_count=Count('trainings')
).order_by('-training_count')[:5]
top_trainers = [
{'name': t.first_name + ' ' + t.last_name[0] + '.', 'training_count': t.training_count}
for t in trainer_stats
]
return Response({
'wrestlers': {'total': total_wrestlers, 'this_week': wrestlers_this_week},
'trainers': {'total': total_trainers, 'active': active_trainers},
'trainings': {'total': total_trainings, 'this_week': trainings_this_week},
'homework': {'open': open_homework, 'completed': completed_homework},
'attendance': {
'this_week': attendance_data,
'average': Attendance.objects.filter(training__date__gte=week_start).values('training').distinct().count(),
'expected': total_wrestlers
},
'activity': activity,
'wrestlers_by_group': wrestlers_by_group,
'top_trainers': top_trainers,
})
View File
+20
View File
@@ -0,0 +1,20 @@
import unfold
from unfold.admin import ModelAdmin as UnfoldModelAdmin
from django.contrib import admin
from .models import TrainingTemplate, TemplateExercise
@admin.register(TrainingTemplate)
class TrainingTemplateAdmin(UnfoldModelAdmin):
list_display = ['name', 'category', 'is_active', 'created_at']
list_filter = ['category', 'is_active']
search_fields = ['name', 'description']
readonly_fields = ['created_at', 'updated_at']
filter_horizontal = ['exercises']
@admin.register(TemplateExercise)
class TemplateExerciseAdmin(UnfoldModelAdmin):
list_display = ['template', 'exercise', 'order', 'default_value']
list_filter = ['template']
raw_id_fields = ['template', 'exercise']
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class TemplatesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'templates'
@@ -0,0 +1,53 @@
# Generated by Django 4.2.29 on 2026-03-19 09:05
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('exercises', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='TemplateExercise',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order', models.PositiveIntegerField(default=0)),
('default_value', models.CharField(blank=True, max_length=50)),
('exercise', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='exercises.exercise')),
],
options={
'ordering': ['order'],
},
),
migrations.CreateModel(
name='TrainingTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('category', models.CharField(choices=[('warmup', 'Warm-up'), ('main', 'Main'), ('cooldown', 'Cool-down')], default='main', max_length=20)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('exercises', models.ManyToManyField(related_name='templates', through='templates.TemplateExercise', to='exercises.exercise')),
],
options={
'ordering': ['name'],
},
),
migrations.AddField(
model_name='templateexercise',
name='template',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='templates.trainingtemplate'),
),
migrations.AlterUniqueTogether(
name='templateexercise',
unique_together={('template', 'exercise')},
),
]
@@ -0,0 +1,20 @@
# Generated by Django 4.2.29 on 2026-03-20 14:51
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('clubs', '0001_initial'),
('templates', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='trainingtemplate',
name='club',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='training_templates', to='clubs.club'),
),
]
+33
View File
@@ -0,0 +1,33 @@
from django.db import models
class TrainingTemplate(models.Model):
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
category = models.CharField(max_length=20, choices=[
('warmup', 'Warm-up'),
('main', 'Main'),
('cooldown', 'Cool-down'),
], default='main')
club = models.ForeignKey('clubs.Club', on_delete=models.CASCADE, related_name='training_templates', null=True, blank=True)
exercises = models.ManyToManyField('exercises.Exercise', through='TemplateExercise', related_name='templates')
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
class TemplateExercise(models.Model):
template = models.ForeignKey(TrainingTemplate, on_delete=models.CASCADE)
exercise = models.ForeignKey('exercises.Exercise', on_delete=models.CASCADE)
order = models.PositiveIntegerField(default=0)
default_value = models.CharField(max_length=50, blank=True)
class Meta:
ordering = ['order']
unique_together = ['template', 'exercise']
+29
View File
@@ -0,0 +1,29 @@
from rest_framework import serializers
from .models import TrainingTemplate, TemplateExercise
class TemplateExerciseSerializer(serializers.ModelSerializer):
exercise_name = serializers.CharField(source='exercise.name', read_only=True)
class Meta:
model = TemplateExercise
fields = '__all__'
class TrainingTemplateSerializer(serializers.ModelSerializer):
exercises = TemplateExerciseSerializer(many=True, source='templateexercise_set', read_only=True)
exercise_count = serializers.SerializerMethodField()
class Meta:
model = TrainingTemplate
fields = '__all__'
read_only_fields = ['created_at', 'updated_at']
def get_exercise_count(self, obj):
return obj.templateexercise_set.count()
def create(self, validated_data):
request = self.context.get('request')
if request and hasattr(request.user, 'profile') and request.user.profile.club:
validated_data['club'] = request.user.profile.club
return super().create(validated_data)
+27
View File
@@ -0,0 +1,27 @@
from rest_framework import viewsets, filters
from rest_framework.permissions import IsAuthenticated
from django_filters.rest_framework import DjangoFilterBackend
from .models import TrainingTemplate, TemplateExercise
from .serializers import TrainingTemplateSerializer, TemplateExerciseSerializer
from wrestleDesk.pagination import StandardResultsSetPagination
class TrainingTemplateViewSet(viewsets.ModelViewSet):
queryset = TrainingTemplate.objects.prefetch_related('templateexercise_set').all()
serializer_class = TrainingTemplateSerializer
pagination_class = StandardResultsSetPagination
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['category', 'is_active']
search_fields = ['name', 'description']
ordering_fields = ['name', 'created_at']
class TemplateExerciseViewSet(viewsets.ModelViewSet):
queryset = TemplateExercise.objects.all()
serializer_class = TemplateExerciseSerializer
pagination_class = StandardResultsSetPagination
permission_classes = [IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ['template']
ordering_fields = ['order']
+13
View File
@@ -0,0 +1,13 @@
import unfold
from unfold.admin import ModelAdmin as UnfoldModelAdmin
from django.contrib import admin
from .models import Trainer
@admin.register(Trainer)
class TrainerAdmin(UnfoldModelAdmin):
list_display = ['first_name', 'last_name', 'club', 'email', 'is_active']
list_filter = ['is_active', 'club']
search_fields = ['first_name', 'last_name', 'email']
readonly_fields = ['created_at', 'updated_at']
raw_id_fields = ['club']
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class TrainersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'trainers'
@@ -0,0 +1,34 @@
# Generated by Django 4.2.29 on 2026-03-19 09:05
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('clubs', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Trainer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('first_name', models.CharField(max_length=100)),
('last_name', models.CharField(max_length=100)),
('email', models.EmailField(blank=True, max_length=254)),
('phone', models.CharField(blank=True, max_length=50)),
('photo', models.ImageField(blank=True, null=True, upload_to='trainers/photos/')),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('club', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trainers', to='clubs.club')),
],
options={
'ordering': ['last_name', 'first_name'],
},
),
]

Some files were not shown because too many files have changed in this diff Show More