Feat: Implementierung des Abstimmungs-Features (Polls)

Fügt ein neues Abstimmungs-Feature hinzu, das es Head Coaches ermöglicht,
Umfragen innerhalb ihres Teams zu erstellen und zu verwalten.
Teammitglieder können Umfragen ansehen und daran teilnehmen.

Wesentliche Änderungen:
- **Neue 'polls'-App:** Enthält Modelle, Formulare, Views und Templates.
- **Modelle  und :** Definieren die Struktur für Umfragen
  (Frage, Team, Ersteller, Mehrfachauswahl-Option) und die Auswahlmöglichkeiten
  (Text, Stimmen).
- **Formulare  und :** Für die Erstellung von Umfragen
  und deren Auswahlmöglichkeiten.
- **Views:**
    - : Zeigt alle für den Benutzer relevanten Umfragen an.
    - : Ermöglicht Head Coaches das Erstellen neuer Umfragen
      (inkl. Fehlerbehebung bei der Formularinitialisierung).
    - : Zeigt Details einer Umfrage an und ermöglicht die
      Stimmabgabe.
    - : Zeigt die Ergebnisse einer Umfrage an.
    - : Funktion für die Stimmabgabe.
- **Templates:** Spezifische Templates für alle Umfrage-Views.
- **URL-Konfiguration:** Neue URLs für die 'polls'-App und Einbindung in die
  Haupt-URL-Konfiguration.
- **Navigationslink:** Ein neuer Link 'Polls' in der Hauptnavigation für
  authentifizierte Benutzer.
- **Migrationen:** Datenbankmigrationen für die neuen - und -Modelle.
This commit is contained in:
Matthias Nagel 2025-11-21 22:55:07 +01:00
parent cb08474301
commit 001444e0dd
17 changed files with 361 additions and 1 deletions

View File

@ -25,7 +25,7 @@ SECRET_KEY = 'django-insecure-)p-ei0pchzmkv!72^wr$!_s=9a_*4kuzsy(5_(urc*w(uummf3
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
ALLOWED_HOSTS = ['*']
# Application definition
@ -42,6 +42,7 @@ INSTALLED_APPS = [
'calendars',
'dashboard',
'team_stats',
'polls',
]
MIDDLEWARE = [

View File

@ -24,5 +24,6 @@ urlpatterns = [
path('clubs/', include('clubs.urls')),
path('calendars/', include('calendars.urls')),
path('statistics/', include('team_stats.urls')),
path('polls/', include('polls.urls')),
path('', include('dashboard.urls')),
]

0
polls/__init__.py Normal file
View File

3
polls/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
polls/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class PollsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'polls'

23
polls/forms.py Normal file
View File

@ -0,0 +1,23 @@
from django import forms
from django.forms import formset_factory
from .models import Poll, Choice
class PollForm(forms.ModelForm):
class Meta:
model = Poll
fields = ['question', 'team', 'multiple_choice']
widgets = {
'question': forms.TextInput(attrs={'class': 'form-control'}),
'team': forms.Select(attrs={'class': 'form-control'}),
'multiple_choice': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
class ChoiceForm(forms.ModelForm):
class Meta:
model = Choice
fields = ['choice_text']
widgets = {
'choice_text': forms.TextInput(attrs={'class': 'form-control', 'required': True}),
}
ChoiceFormSet = formset_factory(ChoiceForm, extra=2, max_num=5)

View File

@ -0,0 +1,38 @@
# Generated by Django 5.2.6 on 2025-11-21 21:18
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('clubs', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Poll',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('question', models.CharField(max_length=255)),
('multiple_choice', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='polls_created', to=settings.AUTH_USER_MODEL)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='polls', to='clubs.team')),
],
),
migrations.CreateModel(
name='Choice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('choice_text', models.CharField(max_length=100)),
('votes', models.ManyToManyField(blank=True, related_name='voted_choices', to=settings.AUTH_USER_MODEL)),
('poll', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='polls.poll')),
],
),
]

View File

21
polls/models.py Normal file
View File

