Compare commits

..

10 Commits

Author SHA1 Message Date
Matthias Nagel
fd47f25c36 tralala 2025-11-23 17:38:43 +01:00
Matthias Nagel
bc4b8a1e7f Feat: Implementierung des Spieler- und Eltern-Verifizierungsprozesses
Fügt einen umfassenden Verifizierungsprozess für neu erstellte Spieler
und zugeordnete Eltern hinzu. Dies ersetzt das frühere Einladungscode-System.

Wesentliche Änderungen:
- **`CustomUser` Modell:** Erweitert um `is_verified` (Standard `False`) und
  `verification_code` (UUID) Felder. `is_active` ist nun standardmäßig `False`
  bis zur Verifizierung. Das `InvitationCode`-Modell wurde entfernt.
- **E-Mail-Utility (`accounts/utils.py`):** Eine neue Funktion `send_verification_email`
  sendet oder simuliert E-Mails (basierend auf `settings.MTP_EMAIL_SEND`).
  Simulierte E-Mails werden im `.mbox`-Format in `tmp_mails/` gespeichert.
- **`settings.py`:** `DEFAULT_FROM_EMAIL` wurde hinzugefügt.
- **`PlayerCreateView` (`accounts/views.py`):**
    - Generiert `verification_code` für neue Spieler und Eltern.
    - Setzt das Passwort für neue Benutzer auf unbrauchbar (`set_unusable_password`).
    - Löst den Versand von Verifizierungs-E-Mails aus.
- **`verify_account` View (`accounts/views.py`):**
    - Eine neue View, die über einen Link in der E-Mail aufgerufen wird.
    - Ermöglicht Spielern, ein Passwort festzulegen.
    - Ermöglicht Eltern, einen eindeutigen Benutzernamen und ein Passwort festzulegen.
    - Setzt `is_active` und `is_verified` auf `True` und invalidiert den
      Verifizierungscode nach erfolgreicher Einrichtung.
    - Loggt den Benutzer nach erfolgreicher Verifizierung direkt ein und zeigt
      eine Erfolgsmeldung an.
    - Behebt ein Problem bei der Bestimmung von Eltern-Benutzern.
- **Formulare (`accounts/forms.py`):** Neue `PlayerVerificationForm` und
  `ParentVerificationForm` für den Verifizierungsprozess.
- **E-Mail-Templates:** Neue Text- und HTML-Templates für Spieler- und
  Eltern-Verifizierungs-E-Mails (`accounts/templates/accounts/email/`).
- **Verifizierungs-Template:** Neues Template für die Verifizierungsseite
  (`accounts/templates/accounts/verify_account.html`).
- **URLs (`accounts/urls.py`):** Entfernung der alten `invitation_code` und
  `register` URLs, Hinzufügung der neuen `verify_account` URL.
- **Datenbankmigrationen:** Migrationen für die Änderungen am `CustomUser`-Modell
  erstellt und angewendet.
- **Temporäres Verzeichnis:** `tmp_mails/` Verzeichnis für E-Mail-Simulation erstellt.
2025-11-23 16:34:25 +01:00
Matthias Nagel
5cc9b387b9 Feat: Saison-Report für Head Coaches
Implementiert einen druckbaren Saison-Report für Head Coaches, der eine
Übersicht über die Spielerteilnahme an allen Spielen einer ausgewählten
Saison bietet.

Wesentliche Änderungen:
- **Neue URL und View (`season_report` in `team_stats/views.py`):**
  Empfängt `team_id` und `season`, holt Spieler, Spiele und deren
  Teilnahmestatus und bereitet die Daten auf. Enthält eine
  Berechtigungsprüfung für den Head Coach.
- **Neue Vorlage (`team_stats/templates/team_stats/season_report.html`):**
  Zeigt eine Tabelle mit Spielen als Zeilen und Spielern als Spalten an.
  Teilnahmestatus wird mit Symbolen (✔, ✖, ?) dargestellt. Enthält
  druckspezifisches CSS, um die Lesbarkeit und das Seitenlayout für DIN A4
  (Querformat) zu optimieren, inklusive vertikal gedrehter Spielernamen in
  der Kopfzeile zur Platzersparnis.
- **Integration (`team_statistics.html`):** Ein "Saison-Report generieren"-Button
  wurde zur Team-Statistikseite hinzugefügt, der den Report für die
  aktuell ausgewählte Saison öffnet.
2025-11-23 14:33:56 +01:00
Matthias Nagel
d45fc54280 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.
2025-11-22 21:34:04 +01:00
Matthias Nagel
223dd65382 Fix: Chronologische Sortierung der Events im Dashboard
Stellt sicher, dass die Event-Listen ('Your Events' und 'Children Events')
im Dashboard konsistent chronologisch nach dem  sortiert sind.

Obwohl die Datenbankabfragen bereits eine Sortierung enthielten, ging diese
durch die anschließende Verarbeitung in Python-Listen verloren. Die Listen
werden nun nach ihrer Erstellung explizit in Python sortiert.

