Feat: Wiederkehrende Trainingsevents

Fügt die Funktionalität hinzu, wiederkehrende Trainingsevents zu erstellen,
zu verwalten und zu löschen. Ein Coach kann nun ein Training erstellen,
das sich alle X Tage bis zu einem bestimmten Enddatum wiederholt.

Wesentliche Änderungen:
- **Datenmodell ():** Das -Modell wurde um Felder
  für die Wiederholung (, ,
  ) und zur Gruppierung von Serien ()
  erweitert.
- **Formulare ():** Das Formular zur Erstellung von Trainings
  wurde um die neuen Wiederholungsoptionen erweitert.
- **Views:**
    - : Die Logik wurde erweitert, um beim Speichern
      eines wiederkehrenden Events automatisch alle zukünftigen Instanzen
      der Serie zu erstellen.
    - : Bietet nun die Möglichkeit, entweder nur ein
      einzelnes Event einer Serie oder die gesamte Serie zu löschen.
- **Templates:**
    - : Enthält jetzt die neuen Formularfelder mit
      JavaScript, um die Wiederholungsoptionen dynamisch ein- und
      auszublenden.
    - : Zeigt eine Auswahlmöglichkeit für den
      Löschumfang an, wenn das Event Teil einer Serie ist.
- **Migration:** Eine neue Datenbankmigration wurde erstellt, um die
  Änderungen am -Modell anzuwenden.
This commit is contained in:
Matthias Nagel 2025-11-22 21:34:04 +01:00
parent 223dd65382
commit d45fc54280
6 changed files with 184 additions and 22 deletions

View File

@ -12,9 +12,14 @@ class EventForm(forms.ModelForm):
class TrainingForm(forms.ModelForm):
start_time = forms.DateTimeField(input_formats=['%d.%m.%Y %H:%M', '%Y-%m-%dT%H:%M'], widget=forms.DateTimeInput(format='%d.%m.%Y %H:%M', attrs={'type': 'datetime-local'}))
end_time = forms.DateTimeField(input_formats=['%d.%m.%Y %H:%M', '%Y-%m-%dT%H:%M'], widget=forms.DateTimeInput(format='%d.%m.%Y %H:%M', attrs={'type': 'datetime-local'}), required=False)
is_recurring = forms.BooleanField(required=False, widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}))
recurrence_interval = forms.IntegerField(required=False, min_value=1, widget=forms.NumberInput(attrs={'class': 'form-control'}), help_text="Repeat every X days.")
recurrence_end_date = forms.DateField(required=False, widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}))
class Meta:
model = Training
fields = ['title', 'description', 'start_time', 'end_time', 'location_address', 'team']
fields = ['title', 'description', 'start_time', 'end_time', 'location_address', 'team', 'is_recurring', 'recurrence_interval', 'recurrence_end_date']
class GameForm(forms.ModelForm):
start_time = forms.DateTimeField(input_formats=['%d.%m.%Y %H:%M', '%Y-%m-%dT%H:%M'], widget=forms.DateTimeInput(format='%d.%m.%Y %H:%M', attrs={'type': 'datetime-local'}))

View File

@ -0,0 +1,34 @@
# Generated by Django 5.2.6 on 2025-11-22 09:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('calendars', '0005_game_number_of_innings_and_more'),
]
operations = [
migrations.AddField(
model_name='event',
name='is_recurring',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='event',
name='parent_event',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_events', to='calendars.event'),
),
migrations.AddField(
model_name='event',
name='recurrence_end_date',
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name='event',
name='recurrence_interval',
field=models.PositiveIntegerField(blank=True, help_text='In days', null=True),
),
]

View File

@ -10,6 +10,12 @@ class Event(models.Model):
location_address = models.CharField(max_length=255)
maps_shortlink = models.URLField(blank=True, editable=False)
team = models.ForeignKey('clubs.Team', on_delete=models.CASCADE, related_name='events')
# Fields for recurring events
is_recurring = models.BooleanField(default=False)
recurrence_interval = models.PositiveIntegerField(null=True, blank=True, help_text="In days")
recurrence_end_date = models.DateField(null=True, blank=True)
parent_event = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='child_events')
def save(self, *args, **kwargs):
if self.location_address and not self.maps_shortlink:

View File

@ -8,9 +8,28 @@
<h2>Delete Event</h2>
</div>
<div class="card-body">
<p>Are you sure you want to delete "{{ object.title }}"?</p>
<p>Are you sure you want to delete the event: <strong>"{{ object.title }}"</strong> on <strong>{{ object.start_time|date:"d.m.Y" }}</strong>?</p>
<form method="post">
{% csrf_token %}
{% if is_series %}
<fieldset class="mb-3">
<legend class="h6">Deletion Scope</legend>
<div class="form-check">
<input class="form-check-input" type="radio" name="delete_scope" id="delete_one" value="one" checked>
<label class="form-check-label" for="delete_one">
Delete only this event
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="delete_scope" id="delete_all" value="all">
<label class="form-check-label" for="delete_all">
Delete the entire series
</label>
</div>
</fieldset>
{% endif %}
<button type="submit" class="btn btn-danger">Confirm Delete</button>
<a href="{% url 'dashboard' %}" class="btn btn-secondary">Cancel</a>
</form>

View File