@ -0,0 +1,21 @@
from django.db import models
from clubs.models import Team
from accounts.models import CustomUser
class Poll(models.Model):
question = models.CharField(max_length=255)
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='polls')
creator = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name='polls_created')
multiple_choice = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.question
class Choice(models.Model):
poll = models.ForeignKey(Poll, on_delete=models.CASCADE, related_name='choices')
choice_text = models.CharField(max_length=100)
votes = models.ManyToManyField(CustomUser, related_name='voted_choices', blank=True)
def __str__(self):
return self.choice_text

View File

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h1>{{ poll.question }}</h1>
<p class="text-muted">Asked by {{ poll.creator.get_full_name }} for team {{ poll.team.name }}</p>
<form action="{% url 'polls:vote' poll.id %}" method="post">
{% csrf_token %}
<fieldset class="mb-3">
<legend>Your Vote</legend>
{% for choice in poll.choices.all %}
<div class="form-check">
{% if poll.multiple_choice %}
<input class="form-check-input" type="checkbox" name="choice" id="choice{{ forloop.counter }}" value="{{ choice.id }}">
{% else %}
<input class="form-check-input" type="radio" name="choice" id="choice{{ forloop.counter }}" value="{{ choice.id }}">
{% endif %}
<label class="form-check-label" for="choice{{ forloop.counter }}">
{{ choice.choice_text }}
</label>
</div>
{% endfor %}
</fieldset>
<button type="submit" class="btn btn-success">Submit Vote</button>
<a href="{% url 'polls:poll_results' poll.id %}" class="btn btn-secondary">View Results</a>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h1>Create a New Poll</h1>
<form method="post">
{% csrf_token %}
<div class="mb-3">
{{ form.question.label_tag }}
{{ form.question }}
</div>
<div class="mb-3">
{{ form.team.label_tag }}
{{ form.team }}
</div>
<div class="form-check mb-3">
{{ form.multiple_choice }}
{{ form.multiple_choice.label_tag }}
</div>
<hr>
<h4>Choices</h4>
{{ choice_formset.management_form }}
<div id="choice-forms">
{% for form in choice_formset %}
<div class="mb-2">
{{ form.choice_text.label_tag }}
{{ form.choice_text }}
</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary">Save Poll</button>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Polls</h1>
{% if user.coached_teams.exists %}
<a href="{% url 'polls:poll_create' %}" class="btn btn-primary">Create Poll</a>
{% endif %}
</div>
{% if polls %}
<div class="list-group">
{% for poll in polls %}
<a href="{% url 'polls:poll_detail' poll.pk %}" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{ poll.question }}</h5>
<small>{{ poll.created_at|date:"d.m.Y" }}</small>
</div>
<p class="mb-1">For team: {{ poll.team.name }}</p>
<small>Created by: {{ poll.creator.get_full_name }}</small>
</a>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info" role="alert">
No polls available for your teams yet.
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h1>{{ poll.question }}</h1>
<p class="text-muted">Results for the poll asked by {{ poll.creator.get_full_name }}</p>
<ul class="list-group">
{% for choice in poll.choices.all %}
<li class="list-group-item d-flex justify-content-between align-items-center">
{{ choice.choice_text }}
<span class="badge bg-primary rounded-pill">{{ choice.votes.count }} vote(s)</span>
</li>
{% endfor %}
</ul>
<div class="mt-3">
<a href="{% url 'polls:poll_detail' poll.id %}" class="btn btn-secondary">Back to Vote</a>
<a href="{% url 'polls:poll_list' %}" class="btn btn-outline-primary">Back to All Polls</a>
</div>
</div>
{% endblock %}

3
polls/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

12
polls/urls.py Normal file
View File

@ -0,0 +1,12 @@
from django.urls import path
from . import views
app_name = 'polls'
urlpatterns = [
path('', views.PollListView.as_view(), name='poll_list'),
path('create/', views.PollCreateView.as_view(), name='poll_create'),
path('<int:pk>/', views.PollDetailView.as_view(), name='poll_detail'),
path('<int:pk>/results/', views.PollResultsView.as_view(), name='poll_results'),
path('<int:poll_id>/vote/', views.vote, name='vote'),
]