Änderungen:
- Die -Liste wird nach ihrer Befüllung nach
   sortiert.
- Die  für jedes Kind wird ebenfalls nach
   sortiert, bevor sie dem Kontext hinzugefügt wird.
2025-11-21 23:03:44 +01:00
Matthias Nagel
20075fbe1f Fix: Behebung von Fehlern im Abstimmungs-Feature
Behebt zwei Fehler im kürzlich implementierten Abstimmungs-Feature:
1.  **Fehler bei Mehrfachauswahl:** Ein logischer Fehler in der -View, der
    beim Abstimmen bei Multiple-Choice-Umfragen auftrat, wurde behoben.
    Die inkorrekte Verwendung von  wurde durch eine
    korrekte Iteration ersetzt, die den Benutzer aus den  der
    einzelnen Auswahlmöglichkeiten entfernt.
2.  **Anzahl der Auswahlmöglichkeiten:** Das Formular zur Erstellung von
    Umfragen zeigte nur zwei Felder für Auswahlmöglichkeiten an. Dies wurde
    korrigiert, sodass nun, wie gefordert, bis zu fünf
    Auswahlmöglichkeiten eingegeben werden können.

Änderungen:
- Korrektur der Logik zum Zurücksetzen von Stimmen in  für
  Mehrfachauswahl-Umfragen.
- Anpassung des  in , um die Anzahl der
  angezeigten Auswahlfelder auf 5 zu erhöhen.
2025-11-21 22:58:38 +01:00
Matthias Nagel
001444e0dd 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.
2025-11-21 22:55:07 +01:00
Matthias Nagel
cb08474301 Fix: EventUpdateView - Formular mit bestehenden Daten vorbefüllen
Behebt einen Fehler in der , bei dem beim Bearbeiten
eines Events (Spiel oder Training) das Formular leer angezeigt wurde
anstatt mit den vorhandenen Daten des Events vorbefüllt zu sein.

Die Ursache war eine Inkonsistenz bei der Übergabe der Modellinstanz:
Die  erhielt ein übergeordnetes -Objekt, während die
 für  oder  eine spezifische Child-Instanz
erwartete.

Änderungen:
- Überschreibung der Methode  in , um die
  korrekte untergeordnete Instanz ( oder ) des Events
  abzurufen und an das Formular zu übergeben.
- Aktualisierung der Methode  in  zur
  Verwendung von  für eine robustere Typüberprüfung des
  Objekts.
2025-11-20 08:54:28 +01:00
Matthias Nagel
fb782d85db Feat: Supporter-Team-Statistiken in der Team-Statistikansicht
Erweitert die Team-Statistikansicht um zwei neue Metriken:
- 'Games with Supporters': Zeigt die Anzahl der Spiele an, die mit Unterstützung
  durch ein zweites Team (Supporter-Team) stattgefunden haben.
- 'Avg. Supporter Share': Berechnet den durchschnittlichen Prozentsatz der
  Spieler, die in diesen Spielen vom Supporter-Team gestellt wurden.

Diese Statistiken helfen, den Grad der Abhängigkeit von externer Spielerunterstützung
zu analysieren. Die Anzeige erfolgt in einer neuen Karte auf der Team-Statistikseite.

Änderungen umfassen:
- Anpassung der Funktion  in
  zur Berechnung der neuen Metriken unter Berücksichtigung von
   und .
- Erweiterung des Templates
  um eine neue 'Supporter Stats'-Karte zur Anzeige der berechneten Werte.
2025-11-19 21:14:21 +01:00
Matthias Nagel
a37954de65 Feat: Spiele-Historie-Ansicht für alle Team-User
Fügt eine neue Seite 'Spiele-Historie' hinzu, die allen Nutzern, die mit
einem oder mehreren Teams in Verbindung stehen (als Spieler, Head Coach,
Assistant Coach oder Elternteil), eine Übersicht über vergangene Spiele
ihrer Teams bietet. Die Spiele sind pro Team aufgeschlüsselt und zeigen
detaillierte Scorelines inklusive Inning-Ergebnissen an.

Änderungen umfassen:
- Neue View  in  zur Ermittlung der
  zugehörigen Teams und Abfrage der Spielhistorie.
- Aktualisierung der  in der View, um Inning-Scores auf 9
  Einträge aufzufüllen und so die Template-Logik zu vereinfachen.
- Neue URL-Konfiguration in .
- Neues Template  für die
  Darstellung der Spielhistorie mit detaillierter Scoreline pro Inning.
- Ergänzung eines Navigationslinks in  für
  authentifizierte Benutzer.
2025-11-19 10:00:58 +01:00
41 changed files with 1189 additions and 184 deletions

View File