@ -1,40 +1,83 @@
{% extends "base.html" %}
{% load l10n %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h2>{% if object %}Edit Event{% else %}Create Event{% endif %}</h2>
<h2>{% if object %}Edit Event{% else %}Create Training{% endif %}</h2>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
{% for field in form %}
<div class="mb-3">
<label for="{{ field.id_for_label }}" class="form-label">{{ field.label }}</label>
{% if object and field.name == 'start_time' %}
<p>Current: {{ object.start_time|localize }}</p>
{% endif %}
{% if object and field.name == 'end_time' %}
<p>Current: {{ object.end_time|localize }}</p>
{% endif %}
{{ field }}
{% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text }}</small>
{% endif %}
{% for error in field.errors %}
<div class="alert alert-danger">{{ error }}</div>
{% endfor %}
<!-- Standard Fields -->
<div class="mb-3">{{ form.title.label_tag }} {{ form.title }}</div>
<div class="mb-3">{{ form.description.label_tag }} {{ form.description }}</div>
<div class="row">
<div class="col-md-6 mb-3">{{ form.start_time.label_tag }} {{ form.start_time }}</div>
<div class="col-md-6 mb-3">{{ form.end_time.label_tag }} {{ form.end_time }}</div>
</div>
<div class="mb-3">{{ form.location_address.label_tag }} {{ form.location_address }}</div>
<div class="mb-3">{{ form.team.label_tag }} {{ form.team }}</div>
<!-- Recurrence Fields -->
{% if 'is_recurring' in form.fields %}
<hr>
<div class="form-check mb-3">
{{ form.is_recurring }}
<label class="form-check-label" for="{{ form.is_recurring.id_for_label }}">{{ form.is_recurring.label }}</label>
</div>
{% endfor %}
<div id="recurrence-options" style="display: none;">
<div class="row">
<div class="col-md-6 mb-3">
{{ form.recurrence_interval.label_tag }}
{{ form.recurrence_interval }}
<small class="form-text text-muted">{{ form.recurrence_interval.help_text }}</small>
</div>
<div class="col-md-6 mb-3">
{{ form.recurrence_end_date.label_tag }}
{{ form.recurrence_end_date }}
</div>
</div>
</div>
{% endif %}
{% for error in form.non_field_errors %}
<div class="alert alert-danger">{{ error }}</div>
{% endfor %}
<button type="submit" class="btn btn-primary">Save</button>
<a href="{% url 'dashboard' %}" class="btn btn-secondary">Cancel</a>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javascript %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const recurringCheckbox = document.getElementById('{{ form.is_recurring.id_for_label }}');
const recurrenceOptions = document.getElementById('recurrence-options');
function toggleRecurrenceOptions() {
if (recurringCheckbox.checked) {
recurrenceOptions.style.display = 'block';
} else {
recurrenceOptions.style.display = 'none';
}
}
if (recurringCheckbox) {
// Set initial state on page load
toggleRecurrenceOptions();
// Add event listener
recurringCheckbox.addEventListener('change', toggleRecurrenceOptions);
}
});
</script>
{% endblock %}

View File

@ -65,6 +65,38 @@ class TrainingCreateView(LoginRequiredMixin, ManageableTeamsMixin, CreateView):
template_name = 'calendars/event_form.html'
success_url = reverse_lazy('dashboard')
def form_valid(self, form):
is_recurring = form.cleaned_data.get('is_recurring')
interval = form.cleaned_data.get('recurrence_interval')
end_date = form.cleaned_data.get('recurrence_end_date')
if is_recurring and interval and end_date:
# Save the first event, which will be the parent
self.object = form.save()
# Get details from the parent event
start_time = self.object.start_time
duration = self.object.end_time - start_time if self.object.end_time else datetime.timedelta(hours=1)
current_start_time = start_time + datetime.timedelta(days=interval)
while current_start_time.date() <= end_date:
Training.objects.create(
title=self.object.title,
description=self.object.description,
start_time=current_start_time,
end_time=current_start_time + duration,
location_address=self.object.location_address,
team=self.object.team,
parent_event=self.object,
is_recurring=False # Child events are not themselves recurring
)
current_start_time += datetime.timedelta(days=interval)
return redirect(self.get_success_url())
else:
return super().form_valid(form)
class GameCreateView(LoginRequiredMixin, ManageableTeamsMixin, CreateView):
model = Game
form_class = GameForm
@ -98,6 +130,29 @@ class EventDeleteView(LoginRequiredMixin, CoachCheckMixin, DeleteView):
template_name = 'calendars/event_confirm_delete.html'
success_url = reverse_lazy('dashboard')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# An event is part of a series if it has a parent or is a parent
context['is_series'] = self.object.parent_event is not None or self.object.child_events.exists()
return context
def post(self, request, *args, **kwargs):
self.object = self.get_object()
delete_scope = request.POST.get('delete_scope')
if delete_scope == 'all':
# Identify the parent of the series
parent = self.object.parent_event if self.object.parent_event else self.object
# Delete all children and the parent itself
parent.child_events.all().delete()
parent.delete()
return redirect(self.success_url)
else:
# Default behavior: delete only the single event
return super().post(request, *args, **kwargs)
@login_required
def manage_participation(request, child_id, event_id, status):
child = get_object_or_404(CustomUser, id=child_id)