Initial commit: WrestleDesk full project

- Django backend with DRF (clubs, wrestlers, trainers, exercises, templates, trainings, homework, locations, leistungstest)
- Next.js 16 frontend with React, Shadcn UI, Tailwind
- JWT authentication
- Full CRUD for all entities
- Calendar view for trainings
- Homework management system
- Leistungstest tracking
This commit is contained in:
Andrej Spielmann
2026-03-26 13:24:57 +01:00
commit 3fefc550fe
256 changed files with 38295 additions and 0 deletions
View File
+31
View File
@@ -0,0 +1,31 @@
from django.contrib import admin
from .models import LeistungstestTemplate, LeistungstestTemplateExercise, LeistungstestResult, LeistungstestResultItem
class LeistungstestTemplateExerciseInline(admin.TabularInline):
model = LeistungstestTemplateExercise
extra = 0
readonly_fields = ['exercise']
@admin.register(LeistungstestTemplate)
class LeistungstestTemplateAdmin(admin.ModelAdmin):
list_display = ['name', 'created_at', 'usage_count']
search_fields = ['name']
inlines = [LeistungstestTemplateExerciseInline]
@admin.register(LeistungstestResult)
class LeistungstestResultAdmin(admin.ModelAdmin):
list_display = ['id', 'wrestler', 'template', 'total_time_seconds', 'score_percent', 'rating', 'completed_at']
list_filter = ['template', 'rating', 'completed_at']
search_fields = ['wrestler__first_name', 'wrestler__last_name', 'template__name']
readonly_fields = ['score_percent', 'created_at']
raw_id_fields = ['wrestler', 'template']
@admin.register(LeistungstestResultItem)
class LeistungstestResultItemAdmin(admin.ModelAdmin):
list_display = ['id', 'result', 'exercise', 'target_reps', 'actual_reps', 'elapsed_seconds', 'order']
list_filter = ['exercise']
raw_id_fields = ['result', 'exercise']
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class LeistungstestConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'leistungstest'
@@ -0,0 +1,94 @@
# Generated by Django 4.2.29 on 2026-03-23 12:43
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
('wrestlers', '0002_alter_wrestler_license_scan_alter_wrestler_photo'),
('exercises', '0003_alter_exercise_media'),
]
operations = [
migrations.CreateModel(
name='LeistungstestResult',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('total_time_minutes', models.PositiveIntegerField(blank=True, null=True)),
('rating', models.PositiveSmallIntegerField(choices=[(1, 1), (2, 2), (3, 3), (4, 4), (5, 5)], default=3)),
('notes', models.TextField(blank=True)),
('completed_at', models.DateTimeField(default=django.utils.timezone.now)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['-completed_at'],
},
),
migrations.CreateModel(
name='LeistungstestTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='LeistungstestResultItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('target_reps', models.PositiveIntegerField()),
('actual_reps', models.PositiveIntegerField()),
('order', models.IntegerField(default=0)),
('exercise', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='exercises.exercise')),
('result', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='leistungstest.leistungstestresult')),
],
options={
'ordering': ['result', 'order'],
},
),
migrations.AddField(
model_name='leistungstestresult',
name='template',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='leistungstest.leistungstesttemplate'),
),
migrations.AddField(
model_name='leistungstestresult',
name='wrestler',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='leistungstest_results', to='wrestlers.wrestler'),
),
migrations.CreateModel(
name='LeistungstestTemplateExercise',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('target_reps', models.PositiveIntegerField()),
('order', models.IntegerField(default=0)),
('exercise', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='exercises.exercise')),
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exercises', to='leistungstest.leistungstesttemplate')),
],
options={
'ordering': ['template', 'order'],
'unique_together': {('template', 'exercise')},
},
),
migrations.AddIndex(
model_name='leistungstestresult',
index=models.Index(fields=['wrestler'], name='leistungste_wrestle_f3f6c2_idx'),
),
migrations.AddIndex(
model_name='leistungstestresult',
index=models.Index(fields=['template'], name='leistungste_templat_daf98b_idx'),
),
migrations.AddIndex(
model_name='leistungstestresult',
index=models.Index(fields=['completed_at'], name='leistungste_complet_838820_idx'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-03-24 08:14
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('leistungstest', '0001_initial'),
]
operations = [
migrations.RenameField(
model_name='leistungstestresult',
old_name='total_time_minutes',
new_name='total_time_seconds',
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-03-24 09:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('leistungstest', '0002_change_total_time_to_seconds'),
]
operations = [
migrations.AddField(
model_name='leistungstestresultitem',
name='elapsed_seconds',
field=models.PositiveIntegerField(default=0),
),
]
+79
View File
@@ -0,0 +1,79 @@
from django.db import models
from django.utils import timezone
class LeistungstestTemplate(models.Model):
name = models.CharField(max_length=200)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return self.name
@property
def usage_count(self):
return LeistungstestResult.objects.filter(template_id=self.pk).count()
class LeistungstestTemplateExercise(models.Model):
template = models.ForeignKey(LeistungstestTemplate, on_delete=models.CASCADE, related_name='exercises')
exercise = models.ForeignKey('exercises.Exercise', on_delete=models.CASCADE)
target_reps = models.PositiveIntegerField()
order = models.IntegerField(default=0)
class Meta:
ordering = ['template', 'order']
unique_together = ['template', 'exercise']
def __str__(self):
return f"{self.template.name} - {self.exercise.name}"
class LeistungstestResult(models.Model):
template = models.ForeignKey(LeistungstestTemplate, on_delete=models.CASCADE, related_name='results')
wrestler = models.ForeignKey('wrestlers.Wrestler', on_delete=models.CASCADE, related_name='leistungstest_results')
total_time_seconds = models.PositiveIntegerField(null=True, blank=True)
rating = models.PositiveSmallIntegerField(choices=[(1,1),(2,2),(3,3),(4,4),(5,5)], default=3)
notes = models.TextField(blank=True)
completed_at = models.DateTimeField(default=timezone.now)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-completed_at']
indexes = [
models.Index(fields=['wrestler']),
models.Index(fields=['template']),
models.Index(fields=['completed_at']),
]
def __str__(self):
return f"{self.wrestler} - {self.template.name}"
@property
def score_percent(self):
items = self.items.all()
if not items.exists():
return 0
total_target = sum(item.target_reps for item in items)
total_actual = sum(item.actual_reps for item in items)
if total_target == 0:
return 0
return round((total_actual / total_target) * 100, 1)
class LeistungstestResultItem(models.Model):
result = models.ForeignKey(LeistungstestResult, on_delete=models.CASCADE, related_name='items')
exercise = models.ForeignKey('exercises.Exercise', on_delete=models.CASCADE)
target_reps = models.PositiveIntegerField()
actual_reps = models.PositiveIntegerField()
order = models.IntegerField(default=0)
elapsed_seconds = models.PositiveIntegerField(default=0)
class Meta:
ordering = ['result', 'order']
def __str__(self):
return f"{self.result} - {self.exercise.name}: {self.actual_reps}/{self.target_reps}"
+52
View File
@@ -0,0 +1,52 @@
from rest_framework import serializers
from .models import LeistungstestTemplate, LeistungstestTemplateExercise, LeistungstestResult, LeistungstestResultItem
class LeistungstestTemplateExerciseSerializer(serializers.ModelSerializer):
exercise_name = serializers.CharField(source='exercise.name', read_only=True)
class Meta:
model = LeistungstestTemplateExercise
fields = ['id', 'exercise', 'exercise_name', 'target_reps', 'order']
class LeistungstestTemplateSerializer(serializers.ModelSerializer):
exercises = LeistungstestTemplateExerciseSerializer(many=True, read_only=True)
usage_count = serializers.IntegerField(read_only=True)
class Meta:
model = LeistungstestTemplate
fields = ['id', 'name', 'exercises', 'usage_count', 'created_at']
class LeistungstestResultItemSerializer(serializers.ModelSerializer):
exercise_name = serializers.CharField(source='exercise.name', read_only=True)
class Meta:
model = LeistungstestResultItem
fields = ['id', 'exercise', 'exercise_name', 'target_reps', 'actual_reps', 'elapsed_seconds', 'order']
class LeistungstestResultSerializer(serializers.ModelSerializer):
items = LeistungstestResultItemSerializer(many=True, read_only=True)
template_name = serializers.CharField(source='template.name', read_only=True)
wrestler_name = serializers.CharField(source='wrestler.__str__', read_only=True)
score_percent = serializers.FloatField(read_only=True)
total_time_minutes = serializers.SerializerMethodField()
class Meta:
model = LeistungstestResult
fields = ['id', 'template', 'template_name', 'wrestler', 'wrestler_name',
'total_time_minutes', 'total_time_seconds', 'rating', 'notes', 'completed_at',
'score_percent', 'items', 'created_at']
read_only_fields = ['total_time_minutes']
def get_total_time_minutes(self, obj):
if obj.total_time_seconds is None:
return None
return obj.total_time_seconds // 60
def validate_total_time_seconds(self, value):
if value is not None and value < 0:
raise serializers.ValidationError("Zeit muss positiv sein.")
return value
+78
View File
@@ -0,0 +1,78 @@
from datetime import date, timedelta
def get_date_range(period):
"""Return start date for period filter, or None for 'all'."""
today = date.today()
if period == "month":
return today.replace(day=1)
elif period == "3months":
return today - timedelta(days=90)
elif period == "year":
return today.replace(month=1, day=1)
return None
def get_template_leaderboard(template_id, period="all", limit=10):
"""Return top wrestlers by score_percent for a template."""
from .models import LeistungstestResult
qs = LeistungstestResult.objects.filter(template_id=template_id)
start_date = get_date_range(period)
if start_date:
qs = qs.filter(completed_at__date__gte=start_date)
qs = qs.select_related('wrestler')
results = []
all_results = list(qs)
all_results.sort(key=lambda r: (-r.score_percent, r.total_time_seconds))
for rank, result in enumerate(all_results[:limit], 1):
results.append({
'rank': rank,
'wrestler_id': result.wrestler_id,
'wrestler_name': str(result.wrestler),
'score_percent': result.score_percent,
'total_time_seconds': result.total_time_seconds,
'completed_at': result.completed_at.date().isoformat() if result.completed_at else None,
})
return results
def get_exercise_leaderboard(exercise_id, period="all", limit=10):
"""Return top wrestlers by best time for an exercise."""
from .models import LeistungstestResultItem, LeistungstestResult
from django.db.models import Min
start_date = get_date_range(period)
qs = LeistungstestResultItem.objects.filter(exercise_id=exercise_id)
if start_date:
qs = qs.filter(result__completed_at__date__gte=start_date)
# Get best time per wrestler
best_times = qs.values('result__wrestler__id', 'result__wrestler__first_name', 'result__wrestler__last_name', 'result__completed_at__date')\
.annotate(best_time=Min('elapsed_seconds'))\
.order_by('best_time')
results = []
for rank, item in enumerate(best_times[:limit], 1):
wrestler_name = f"{item['result__wrestler__first_name']} {item['result__wrestler__last_name']}"
results.append({
'rank': rank,
'wrestler_id': item['result__wrestler__id'],
'wrestler_name': wrestler_name.strip(),
'best_time_seconds': item['best_time'],
'completed_at': item['result__completed_at__date'].isoformat() if item['result__completed_at__date'] else None,
})
return results
def get_used_exercises():
"""Return all exercises that have been used in any Leistungstest result."""
from .models import LeistungstestResultItem
from exercises.models import Exercise
exercise_ids = LeistungstestResultItem.objects.values_list('exercise_id', flat=True).distinct()
return Exercise.objects.filter(id__in=exercise_ids).order_by('name')
+14
View File
@@ -0,0 +1,14 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import LeistungstestTemplateViewSet, LeistungstestTemplateExerciseViewSet, LeistungstestResultViewSet, LeistungstestResultItemViewSet, LeistungstestStatsViewSet
router = DefaultRouter()
router.register('templates', LeistungstestTemplateViewSet, basename='leistungstest-template')
router.register('template-exercises', LeistungstestTemplateExerciseViewSet, basename='leistungstest-template-exercise')
router.register('results', LeistungstestResultViewSet, basename='leistungstest-result')
router.register('result-items', LeistungstestResultItemViewSet, basename='leistungstest-result-item')
router.register('stats', LeistungstestStatsViewSet, basename='leistungstest-stats')
urlpatterns = [
path('', include(router.urls)),
]
+259
View File
@@ -0,0 +1,259 @@
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .models import LeistungstestTemplate, LeistungstestTemplateExercise, LeistungstestResult, LeistungstestResultItem
from .serializers import (
LeistungstestTemplateSerializer,
LeistungstestTemplateExerciseSerializer,
LeistungstestResultSerializer,
LeistungstestResultItemSerializer,
)
from .stats import get_template_leaderboard, get_exercise_leaderboard, get_used_exercises
class LeistungstestTemplateViewSet(viewsets.ModelViewSet):
queryset = LeistungstestTemplate.objects.all()
serializer_class = LeistungstestTemplateSerializer
def get_queryset(self):
return LeistungstestTemplate.objects.all().prefetch_related('exercises__exercise')
def create(self, request, *args, **kwargs):
name = request.data.get('name')
exercises_data = request.data.get('exercises', [])
# Create template first
template = LeistungstestTemplate.objects.create(name=name)
# Create exercises
for i, ex_data in enumerate(exercises_data):
LeistungstestTemplateExercise.objects.create(
template=template,
exercise_id=ex_data['exercise'],
target_reps=ex_data['target_reps'],
order=ex_data.get('order', i),
)
# Reload with prefetch to get exercise names
template = LeistungstestTemplate.objects.prefetch_related('exercises__exercise').get(pk=template.pk)
return Response(
LeistungstestTemplateSerializer(template).data,
status=status.HTTP_201_CREATED
)
@action(detail=True, methods=['post'])
def duplicate(self, request, pk=None):
template = self.get_object()
new_template = LeistungstestTemplate.objects.create(name=f"{template.name} (Kopie)")
for exercise in template.exercises.all():
LeistungstestTemplateExercise.objects.create(
template=new_template,
exercise=exercise.exercise,
target_reps=exercise.target_reps,
order=exercise.order,
)
return Response(
LeistungstestTemplateSerializer(new_template).data,
status=status.HTTP_201_CREATED
)
def update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False)
instance = self.get_object()
instance.name = request.data.get('name', instance.name)
instance.save()
exercises_data = request.data.get('exercises')
if exercises_data is not None:
instance.exercises.all().delete()
for i, ex_data in enumerate(exercises_data):
LeistungstestTemplateExercise.objects.create(
template=instance,
exercise_id=ex_data['exercise'],
target_reps=ex_data['target_reps'],
order=ex_data.get('order', i),
)
instance = LeistungstestTemplate.objects.prefetch_related('exercises__exercise').get(pk=instance.pk)
return Response(LeistungstestTemplateSerializer(instance).data)
class LeistungstestTemplateExerciseViewSet(viewsets.ModelViewSet):
queryset = LeistungstestTemplateExercise.objects.all()
serializer_class = LeistungstestTemplateExerciseSerializer
class LeistungstestResultViewSet(viewsets.ModelViewSet):
queryset = LeistungstestResult.objects.all()
serializer_class = LeistungstestResultSerializer
def get_queryset(self):
queryset = LeistungstestResult.objects.all().prefetch_related('items__exercise')
template_id = self.request.query_params.get('template')
wrestler_id = self.request.query_params.get('wrestler')
if template_id:
queryset = queryset.filter(template_id=template_id)
if wrestler_id:
queryset = queryset.filter(wrestler_id=wrestler_id)
return queryset
def create(self, request, *args, **kwargs):
template_id = request.data.get('template')
wrestler_id = request.data.get('wrestler')
items_data = request.data.get('items', [])
result = LeistungstestResult.objects.create(
template_id=template_id,
wrestler_id=wrestler_id,
total_time_seconds=request.data.get('total_time_seconds') or None,
rating=request.data.get('rating', 3),
notes=request.data.get('notes', ''),
)
for i, item_data in enumerate(items_data):
LeistungstestResultItem.objects.create(
result=result,
exercise_id=item_data['exercise'],
target_reps=item_data.get('target_reps', 0),
actual_reps=item_data.get('actual_reps', 0),
elapsed_seconds=item_data.get('elapsed_seconds', 0),
order=item_data.get('order', i),
)
result_items = LeistungstestResultItem.objects.filter(result=result)
total_target = sum(item.target_reps for item in result_items)
total_actual = sum(item.actual_reps for item in result_items)
if total_target > 0:
score = round((total_actual / total_target) * 100, 1)
else:
score = 0.0
result.refresh_from_db()
result_data = LeistungstestResultSerializer(result).data
result_data['score_percent'] = score
return Response(result_data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False)
instance = self.get_object()
instance.total_time_seconds = request.data.get('total_time_seconds', instance.total_time_seconds)
instance.rating = request.data.get('rating', instance.rating)
instance.notes = request.data.get('notes', instance.notes)
instance.save()
items_data = request.data.get('items')
if items_data is not None:
instance.items.all().delete()
for i, item_data in enumerate(items_data):
LeistungstestResultItem.objects.create(
result=instance,
exercise_id=item_data['exercise'],
target_reps=item_data.get('target_reps', 0),
actual_reps=item_data.get('actual_reps', 0),
elapsed_seconds=item_data.get('elapsed_seconds', 0),
order=item_data.get('order', i),
)
result_items = LeistungstestResultItem.objects.filter(result=instance)
total_target = sum(item.target_reps for item in result_items)
total_actual = sum(item.actual_reps for item in result_items)
if total_target > 0:
score = round((total_actual / total_target) * 100, 1)
else:
score = 0.0
result_data = LeistungstestResultSerializer(instance).data
result_data['score_percent'] = score
return Response(result_data)
@action(detail=False, methods=['get'])
def leaderboard(self, request):
template_id = request.query_params.get('template')
if not template_id:
return Response(
{'error': 'template parameter is required'},
status=status.HTTP_400_BAD_REQUEST
)
limit = int(request.query_params.get('limit', 10))
results = LeistungstestResult.objects.filter(template_id=template_id)\
.select_related('wrestler')
leaderboard_data = []
for result in results:
leaderboard_data.append({
'rank': 0,
'result_id': result.id,
'wrestler_id': result.wrestler.id,
'wrestler_name': str(result.wrestler),
'score_percent': result.score_percent,
'completed_at': result.completed_at,
'total_time_seconds': result.total_time_seconds,
'rating': result.rating,
})
leaderboard_data.sort(key=lambda x: x['score_percent'], reverse=True)
leaderboard_data = leaderboard_data[:limit]
for i, entry in enumerate(leaderboard_data, 1):
entry['rank'] = i
return Response(leaderboard_data)
class LeistungstestResultItemViewSet(viewsets.ModelViewSet):
queryset = LeistungstestResultItem.objects.all()
serializer_class = LeistungstestResultItemSerializer
class LeistungstestStatsViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
@action(detail=False, methods=['get'])
def leaderboard(self, request):
lb_type = request.query_params.get('type', 'template')
template_id = request.query_params.get('template_id')
exercise_id = request.query_params.get('exercise_id')
period = request.query_params.get('period', 'all')
limit = int(request.query_params.get('limit', 10))
if lb_type == 'template' and template_id:
results = get_template_leaderboard(int(template_id), period, limit)
template = LeistungstestTemplate.objects.get(pk=template_id)
return Response({
'template_id': template_id,
'template_name': template.name,
'period': period,
'results': results,
})
elif lb_type == 'exercise' and exercise_id:
from exercises.models import Exercise
results = get_exercise_leaderboard(int(exercise_id), period, limit)
exercise = Exercise.objects.get(pk=exercise_id)
return Response({
'exercise_id': exercise_id,
'exercise_name': exercise.name,
'period': period,
'results': results,
})
return Response({'error': 'Invalid parameters'}, status=400)
@action(detail=False, methods=['get'])
def exercises(self, request):
exercises = get_used_exercises()
return Response([{'id': e.id, 'name': e.name} for e in exercises])