@ -1,18 +1,5 @@
from django import forms
from .models import InvitationCode, CustomUser
class InvitationCodeForm(forms.Form):
code = forms.CharField(max_length=255, label="Einladungscode")
def clean_code(self):
code = self.cleaned_data.get('code')
try:
invitation_code = InvitationCode.objects.get(code=code)
if not invitation_code.is_valid():
raise forms.ValidationError("Dieser Einladungscode ist nicht mehr gültig.")
except InvitationCode.DoesNotExist:
raise forms.ValidationError("Ungültiger Einladungscode.")
return code
from .models import CustomUser
class CustomUserCreationForm(forms.ModelForm):
password = forms.CharField(widget=forms.PasswordInput)
@ -37,3 +24,22 @@ class PlayerCreationForm(forms.ModelForm):
class Meta:
model = CustomUser
fields = ('username', 'first_name', 'last_name', 'email', 'birth_date', 'player_number', 'team')
class PlayerVerificationForm(forms.Form):
password = forms.CharField(widget=forms.PasswordInput, label="Passwort")
password_confirm = forms.CharField(widget=forms.PasswordInput, label="Passwort bestätigen")
def clean(self):
cleaned_data = super().clean()
if cleaned_data.get('password') != cleaned_data.get('password_confirm'):
raise forms.ValidationError("Die Passwörter stimmen nicht überein.")
return cleaned_data
class ParentVerificationForm(PlayerVerificationForm):
username = forms.CharField(max_length=150, label="Benutzername")
def clean_username(self):
username = self.cleaned_data.get('username')
if CustomUser.objects.filter(username=username).exists():
raise forms.ValidationError("Dieser Benutzername ist bereits vergeben.")
return username

View File

@ -0,0 +1,31 @@
# Generated by Django 5.2.6 on 2025-11-23 15:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0004_customuser_parents'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='is_verified',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='customuser',
name='verification_code',
field=models.UUIDField(blank=True, editable=False, null=True, unique=True),
),
migrations.AlterField(
model_name='customuser',
name='is_active',
field=models.BooleanField(default=False),
),
migrations.DeleteModel(
name='InvitationCode',
),
]

View File

@ -1,3 +1,4 @@
import uuid
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.utils import timezone
@ -8,6 +9,11 @@ class CustomUser(AbstractUser):
player_number = models.IntegerField(default=999)
team = models.ForeignKey('clubs.Team', on_delete=models.SET_NULL, null=True, blank=True, related_name='players')
parents = models.ManyToManyField('self', symmetrical=False, blank=True, related_name='children')
is_verified = models.BooleanField(default=False)
verification_code = models.UUIDField(null=True, blank=True, unique=True, editable=False)
# New users are not active until they verify and set a password
is_active = models.BooleanField(default=False)
@property
def age(self):
@ -16,23 +22,6 @@ class CustomUser(AbstractUser):
today = datetime.date.today()
return today.year - self.birth_date.year - ((today.month, today.day) < (self.birth_date.month, self.birth_date.day))
class InvitationCode(models.Model):
code = models.CharField(max_length=255, unique=True)
user = models.ForeignKey(CustomUser, on_delete=models.CASCADE, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
is_active = models.BooleanField(default=True)
def is_valid(self):
if not self.is_active:
return False
two_weeks_ago = timezone.now() - datetime.timedelta(weeks=2)
if self.created_at < two_weeks_ago:
return False
return True
def __str__(self):
return self.code
class AbsencePeriod(models.Model):
user = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name='absence_periods')
start_date = models.DateField()

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<title>Kontoaktivierung - Baseball Organisator</title>
</head>
<body>
<h2>Hallo,</h2>
<p>ein Konto wurde für Sie beim Baseball Organisator erstellt, da Sie als Elternteil für einen Spieler registriert wurden.</p>
<p>Bitte klicken Sie auf den folgenden Link, um Ihr Konto zu aktivieren. Sie werden aufgefordert, einen Benutzernamen zu wählen und ein Passwort festzulegen:</p>
<p><a href="{{ verification_url }}">Konto jetzt aktivieren</a></p>
<p>Ihr Verifizierungscode lautet: <strong>{{ verification_code }}</strong></p>
<p>Wenn Sie den Link nicht klicken können, kopieren Sie bitte die folgende URL und fügen Sie sie in die Adresszeile Ihres Browsers ein:<br>{{ verification_url }}</p>
<p>Vielen Dank,<br>Ihr Baseball Organisator Team</p>
</body>
</html>

View File

