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:
parent
cb08474301
commit
001444e0dd
@ -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!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
|
||||||
ALLOWED_HOSTS = []
|
ALLOWED_HOSTS = ['*']
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
@ -42,6 +42,7 @@ INSTALLED_APPS = [
|
|||||||
'calendars',
|
'calendars',
|
||||||
'dashboard',
|
'dashboard',
|
||||||
'team_stats',
|
'team_stats',
|
||||||
|
'polls',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@ -24,5 +24,6 @@ urlpatterns = [
|
|||||||
path('clubs/', include('clubs.urls')),
|
path('clubs/', include('clubs.urls')),
|
||||||
path('calendars/', include('calendars.urls')),
|
path('calendars/', include('calendars.urls')),
|
||||||
path('statistics/', include('team_stats.urls')),
|
path('statistics/', include('team_stats.urls')),
|
||||||
|
path('polls/', include('polls.urls')),
|
||||||
path('', include('dashboard.urls')),
|
path('', include('dashboard.urls')),
|
||||||
]
|
]
|
||||||
|
|||||||
0
polls/__init__.py
Normal file
0
polls/__init__.py
Normal file
3
polls/admin.py
Normal file
3
polls/admin.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
polls/apps.py
Normal file
6
polls/apps.py
Normal 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
23
polls/forms.py
Normal 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)
|
||||||
38
polls/migrations/0001_initial.py
Normal file
38
polls/migrations/0001_initial.py
Normal 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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
0
polls/migrations/__init__.py
Normal file
0
polls/migrations/__init__.py
Normal file
21
polls/models.py
Normal file
21
polls/models.py
Normal 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
|
||||||
29
polls/templates/polls/poll_detail.html
Normal file
29
polls/templates/polls/poll_detail.html
Normal 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 %}
|
||||||
40
polls/templates/polls/poll_form.html
Normal file
40
polls/templates/polls/poll_form.html
Normal 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 %}
|
||||||
31
polls/templates/polls/poll_list.html
Normal file
31
polls/templates/polls/poll_list.html
Normal 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 %}
|
||||||
22
polls/templates/polls/poll_results.html
Normal file
22
polls/templates/polls/poll_results.html
Normal 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
3
polls/tests.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
12
polls/urls.py
Normal file
12
polls/urls.py
Normal 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
127
polls/views.py
Normal 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'
|
||||||
@ -41,6 +41,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'past_games' %}">Past Games</a>
|
<a class="nav-link" href="{% url 'past_games' %}">Past Games</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{% url 'polls:poll_list' %}">Polls</a>
|
||||||
|
</li>
|
||||||
{% if user.coached_teams.all %}
|
{% if user.coached_teams.all %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'player-add' %}">Create New Player</a>
|
<a class="nav-link" href="{% url 'player-add' %}">Create New Player</a>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user