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:
@@ -0,0 +1,415 @@
|
||||
# Leistungstest Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Create Leistungstest (Performance Test) system with templates, assignment, results tracking, and leaderboard.
|
||||
|
||||
**Architecture:** Backend provides CRUD for templates and results + leaderboard endpoint. Frontend has 4 tabs: Vorlagen, Zuweisen, Ergebnisse, Leaderboard.
|
||||
|
||||
**Tech Stack:** Django REST Framework (backend), React/Next.js (frontend)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Backend
|
||||
- **Create:** `backend/leistungstest/__init__.py`
|
||||
- **Create:** `backend/leistungstest/apps.py`
|
||||
- **Create:** `backend/leistungstest/models.py`
|
||||
- **Create:** `backend/leistungstest/serializers.py`
|
||||
- **Create:** `backend/leistungstest/views.py`
|
||||
- **Create:** `backend/leistungstest/urls.py`
|
||||
- **Modify:** `backend/wrestleDesk/settings.py` — add 'leistungstest'
|
||||
- **Modify:** `backend/wrestleDesk/urls.py` — include leistungstest URLs
|
||||
|
||||
### Frontend
|
||||
- **Create:** `frontend/src/app/(dashboard)/leistungstest/page.tsx`
|
||||
- **Modify:** `frontend/src/components/layout/sidebar.tsx` — add Leistungstest nav item
|
||||
- **Modify:** `frontend/src/lib/api.ts` — add interfaces
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Create Leistungstest Backend App
|
||||
|
||||
- [ ] **Step 1: Create directory and files**
|
||||
|
||||
Create `backend/leistungstest/` with `__init__.py` and `apps.py`
|
||||
|
||||
- [ ] **Step 2: Add to INSTALLED_APPS**
|
||||
|
||||
Add `'leistungstest'` to `INSTALLED_APPS` in `backend/wrestleDesk/settings.py`
|
||||
|
||||
- [ ] **Step 3: Create models**
|
||||
|
||||
```python
|
||||
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 self.results.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_minutes = 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)
|
||||
|
||||
class Meta:
|
||||
ordering = ['result', 'order']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.result} - {self.exercise.name}: {self.actual_reps}/{self.target_reps}"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create serializers**
|
||||
|
||||
```python
|
||||
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', '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)
|
||||
|
||||
class Meta:
|
||||
model = LeistungstestResult
|
||||
fields = ['id', 'template', 'template_name', 'wrestler', 'wrestler_name',
|
||||
'total_time_minutes', 'rating', 'notes', 'completed_at',
|
||||
'score_percent', 'items', 'created_at']
|
||||
|
||||
|
||||
class LeaderboardEntrySerializer(serializers.Serializer):
|
||||
rank = serializers.IntegerField()
|
||||
wrestler = serializers.DictField()
|
||||
score_percent = serializers.FloatField()
|
||||
rating = serializers.IntegerField()
|
||||
time_minutes = serializers.IntegerField(allow_null=True)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Create views**
|
||||
|
||||
```python
|
||||
from rest_framework import viewsets
|
||||
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 Max
|
||||
from .models import LeistungstestTemplate, LeistungstestTemplateExercise, LeistungstestResult, LeistungstestResultItem
|
||||
from .serializers import (
|
||||
LeistungstestTemplateSerializer, LeistungstestResultSerializer
|
||||
)
|
||||
|
||||
|
||||
class LeistungstestTemplateViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
queryset = LeistungstestTemplate.objects.all()
|
||||
serializer_class = LeistungstestTemplateSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
template = serializer.save()
|
||||
exercises_data = self.request.data.get('exercises', [])
|
||||
for i, ex in enumerate(exercises_data):
|
||||
LeistungstestTemplateExercise.objects.create(
|
||||
template=template,
|
||||
exercise_id=ex['exercise'],
|
||||
target_reps=ex['target_reps'],
|
||||
order=i
|
||||
)
|
||||
|
||||
|
||||
class LeistungstestTemplateExerciseViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
queryset = LeistungstestTemplateExercise.objects.all()
|
||||
serializer_class = None
|
||||
|
||||
|
||||
class LeistungstestResultViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = LeistungstestResultSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = LeistungstestResult.objects.all()
|
||||
wrestler = self.request.query_params.get('wrestler')
|
||||
template = self.request.query_params.get('template')
|
||||
if wrestler:
|
||||
queryset = queryset.filter(wrestler=wrestler)
|
||||
if template:
|
||||
queryset = queryset.filter(template=template)
|
||||
return queryset.prefetch_related('items')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
result = serializer.save()
|
||||
items_data = self.request.data.get('items', [])
|
||||
for i, item in enumerate(items_data):
|
||||
LeistungstestResultItem.objects.create(
|
||||
result=result,
|
||||
exercise_id=item['exercise'],
|
||||
target_reps=item['target_reps'],
|
||||
actual_reps=item['actual_reps'],
|
||||
order=i
|
||||
)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def leaderboard(request):
|
||||
template_id = request.query_params.get('template')
|
||||
if not template_id:
|
||||
return Response({'error': 'template is required'}, status=400)
|
||||
|
||||
template = LeistungstestTemplate.objects.get(id=template_id)
|
||||
|
||||
latest_results = LeistungstestResult.objects.filter(
|
||||
template=template
|
||||
).values('wrestler').annotate(
|
||||
latest_date=Max('completed_at')
|
||||
)
|
||||
|
||||
rankings = []
|
||||
for entry in latest_results:
|
||||
result = LeistungstestResult.objects.get(
|
||||
template=template,
|
||||
wrestler_id=entry['wrestler'],
|
||||
completed_at=entry['latest_date']
|
||||
)
|
||||
wrestler = result.wrestler
|
||||
rankings.append({
|
||||
'rank': 0,
|
||||
'wrestler': {'id': wrestler.id, 'name': str(wrestler)},
|
||||
'score_percent': result.score_percent,
|
||||
'rating': result.rating,
|
||||
'time_minutes': result.total_time_minutes
|
||||
})
|
||||
|
||||
rankings.sort(key=lambda x: (-x['score_percent'], x['time_minutes'] or 999))
|
||||
for i, r in enumerate(rankings):
|
||||
r['rank'] = i + 1
|
||||
|
||||
return Response({
|
||||
'template': {'id': template.id, 'name': template.name},
|
||||
'rankings': rankings
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Create urls.py**
|
||||
|
||||
```python
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import LeistungstestTemplateViewSet, LeistungstestResultViewSet, leaderboard
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'templates', LeistungstestTemplateViewSet, basename='leistungstest-template')
|
||||
router.register(r'results', LeistungstestResultViewSet, basename='leistungstest-result')
|
||||
|
||||
urlpatterns = [
|
||||
path('leaderboard/', leaderboard, name='leistungstest-leaderboard'),
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Add URL to main urls.py**
|
||||
|
||||
```python
|
||||
path('api/v1/leistungstest/', include('leistungstest.urls')),
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Create migration**
|
||||
|
||||
Run: `cd backend && python manage.py makemigrations leistungstest`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update Frontend API Types
|
||||
|
||||
- [ ] **Step 1: Add interfaces to frontend/src/lib/api.ts**
|
||||
|
||||
```typescript
|
||||
export interface ILeistungstestTemplateExercise {
|
||||
id: number
|
||||
exercise: number
|
||||
exercise_name: string
|
||||
target_reps: number
|
||||
order: number
|
||||
}
|
||||
|
||||
export interface ILeistungstestTemplate {
|
||||
id: number
|
||||
name: string
|
||||
exercises: ILeistungstestTemplateExercise[]
|
||||
usage_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ILeistungstestResultItem {
|
||||
id: number
|
||||
exercise: number
|
||||
exercise_name: string
|
||||
target_reps: number
|
||||
actual_reps: number
|
||||
order: number
|
||||
}
|
||||
|
||||
export interface ILeistungstestResult {
|
||||
id: number
|
||||
template: number
|
||||
template_name: string
|
||||
wrestler: number
|
||||
wrestler_name: string
|
||||
total_time_minutes: number | null
|
||||
rating: number
|
||||
notes: string
|
||||
completed_at: string
|
||||
score_percent: number
|
||||
items: ILeistungstestResultItem[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ILeaderboardEntry {
|
||||
rank: number
|
||||
wrestler: { id: number; name: string }
|
||||
score_percent: number
|
||||
rating: number
|
||||
time_minutes: number | null
|
||||
}
|
||||
|
||||
export interface ILeaderboard {
|
||||
template: { id: number; name: string }
|
||||
rankings: ILeaderboardEntry[]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create Leistungstest Page
|
||||
|
||||
- [ ] **Step 1: Create page with 4 tabs**
|
||||
|
||||
Create `frontend/src/app/(dashboard)/leistungstest/page.tsx` with:
|
||||
- Tab state (vorlagen | zuweisen | ergebnisse | leaderboard)
|
||||
- Vorlagen tab: Template list + create form
|
||||
- Zuweisen tab: Select wrestler/template, record results
|
||||
- Ergebnisse tab: Results table with filters + progress
|
||||
- Leaderboard tab: Rankings by template
|
||||
|
||||
- [ ] **Step 2: Add sidebar navigation**
|
||||
|
||||
Add to `sidebar.tsx`:
|
||||
```typescript
|
||||
{ name: "Leistungstest", href: "/leistungstest", icon: Trophy },
|
||||
```
|
||||
|
||||
Import Trophy icon from lucide-react.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend
|
||||
- Run: `cd backend && python manage.py check`
|
||||
- Run: `python manage.py migrate`
|
||||
- Test endpoint: `curl -H "Authorization: Bearer <token>" http://localhost:8000/api/v1/leistungstest/templates/`
|
||||
|
||||
### Frontend
|
||||
- Run: `cd frontend && npm run lint`
|
||||
- Visit: http://localhost:3000/leistungstest
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
- Wrestler/Template dropdowns use SelectValue with find() to show names not IDs
|
||||
- Score = (sum actual_reps / sum target_reps) * 100
|
||||
- Leaderboard shows latest result per wrestler for selected template
|
||||
- Results table sorted by date (newest first)
|
||||
- Star rating component with clickable stars (1-5)
|
||||
Reference in New Issue
Block a user