@ -0,0 +1,14 @@
Hallo,
ein Konto wurde für Sie beim Baseball Organisator erstellt, da Sie als Elternteil für einen Spieler registriert wurden.
Bitte klicken Sie auf den folgenden Link, um Ihr Konto zu aktivieren. Sie werden aufgefordert, einen Benutzernamen zu wählen und ein Passwort festzulegen:
{{ verification_url }}
Ihr Verifizierungscode lautet: {{ verification_code }}
Wenn Sie den Link nicht klicken können, kopieren Sie ihn bitte und fügen Sie ihn in die Adresszeile Ihres Browsers ein.
Vielen Dank,
Ihr Baseball Organisator Team

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<title>Willkommen beim Baseball Organisator</title>
</head>
<body>
<h2>Hallo {{ user.first_name }},</h2>
<p>willkommen beim Baseball Organisator!</p>
<p>Ein Konto wurde für dich von deinem Coach erstellt. Bitte klicke auf den folgenden Link, um dein Passwort festzulegen und dein Konto zu aktivieren:</p>
<p><a href="{{ verification_url }}">Konto jetzt aktivieren</a></p>
<p>Dein Verifizierungscode lautet: <strong>{{ verification_code }}</strong></p>
<p>Wenn du den Link nicht klicken kannst, kopiere bitte die folgende URL und füge sie in die Adresszeile deines Browsers ein:<br>{{ verification_url }}</p>
<p>Vielen Dank,<br>Dein Baseball Organisator Team</p>
</body>
</html>

View File

@ -0,0 +1,14 @@
Hallo {{ user.first_name }},
willkommen beim Baseball Organisator!
Ein Konto wurde für dich von deinem Coach erstellt. Bitte klicke auf den folgenden Link, um dein Passwort festzulegen und dein Konto zu aktivieren:
{{ verification_url }}
Dein Verifizierungscode lautet: {{ verification_code }}
Wenn du den Link nicht klicken kannst, kopiere ihn bitte und füge ihn in die Adresszeile deines Browsers ein.
Vielen Dank,
Dein Baseball Organisator Team

View File

