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:
parent
223dd65382
commit
d45fc54280
@ -12,9 +12,14 @@ class EventForm(forms.ModelForm):
|
|||||||
class TrainingForm(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'}))
|
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)
|
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:
|
class Meta:
|
||||||
model = Training
|
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):
|
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'}))
|
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'}))
|
||||||
|
|||||||
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -10,6 +10,12 @@ class Event(models.Model):
|
|||||||
location_address = models.CharField(max_length=255)
|
location_address = models.CharField(max_length=255)
|
||||||
maps_shortlink = models.URLField(blank=True, editable=False)
|
maps_shortlink = models.URLField(blank=True, editable=False)
|
||||||
team = models.ForeignKey('clubs.Team', on_delete=models.CASCADE, related_name='events')
|
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):
|
def save(self, *args, **kwargs):
|
||||||
if self.location_address and not self.maps_shortlink:
|
if self.location_address and not self.maps_shortlink:
|
||||||
|
|||||||
@ -8,9 +8,28 @@
|
|||||||
<h2>Delete Event</h2>
|
<h2>Delete Event</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<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">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% 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>
|
<button type="submit" class="btn btn-danger">Confirm Delete</button>
|
||||||
<a href="{% url 'dashboard' %}" class="btn btn-secondary">Cancel</a>
|
<a href="{% url 'dashboard' %}" class="btn btn-secondary">Cancel</a>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,40 +1,83 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load l10n %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-md-6">
|
<div class="col-md-8">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<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>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
{% for field in form %}
|
<!-- Standard Fields -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">{{ form.title.label_tag }} {{ form.title }}</div>
|
||||||
<label for="{{ field.id_for_label }}" class="form-label">{{ field.label }}</label>
|
<div class="mb-3">{{ form.description.label_tag }} {{ form.description }}</div>
|
||||||
{% if object and field.name == 'start_time' %}
|
<div class="row">
|
||||||
<p>Current: {{ object.start_time|localize }}</p>
|
<div class="col-md-6 mb-3">{{ form.start_time.label_tag }} {{ form.start_time }}</div>
|
||||||
{% endif %}
|
<div class="col-md-6 mb-3">{{ form.end_time.label_tag }} {{ form.end_time }}</div>
|
||||||
{% if object and field.name == 'end_time' %}
|
</div>
|
||||||
<p>Current: {{ object.end_time|localize }}</p>
|
<div class="mb-3">{{ form.location_address.label_tag }} {{ form.location_address }}</div>
|
||||||
{% endif %}
|
<div class="mb-3">{{ form.team.label_tag }} {{ form.team }}</div>
|
||||||
{{ field }}
|
|
||||||
{% if field.help_text %}
|
<!-- Recurrence Fields -->
|
||||||
<small class="form-text text-muted">{{ field.help_text }}</small>
|
{% if 'is_recurring' in form.fields %}
|
||||||
{% endif %}
|
<hr>
|
||||||
{% for error in field.errors %}
|
<div class="form-check mb-3">
|
||||||
<div class="alert alert-danger">{{ error }}</div>
|
{{ form.is_recurring }}
|
||||||
{% endfor %}
|
<label class="form-check-label" for="{{ form.is_recurring.id_for_label }}">{{ form.is_recurring.label }}</label>
|
||||||
</div>
|
</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>
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="{% url 'dashboard' %}" class="btn btn-secondary">Cancel</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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 %}
|
||||||
|
|||||||
@ -65,6 +65,38 @@ class TrainingCreateView(LoginRequiredMixin, ManageableTeamsMixin, CreateView):
|
|||||||
template_name = 'calendars/event_form.html'
|
template_name = 'calendars/event_form.html'
|
||||||
success_url = reverse_lazy('dashboard')
|
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):
|
class GameCreateView(LoginRequiredMixin, ManageableTeamsMixin, CreateView):
|
||||||
model = Game
|
model = Game
|
||||||
form_class = GameForm
|
form_class = GameForm
|
||||||
@ -98,6 +130,29 @@ class EventDeleteView(LoginRequiredMixin, CoachCheckMixin, DeleteView):
|
|||||||
template_name = 'calendars/event_confirm_delete.html'
|
template_name = 'calendars/event_confirm_delete.html'
|
||||||
success_url = reverse_lazy('dashboard')
|
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
|
@login_required
|
||||||
def manage_participation(request, child_id, event_id, status):
|
def manage_participation(request, child_id, event_id, status):
|
||||||
child = get_object_or_404(CustomUser, id=child_id)
|
child = get_object_or_404(CustomUser, id=child_id)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user