From d45fc54280b9b7e2e916a63b5eec1697d2134cba Mon Sep 17 00:00:00 2001 From: Matthias Nagel Date: Sat, 22 Nov 2025 21:34:04 +0100 Subject: [PATCH] Feat: Wiederkehrende Trainingsevents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- calendars/forms.py | 7 +- ...s_recurring_event_parent_event_and_more.py | 34 ++++++++ calendars/models.py | 6 ++ .../calendars/event_confirm_delete.html | 21 ++++- calendars/templates/calendars/event_form.html | 83 ++++++++++++++----- calendars/views.py | 55 ++++++++++++ 6 files changed, 184 insertions(+), 22 deletions(-) create mode 100644 calendars/migrations/0006_event_is_recurring_event_parent_event_and_more.py diff --git a/calendars/forms.py b/calendars/forms.py index af669f9..97915f4 100644 --- a/calendars/forms.py +++ b/calendars/forms.py @@ -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'})) diff --git a/calendars/migrations/0006_event_is_recurring_event_parent_event_and_more.py b/calendars/migrations/0006_event_is_recurring_event_parent_event_and_more.py new file mode 100644 index 0000000..481abe9 --- /dev/null +++ b/calendars/migrations/0006_event_is_recurring_event_parent_event_and_more.py @@ -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), + ), + ] diff --git a/calendars/models.py b/calendars/models.py index 5ce1ae0..23c4724 100644 --- a/calendars/models.py +++ b/calendars/models.py @@ -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: diff --git a/calendars/templates/calendars/event_confirm_delete.html b/calendars/templates/calendars/event_confirm_delete.html index 87be18f..e0c74c9 100644 --- a/calendars/templates/calendars/event_confirm_delete.html +++ b/calendars/templates/calendars/event_confirm_delete.html @@ -8,9 +8,28 @@

Delete Event

-

Are you sure you want to delete "{{ object.title }}"?

+

Are you sure you want to delete the event: "{{ object.title }}" on {{ object.start_time|date:"d.m.Y" }}?

{% csrf_token %} + + {% if is_series %} +
+ Deletion Scope +
+ + +
+
+ + +
+
+ {% endif %} + Cancel
diff --git a/calendars/templates/calendars/event_form.html b/calendars/templates/calendars/event_form.html index 9ca22e2..a64c604 100644 --- a/calendars/templates/calendars/event_form.html +++ b/calendars/templates/calendars/event_form.html @@ -1,40 +1,83 @@ {% extends "base.html" %} -{% load l10n %} {% block content %}
-
+
-

{% if object %}Edit Event{% else %}Create Event{% endif %}

+

{% if object %}Edit Event{% else %}Create Training{% endif %}

{% csrf_token %} - {% for field in form %} -
- - {% if object and field.name == 'start_time' %} -

Current: {{ object.start_time|localize }}

- {% endif %} - {% if object and field.name == 'end_time' %} -

Current: {{ object.end_time|localize }}

- {% endif %} - {{ field }} - {% if field.help_text %} - {{ field.help_text }} - {% endif %} - {% for error in field.errors %} -
{{ error }}
- {% endfor %} + +
{{ form.title.label_tag }} {{ form.title }}
+
{{ form.description.label_tag }} {{ form.description }}
+
+
{{ form.start_time.label_tag }} {{ form.start_time }}
+
{{ form.end_time.label_tag }} {{ form.end_time }}
+
+
{{ form.location_address.label_tag }} {{ form.location_address }}
+
{{ form.team.label_tag }} {{ form.team }}
+ + + {% if 'is_recurring' in form.fields %} +
+
+ {{ form.is_recurring }} +
- {% endfor %} + + + {% endif %} + {% for error in form.non_field_errors %} +
{{ error }}
+ {% endfor %} + + Cancel
{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/calendars/views.py b/calendars/views.py index 77c8d62..ba0fafa 100644 --- a/calendars/views.py +++ b/calendars/views.py @@ -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)