@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h2>Konto verifizieren</h2>
</div>
<div class="card-body">
<p>Bitte legen Sie Ihre Zugangsdaten fest.</p>
<form method="post">
{% csrf_token %}
{% if is_parent %}
<div class="mb-3">
<label for="{{ form.username.id_for_label }}" class="form-label">{{ form.username.label }}</label>
{{ form.username }}
{% for error in form.username.errors %}
<div class="alert alert-danger p-1 mt-1">{{ error }}</div>
{% endfor %}
</div>
{% endif %}
<div class="mb-3">
<label for="{{ form.password.id_for_label }}" class="form-label">{{ form.password.label }}</label>
{{ form.password }}
{% for error in form.password.errors %}
<div class="alert alert-danger p-1 mt-1">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
<label for="{{ form.password_confirm.id_for_label }}" class="form-label">{{ form.password_confirm.label }}</label>
{{ form.password_confirm }}
{% for error in form.password_confirm.errors %}
<div class="alert alert-danger p-1 mt-1">{{ error }}</div>
{% endfor %}
</div>
{% for error in form.non_field_errors %}
<div class="alert alert-danger">{{ error }}</div>
{% endfor %}
<button type="submit" class="btn btn-primary">Konto aktivieren</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -3,8 +3,7 @@ from . import views
from django.contrib.auth import views as auth_views
urlpatterns = [
path('invitation/', views.invitation_code_view, name='invitation_code'),
path('register/', views.register_view, name='register'),
path('verify/<uuid:verification_code>/', views.verify_account, name='verify_account'),
path('login/', views.MyLoginView.as_view(template_name='accounts/login.html'), name='login'),
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
path('profile/', views.edit_profile, name='edit_profile'),

74
accounts/utils.py Normal file
View File

@ -0,0 +1,74 @@
import os
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.urls import reverse
import uuid
def send_verification_email(user, request, is_parent=False):
"""
Sends a verification email to a new user (player or parent).
"""
# Ensure user has a verification code
if not user.verification_code:
user.verification_code = uuid.uuid4()
user.save()
# Build the verification URL
verification_path = reverse('verify_account', kwargs={'verification_code': str(user.verification_code)})
verification_url = request.build_absolute_uri(verification_path)
# Determine which template and subject to use
if is_parent:
subject = 'Verifizieren Sie Ihr Eltern-Konto für den Baseball Organisator'
template_prefix = 'accounts/email/parent_verification'
else:
subject = 'Willkommen beim Baseball Organisator! Bitte verifizieren Sie Ihr Konto.'
template_prefix = 'accounts/email/player_verification'
context = {
'user': user,
'verification_url': verification_url,
'verification_code': user.verification_code
}
# Render email body from templates
email_body_txt = render_to_string(f'{template_prefix}.txt', context)
email_body_html = render_to_string(f'{template_prefix}.html', context)
# Send or simulate email based on settings
if settings.MTP_EMAIL_SEND == 1:
send_mail(
subject=subject,
message=email_body_txt,
from_email=settings.DEFAULT_FROM_EMAIL, # Make sure this is set in settings.py
recipient_list=[user.email],
html_message=email_body_html,
fail_silently=False,
)
else:
# Simulate email by saving to a file
mbox_content = f"""From: {settings.DEFAULT_FROM_EMAIL}
To: {user.email}
Subject: {subject}
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="boundary"
--boundary
Content-Type: text/plain; charset="utf-8"
{email_body_txt}
--boundary
Content-Type: text/html; charset="utf-8"
{email_body_html}
--boundary--
"""
# Ensure the tmp_mails directory exists
os.makedirs('tmp_mails', exist_ok=True)
# Save the email to a file
file_path = os.path.join('tmp_mails', f'{user.email}_{user.verification_code}.mbox')
with open(file_path, 'w') as f:
f.write(mbox_content)

View File

@ -3,128 +3,58 @@ from django.urls import reverse_lazy
from django.views.generic.edit import CreateView
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.auth import views as auth_views
from django.db.models import Q
from django.contrib.auth import views as auth_views, login
from django.contrib import messages
from django.db import Q, IntegrityError
from django.http import JsonResponse
from .forms import InvitationCodeForm, CustomUserCreationForm, CustomUserChangeForm, PlayerCreationForm
from .models import CustomUser, InvitationCode
from .forms import CustomUserCreationForm, CustomUserChangeForm, PlayerCreationForm, PlayerVerificationForm, ParentVerificationForm
from .models import CustomUser
from .utils import send_verification_email
import uuid
def invitation_code_view(request):
if request.method == 'POST':
form = InvitationCodeForm(request.POST)
if form.is_valid():
code = form.cleaned_data['code']
request.session['invitation_code'] = code
return redirect('register')
else:
form = InvitationCodeForm()
return render(request, 'accounts/invitation_code.html', {'form': form})
def register_view(request):
invitation_code_str = request.session.get('invitation_code')
if not invitation_code_str:
return redirect('invitation_code')
try:
invitation_code = InvitationCode.objects.get(code=invitation_code_str)
if not invitation_code.is_valid():
# Handle invalid code, maybe redirect with a message
return redirect('invitation_code')
except InvitationCode.DoesNotExist:
return redirect('invitation_code')
if request.method == 'POST':
form = CustomUserCreationForm(request.POST)
if form.is_valid():
user = form.save(commit=False)
user.set_password(form.cleaned_data['password'])
user.save()
invitation_code.is_active = False
invitation_code.user = user
invitation_code.save()
# Log the user in and redirect to the dashboard
return redirect('login') # Or wherever you want to redirect after registration
else:
form = CustomUserCreationForm()
return render(request, 'accounts/register.html', {'form': form})
@login_required
def edit_profile(request):
if request.method == 'POST':
form = CustomUserChangeForm(request.POST, instance=request.user)
if form.is_valid():
form.save()
return redirect('edit_profile') # Or wherever you want to redirect
else:
form = CustomUserChangeForm(instance=request.user)
return render(request, 'accounts/edit_profile.html', {'form': form})
class HeadCoachCheckMixin(UserPassesTestMixin):
def test_func(self):
return self.request.user.is_superuser or self.request.user.coached_teams.exists()
class PlayerCreateView(LoginRequiredMixin, HeadCoachCheckMixin, CreateView):
model = CustomUser
form_class = PlayerCreationForm
template_name = 'accounts/player_form.html'
success_url = reverse_lazy('dashboard')
def form_valid(self, form):
# Create player user
player = form.save(commit=False)
player.is_active = False # Player can only login after using invitation code
player.save()
# Create invitation code for player
InvitationCode.objects.create(code=str(uuid.uuid4()), user=player)
# Handle parents
for i in ['1', '2']:
search_identifier = form.cleaned_data.get(f'parent{i}_search')
new_email = form.cleaned_data.get(f'parent{i}_new')
if search_identifier:
import re
match = re.search(r'\((\w+)\)', search_identifier)
if match:
username = match.group(1)
try:
parent_user = CustomUser.objects.get(username=username)
player.parents.add(parent_user)
except CustomUser.DoesNotExist:
form.add_error(f'parent{i}_search', 'User not found.')
else:
# if no user is selected from the list, maybe the user typed an email/username directly
try:
parent_user = CustomUser.objects.get(Q(username=search_identifier) | Q(email=search_identifier))
player.parents.add(parent_user)
except CustomUser.DoesNotExist:
form.add_error(f'parent{i}_search', 'User not found.')
except CustomUser.MultipleObjectsReturned:
form.add_error(f'parent{i}_search', 'Multiple users found. Please be more specific.')
...
elif new_email:
parent_user, created = CustomUser.objects.get_or_create(email=new_email, defaults={'username': new_email, 'is_active': False})
InvitationCode.objects.create(code=str(uuid.uuid4()), user=parent_user)
player.parents.add(parent_user)
try:
parent_user = CustomUser.objects.create(
email=new_email,
username=new_email,
is_active=False
)
parent_user.set_unusable_password()
parent_user.verification_code = uuid.uuid4()
parent_user.save()
# Send verification email to new parent
send_verification_email(parent_user, self.request, is_parent=True)
player.parents.add(parent_user)
except IntegrityError:
form.add_error(f'parent{i}_new', f"A user with the email '{new_email}' already exists. Please use the search field to add them.")
if form.errors:
return self.form_invalid(form)
return redirect(self.success_url)
class MyLoginView(auth_views.LoginView):
def get(self, request, *args, **kwargs):
if request.user.is_authenticated:
...
def verify_account(request, verification_code):
user = get_object_or_404(CustomUser, verification_code=verification_code, is_verified=False)
# Determine if user is a parent (has no team) or player
is_parent = user.team is None
FormClass = ParentVerificationForm if is_parent else PlayerVerificationForm
if request.method == 'POST':
form = FormClass(request.POST)
if form.is_valid():
user.set_password(form.cleaned_data['password'])
if is_parent:
user.username = form.cleaned_data['username']
user.is_active = True
user.is_verified = True
user.verification_code = None # Invalidate the code
user.save()
login(request, user) # Log the user in
messages.success(request, 'Your account has been verified! You are now logged in.')
return redirect('dashboard')
return super().get(request, *args, **kwargs)
def user_search(request):
q = request.GET.get('q', '')
users = CustomUser.objects.filter(last_name__istartswith=q).values('username', 'first_name', 'last_name')
results = []
for user in users:
results.append(f"{user['last_name']}, {user['first_name']} ({user['username']})")
return JsonResponse(results, safe=False)
else:
form = FormClass()
return render(request, 'accounts/verify_account.html', {'form': form, 'is_parent': is_parent})

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 = [
@ -140,3 +141,6 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
AUTH_USER_MODEL = 'accounts.CustomUser'
LOGIN_REDIRECT_URL = '/'
MTP_EMAIL_SEND = 0
DEFAULT_FROM_EMAIL = 'webmaster@localhost'

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')),
]

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
@ -76,10 +108,20 @@ class EventUpdateView(LoginRequiredMixin, CoachCheckMixin, UpdateView):
template_name = 'calendars/event_form.html'
success_url = reverse_lazy('dashboard')
def get_object(self, queryset=None):
# Fetch the event and then check if it's a game or training to return the specific instance
obj = super().get_object(queryset)
if hasattr(obj, 'game'):
return obj.game
if hasattr(obj, 'training'):
return obj.training
return obj
def get_form_class(self):
if hasattr(self.object, 'game'):
# self.object is now the correct child instance because of the overridden get_object
if isinstance(self.object, Game):
return GameForm
if hasattr(self.object, 'training'):
if isinstance(self.object, Training):
return TrainingForm
return EventForm
@ -88,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)