127
polls/views.py Normal file
View File

@ -0,0 +1,127 @@
from django.shortcuts import render, get_object_or_404, redirect
from django.views.generic import ListView, CreateView, DetailView
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.urls import reverse_lazy, reverse
from django.db import transaction
from .models import Poll, Choice
from .forms import PollForm, ChoiceFormSet
from clubs.models import Team
from accounts.models import CustomUser
class PollListView(LoginRequiredMixin, ListView):
model = Poll
template_name = 'polls/poll_list.html'
context_object_name = 'polls'
def get_queryset(self):
user = self.request.user
user_teams = set()
if user.team:
user_teams.add(user.team)
# Add teams where the user is a coach or assistant
user_teams.update(user.coached_teams.all())
user_teams.update(user.assisted_teams.all())
# Add teams of children for parents
if hasattr(user, 'children'):
for child in user.children.all():
if child.team:
user_teams.add(child.team)
return Poll.objects.filter(team__in=user_teams).order_by('-created_at')
class PollCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
model = Poll
form_class = PollForm
template_name = 'polls/poll_form.html'
def test_func(self):
# Only head coaches can create polls
return self.request.user.coached_teams.exists()
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
if self.request.POST:
data['choice_formset'] = ChoiceFormSet(self.request.POST)
else:
data['choice_formset'] = ChoiceFormSet()
# Filter the team queryset for the form to only show teams the user coaches
data['form'].fields['team'].queryset = self.request.user.coached_teams.all()
return data
def form_valid(self, form):
context = self.get_context_data()
choice_formset = context['choice_formset']
# Set the creator before saving the poll
form.instance.creator = self.request.user
if choice_formset.is_valid():
self.object = form.save()
with transaction.atomic():
for choice_form in choice_formset:
if choice_form.cleaned_data.get('choice_text'):
Choice.objects.create(
poll=self.object,
choice_text=choice_form.cleaned_data['choice_text']
)
return redirect(self.get_success_url())
else:
return self.form_invalid(form)
def get_success_url(self):
return reverse('polls:poll_list')
class PollDetailView(LoginRequiredMixin, UserPassesTestMixin, DetailView):
model = Poll
template_name = 'polls/poll_detail.html'
def test_func(self):
# Check if user is part of the team for which the poll is
poll = self.get_object()
user = self.request.user
user_teams = set()
if user.team:
user_teams.add(user.team)
user_teams.update(user.coached_teams.all())
user_teams.update(user.assisted_teams.all())
if hasattr(user, 'children'):
for child in user.children.all():
if child.team:
user_teams.add(child.team)
return poll.team in user_teams
def vote(request, poll_id):
poll = get_object_or_404(Poll, pk=poll_id)
user = request.user
if request.method == 'POST':
if poll.multiple_choice:
selected_choice_ids = request.POST.getlist('choice')
# First, remove user's previous votes for this poll
poll.choices.filter(votes=user).update(votes=None)
# Then, add new votes
for choice_id in selected_choice_ids:
choice = get_object_or_404(Choice, pk=choice_id)
choice.votes.add(user)
else:
selected_choice_id = request.POST.get('choice')
if selected_choice_id:
# Remove user's vote from all choices in this poll first
for choice in poll.choices.all():
choice.votes.remove(user)
# Add the new vote
choice = get_object_or_404(Choice, pk=selected_choice_id)
choice.votes.add(user)
return redirect('polls:poll_results', pk=poll.id)
class PollResultsView(LoginRequiredMixin, DetailView):
model = Poll
template_name = 'polls/poll_results.html'
context_object_name = 'poll'

View File

@ -41,6 +41,9 @@
<li class="nav-item">
<a class="nav-link" href="{% url 'past_games' %}">Past Games</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'polls:poll_list' %}">Polls</a>
</li>
{% if user.coached_teams.all %}
<li class="nav-item">
<a class="nav-link" href="{% url 'player-add' %}">Create New Player</a>