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:
+42
@@ -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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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!**
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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__'
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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']
|
||||
@@ -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'
|
||||
))
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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']
|
||||
@@ -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']
|
||||
@@ -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'])]),
|
||||
),
|
||||
]
|
||||
@@ -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()})"
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
@@ -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']
|
||||
@@ -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']
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
+126
@@ -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),
|
||||
),
|
||||
]
|
||||
+67
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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']
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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')
|
||||
@@ -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)),
|
||||
]
|
||||
@@ -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])
|
||||
@@ -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']
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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']
|
||||
@@ -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']
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class StatsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'stats'
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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']
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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']
|
||||
@@ -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)
|
||||
@@ -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']
|
||||
@@ -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']
|
||||
@@ -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
Reference in New Issue
Block a user