View File

@ -0,0 +1,63 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="container mt-4">
<h1 class="mb-4">Past Games</h1>
{% if not games_by_team %}
<div class="alert alert-info" role="alert">
You are not associated with any team that has played games.
</div>
{% else %}
{% for team, games in games_by_team.items %}
<h2 class="mt-5 mb-3">{{ team.name }}</h2>
{% if not games %}
<p>No past games with results for this team.</p>
{% else %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="thead-dark">
<tr>
<th scope="col">Date</th>
<th scope="col">Opponent</th>
<th scope="col" colspan="11" class="text-center">Scoreline</th>
<th scope="col">Final</th>
</tr>
<tr>
<th colspan="2"></th>
{% for i in "123456789" %}
<th scope="col" class="text-center">{{ i }}</th>
{% endfor %}
<th class="text-center">R</th>
<th class="text-center">H</th>
</tr>
</thead>
<tbody>
{% for data in games %}
<tr>
<td rowspan="2">{{ data.game.start_time|date:"d.m.Y" }}</td>
<td>{{ data.game.opponent }}</td>
{% for inning_score in data.away_innings %}
<td class="text-center">{{ inning_score }}</td>
{% endfor %}
<td class="text-center fw-bold">{{ data.away_score }}</td>
<td class="text-center fw-bold">{{ data.game.result.away_hits }}</td>
</tr>
<tr>
<td class="fw-bold">{{ team.name }}</td>
{% for inning_score in data.home_innings %}
<td class="text-center">{{ inning_score }}</td>
{% endfor %}
<td class="text-center fw-bold">{{ data.home_score }}</td>
<td class="text-center fw-bold">{{ data.game.result.home_hits }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endfor %}
{% endif %}
</div>
{% endblock %}

View File

@ -4,4 +4,5 @@ from . import views
urlpatterns = [
path('', views.dashboard, name='dashboard'),
path('players/', views.player_list, name='player_list'),
path('past-games/', views.past_games, name='past_games'),
]

View File

@ -1,11 +1,12 @@
from django.shortcuts import render
from django.contrib.auth.decorators import login_required
from calendars.models import Event, EventParticipation
from calendars.models import Event, EventParticipation, Game
from clubs.models import Team
from accounts.models import CustomUser
from django.db.models import Q
from django.utils import timezone
import datetime
from itertools import chain
def get_all_child_teams(parent_team):
"""
@ -40,7 +41,7 @@ def dashboard(request):
assisted_teams = user.assisted_teams.all()
from itertools import chain
all_teams = list(set(chain(player_teams, expanded_coached_teams, assisted_teams)))
now = timezone.now()
@ -86,6 +87,9 @@ def dashboard(request):
'days_until_event': days_until_event,
'local_start_time_iso': local_start_time.isoformat()
})
# Sort the final list by event start time
events_with_participation.sort(key=lambda x: x['event'].start_time)
# Get children's events
if hasattr(user, 'children'):
@ -102,6 +106,9 @@ def dashboard(request):
for event in child_events:
participation, created = EventParticipation.objects.get_or_create(user=child, event=event)
child_events_list.append({'event': event, 'participation': participation})
# Sort each child's event list
child_events_list.sort(key=lambda x: x['event'].start_time)
children_events.append({'child': child, 'events': child_events_list})
context = {
@ -130,3 +137,68 @@ def player_list(request):
'teams': all_teams
}
return render(request, 'dashboard/player_list.html', context)
@login_required
def past_games(request):
user = request.user
user_teams = set()
# Player's team
if user.team:
user_teams.add(user.team)
# Coached teams
for team in user.coached_teams.all():
user_teams.add(team)
user_teams.update(get_all_child_teams(team))
# Assisted teams
for team in user.assisted_teams.all():
user_teams.add(team)
# Parents' children's teams
if hasattr(user, 'children'):
for child in user.children.all():
if child.team:
user_teams.add(child.team)
# Fetch past games for all collected teams
games_qs = Game.objects.filter(
team__in=list(user_teams),
start_time__lt=timezone.now(),
result__isnull=False
).select_related('team', 'result').order_by('team__name', '-start_time')
# Group games by team
games_by_team = {}
for game in games_qs:
if game.team not in games_by_team:
games_by_team[game.team] = []
# Prepare scoreline data
result = game.result
sorted_items = sorted(result.inning_results.items(), key=lambda x: int(x[0].split('_')[1]))
home_innings = [item[1].get('home', 'X') for item in sorted_items]
away_innings = [item[1].get('guest', 'X') for item in sorted_items]
# Pad innings to 9
home_innings.extend([''] * (9 - len(home_innings)))
away_innings.extend([''] * (9 - len(away_innings)))
home_score = sum(i for i in home_innings if isinstance(i, int))
away_score = sum(i for i in away_innings if isinstance(i, int))
game_data = {
'game': game,
'home_score': home_score,
'away_score': away_score,
'home_innings': home_innings[:9],
'away_innings': away_innings[:9]
}
games_by_team[game.team].append(game_data)
context = {
'games_by_team': games_by_team
}
return render(request, 'dashboard/past_games.html', context)

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=5, 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'),
]

128
polls/views.py Normal file
View File

@ -0,0 +1,128 @@
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
for choice in poll.choices.all():
choice.votes.remove(user)
# 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

@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Season Report for {{ team.name }} - {{ season }}</title>
<style>
body {
font-family: sans-serif;
font-size: 10px;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
border: 1px solid #ccc;
padding: 4px;
text-align: center;
}
thead th {
background-color: #f2f2f2;
}
.player-header {
height: 150px;
white-space: nowrap;
position: relative;
}
.player-header > div {
transform: rotate(-90deg);
position: absolute;
left: 0;
bottom: 0;
transform-origin: 0 100%;
width: 150px; /* Should match height */
}
.status-attending {
color: green;
}
.status-rejected {
color: red;
}
.status-maybe {
color: #aaa;
}
.print-button {
margin: 1rem;
padding: 0.5rem 1rem;
}
@media print {
@page {
size: A4 landscape;
margin: 1cm;
}
body {
font-size: 9px;
}
.print-button {
display: none;
}
}
</style>
</head>
<body>
<button onclick="window.print();" class="print-button">Print Report</button>
<h2>Season Report</h2>
<p><strong>Team:</strong> {{ team.name }}</p>
<p><strong>Season:</strong> {{ season }}</p>
<table>
<thead>
<tr>
<th>Date</th>
<th>Opponent</th>
{% for player in players %}
<th class="player-header"><div>{{ player.get_full_name }}</div></th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in report_data %}
<tr>
<td>{{ row.game.start_time|date:"d.m.Y" }}</td>
<td>{{ row.game.opponent }}</td>
{% for status in row.statuses %}
<td class="status-{{ status }}">
{% if status == 'attending' %}
{% elif status == 'rejected' %}
{% else %}
?
{% endif %}
</td>
{% endfor %}
</tr>
{% empty %}
<tr>
<td colspan="{{ players|length|add:2 }}">No games found for this season.</td>
</tr>
{% endfor %}
</tbody>
</table>
</body>
</html>

View File

@ -22,6 +22,11 @@
<div class="col-md-2">
<button type="submit" class="btn btn-primary">Filter</button>
</div>
{% if selected_season %}
<div class="col-md-3">
<a href="{% url 'team_stats:season_report' team_id=team.id season=selected_season %}" class="btn btn-secondary" target="_blank">Generate Season Report</a>
</div>
{% endif %}
</div>
</form>
</div>
@ -67,9 +72,9 @@
</div>
</div>
<!-- Luck-O-Meter and Inning Heatmap -->
<!-- Luck-O-Meter, Supporter Stats -->
<div class="row mt-4">
<div class="col-md-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">Luck-O-Meter</div>
<div class="card-body">
@ -78,7 +83,20 @@
</div>
</div>
</div>
<div class="col-md-8">
<div class="col-md-6">
<div class="card">
<div class="card-header">Supporter Stats</div>
<div class="card-body">
<p>Games with Supporters: {{ stats.games_with_supporters }}</p>
<p>Avg. Supporter Share: {{ stats.avg_supporter_player_percentage|floatformat:2 }}%</p>
</div>
</div>
</div>
</div>
<!-- Inning Heatmap -->
<div class="row mt-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">Inning Heatmap (Runs Scored)</div>
<div class="card-body">

View File

@ -5,4 +5,5 @@ app_name = 'team_stats'
urlpatterns = [
path('team/<int:team_id>/', views.team_statistics, name='team_statistics'),
path('team/<int:team_id>/report/<str:season>/', views.season_report, name='season_report'),
]

View File

@ -2,13 +2,13 @@ from django.shortcuts import render, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseForbidden
from clubs.models import Team
from calendars.models import Game
from calendars.models import Game, EventParticipation
def _calculate_statistics(team, season=None):
"""
Calculates statistics for a given team and season.
"""
games_query = Game.objects.filter(team=team, result__isnull=False)
games_query = Game.objects.filter(team=team, result__isnull=False).prefetch_related('opened_for_teams')
if season:
games_query = games_query.filter(season=season)
@ -20,10 +20,25 @@ def _calculate_statistics(team, season=None):
runs_allowed = 0
inning_runs = {i: 0 for i in range(1, 10)}
streak_counter = 0
current_streak_type = None
last_game_result = None
games_with_supporters = 0
total_supporter_player_percentage = 0
for game in games:
# Supporter stats
if game.opened_for_teams.exists():
games_with_supporters += 1
attending_players = EventParticipation.objects.filter(event=game, status='attending').select_related('user__team')
total_attendees = attending_players.count()
if total_attendees > 0:
supporter_players_count = attending_players.exclude(user__team=game.team).count()
supporter_percentage = (supporter_players_count / total_attendees) * 100
total_supporter_player_percentage += supporter_percentage
# Standard game stats
result = game.result
sorted_items = sorted(result.inning_results.items(), key=lambda x: int(x[0].split('_')[1]))
@ -84,6 +99,8 @@ def _calculate_statistics(team, season=None):
pythagorean_pct = (runs_scored**2 / (runs_scored**2 + runs_allowed**2)) * 100
else:
pythagorean_pct = 0
avg_supporter_player_percentage = (total_supporter_player_percentage / games_with_supporters) if games_with_supporters > 0 else 0
return {
'team': team,
@ -96,6 +113,8 @@ def _calculate_statistics(team, season=None):
'pythagorean_pct': pythagorean_pct,
'inning_runs': inning_runs,
'total_games': total_games,
'games_with_supporters': games_with_supporters,
'avg_supporter_player_percentage': avg_supporter_player_percentage,
}
@login_required
@ -128,3 +147,50 @@ def team_statistics(request, team_id):
}
return render(request, 'team_stats/team_statistics.html', context)
@login_required
def season_report(request, team_id, season):
team = get_object_or_404(Team, pk=team_id)
# Security check: only head coach can view
if request.user != team.head_coach:
return HttpResponseForbidden("You are not authorized to view this report.")
# 1. Get all players for the team, ordered
players = team.players.all().order_by('last_name', 'first_name')
player_ids = [p.id for p in players]
# 2. Get all games for the team and season
games = Game.objects.filter(team=team, season=season).order_by('start_time')
game_ids = [g.id for g in games]
# 3. Get all relevant participation data in one query
participations = EventParticipation.objects.filter(
event_id__in=game_ids,
user_id__in=player_ids
)
# 4. Create a fast lookup map
participation_map = {
(p.event_id, p.user_id): p.status for p in participations
}
# 5. Build the final data structure for the template
report_data = []
for game in games:
statuses = []
for player in players:
status = participation_map.get((game.id, player.id), 'maybe')
statuses.append(status)
report_data.append({
'game': game,
'statuses': statuses
})
context = {
'team': team,
'season': season,
'players': players,
'report_data': report_data,
}
return render(request, 'team_stats/season_report.html', context)

View File

@ -38,6 +38,12 @@
<li class="nav-item">
<a class="nav-link" href="{% url 'edit_profile' %}">Profile</a>
</li>
<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>