Compare commits
10 Commits
c63ad532b5
...
fd47f25c36
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd47f25c36 | ||
|
|
bc4b8a1e7f | ||
|
|
5cc9b387b9 | ||
|
|
d45fc54280 | ||
|
|
223dd65382 | ||
|
|
20075fbe1f | ||
|
|
001444e0dd | ||
|
|
cb08474301 | ||
|
|
fb782d85db | ||
|
|
a37954de65 |
@ -1,18 +1,5 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from .models import InvitationCode, CustomUser
|
from .models import 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
|
|
||||||
|
|
||||||
class CustomUserCreationForm(forms.ModelForm):
|
class CustomUserCreationForm(forms.ModelForm):
|
||||||
password = forms.CharField(widget=forms.PasswordInput)
|
password = forms.CharField(widget=forms.PasswordInput)
|
||||||
@ -37,3 +24,22 @@ class PlayerCreationForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = CustomUser
|
model = CustomUser
|
||||||
fields = ('username', 'first_name', 'last_name', 'email', 'birth_date', 'player_number', 'team')
|
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
|
||||||
|
|||||||
@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import uuid
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@ -8,6 +9,11 @@ class CustomUser(AbstractUser):
|
|||||||
player_number = models.IntegerField(default=999)
|
player_number = models.IntegerField(default=999)
|
||||||
team = models.ForeignKey('clubs.Team', on_delete=models.SET_NULL, null=True, blank=True, related_name='players')
|
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')
|
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
|
@property
|
||||||
def age(self):
|
def age(self):
|
||||||
@ -16,23 +22,6 @@ class CustomUser(AbstractUser):
|
|||||||
today = datetime.date.today()
|
today = datetime.date.today()
|
||||||
return today.year - self.birth_date.year - ((today.month, today.day) < (self.birth_date.month, self.birth_date.day))
|
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):
|
class AbsencePeriod(models.Model):
|
||||||
user = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name='absence_periods')
|
user = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name='absence_periods')
|
||||||
start_date = models.DateField()
|
start_date = models.DateField()
|
||||||
|
|||||||
15
accounts/templates/accounts/email/parent_verification.html
Normal file
15
accounts/templates/accounts/email/parent_verification.html
Normal 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>
|
||||||
14
accounts/templates/accounts/email/parent_verification.txt
Normal file
14
accounts/templates/accounts/email/parent_verification.txt
Normal 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
|
||||||
15
accounts/templates/accounts/email/player_verification.html
Normal file
15
accounts/templates/accounts/email/player_verification.html
Normal 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>
|
||||||
14
accounts/templates/accounts/email/player_verification.txt
Normal file
14
accounts/templates/accounts/email/player_verification.txt
Normal 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
|
||||||
51
accounts/templates/accounts/verify_account.html
Normal file
51
accounts/templates/accounts/verify_account.html
Normal 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 %}
|
||||||
@ -3,8 +3,7 @@ from . import views
|
|||||||
from django.contrib.auth import views as auth_views
|
from django.contrib.auth import views as auth_views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('invitation/', views.invitation_code_view, name='invitation_code'),
|
path('verify/<uuid:verification_code>/', views.verify_account, name='verify_account'),
|
||||||
path('register/', views.register_view, name='register'),
|
|
||||||
path('login/', views.MyLoginView.as_view(template_name='accounts/login.html'), name='login'),
|
path('login/', views.MyLoginView.as_view(template_name='accounts/login.html'), name='login'),
|
||||||
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
|
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
|
||||||
path('profile/', views.edit_profile, name='edit_profile'),
|
path('profile/', views.edit_profile, name='edit_profile'),
|
||||||
|
|||||||
74
accounts/utils.py
Normal file
74
accounts/utils.py
Normal 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)
|
||||||
@ -3,128 +3,58 @@ from django.urls import reverse_lazy
|
|||||||
from django.views.generic.edit import CreateView
|
from django.views.generic.edit import CreateView
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||||
from django.contrib.auth import views as auth_views
|
from django.contrib.auth import views as auth_views, login
|
||||||
from django.db.models import Q
|
from django.contrib import messages
|
||||||
|
from django.db import Q, IntegrityError
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from .forms import InvitationCodeForm, CustomUserCreationForm, CustomUserChangeForm, PlayerCreationForm
|
from .forms import CustomUserCreationForm, CustomUserChangeForm, PlayerCreationForm, PlayerVerificationForm, ParentVerificationForm
|
||||||
from .models import CustomUser, InvitationCode
|
from .models import CustomUser
|
||||||
|
from .utils import send_verification_email
|
||||||
import uuid
|
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:
|
elif new_email:
|
||||||
parent_user, created = CustomUser.objects.get_or_create(email=new_email, defaults={'username': new_email, 'is_active': False})
|
try:
|
||||||
InvitationCode.objects.create(code=str(uuid.uuid4()), user=parent_user)
|
parent_user = CustomUser.objects.create(
|
||||||
player.parents.add(parent_user)
|
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:
|
if form.errors:
|
||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
...
|
||||||
|
def verify_account(request, verification_code):
|
||||||
|
user = get_object_or_404(CustomUser, verification_code=verification_code, is_verified=False)
|
||||||
|
|
||||||
return redirect(self.success_url)
|
# Determine if user is a parent (has no team) or player
|
||||||
|
is_parent = user.team is None
|
||||||
|
|
||||||
class MyLoginView(auth_views.LoginView):
|
FormClass = ParentVerificationForm if is_parent else PlayerVerificationForm
|
||||||
def get(self, request, *args, **kwargs):
|
|
||||||
if request.user.is_authenticated:
|
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 redirect('dashboard')
|
||||||
return super().get(request, *args, **kwargs)
|
else:
|
||||||
|
form = FormClass()
|
||||||
|
|
||||||
def user_search(request):
|
return render(request, 'accounts/verify_account.html', {'form': form, 'is_parent': is_parent})
|
||||||
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)
|
|
||||||
@ -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 = [
|
||||||
@ -140,3 +141,6 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
|||||||
AUTH_USER_MODEL = 'accounts.CustomUser'
|
AUTH_USER_MODEL = 'accounts.CustomUser'
|
||||||
|
|
||||||
LOGIN_REDIRECT_URL = '/'
|
LOGIN_REDIRECT_URL = '/'
|
||||||
|
|
||||||
|
MTP_EMAIL_SEND = 0
|
||||||
|
DEFAULT_FROM_EMAIL = 'webmaster@localhost'
|
||||||
|
|||||||
@ -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')),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -12,9 +12,14 @@ class EventForm(forms.ModelForm):
|
|||||||
class TrainingForm(forms.ModelForm):
|
class TrainingForm(forms.ModelForm):
|
||||||
start_time = forms.DateTimeField(input_formats=['%d.%m.%Y %H:%M', '%Y-%m-%dT%H:%M'], widget=forms.DateTimeInput(format='%d.%m.%Y %H:%M', attrs={'type': 'datetime-local'}))
|
start_time = forms.DateTimeField(input_formats=['%d.%m.%Y %H:%M', '%Y-%m-%dT%H:%M'], widget=forms.DateTimeInput(format='%d.%m.%Y %H:%M', attrs={'type': 'datetime-local'}))
|
||||||
end_time = forms.DateTimeField(input_formats=['%d.%m.%Y %H:%M', '%Y-%m-%dT%H:%M'], widget=forms.DateTimeInput(format='%d.%m.%Y %H:%M', attrs={'type': 'datetime-local'}), required=False)
|
end_time = forms.DateTimeField(input_formats=['%d.%m.%Y %H:%M', '%Y-%m-%dT%H:%M'], widget=forms.DateTimeInput(format='%d.%m.%Y %H:%M', attrs={'type': 'datetime-local'}), required=False)
|
||||||
|
is_recurring = forms.BooleanField(required=False, widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}))
|
||||||
|
recurrence_interval = forms.IntegerField(required=False, min_value=1, widget=forms.NumberInput(attrs={'class': 'form-control'}), help_text="Repeat every X days.")
|
||||||
|
recurrence_end_date = forms.DateField(required=False, widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Training
|
model = Training
|
||||||
fields = ['title', 'description', 'start_time', 'end_time', 'location_address', 'team']
|
fields = ['title', 'description', 'start_time', 'end_time', 'location_address', 'team', 'is_recurring', 'recurrence_interval', 'recurrence_end_date']
|
||||||
|
|
||||||
|
|
||||||
class GameForm(forms.ModelForm):
|
class GameForm(forms.ModelForm):
|
||||||
start_time = forms.DateTimeField(input_formats=['%d.%m.%Y %H:%M', '%Y-%m-%dT%H:%M'], widget=forms.DateTimeInput(format='%d.%m.%Y %H:%M', attrs={'type': 'datetime-local'}))
|
start_time = forms.DateTimeField(input_formats=['%d.%m.%Y %H:%M', '%Y-%m-%dT%H:%M'], widget=forms.DateTimeInput(format='%d.%m.%Y %H:%M', attrs={'type': 'datetime-local'}))
|
||||||
|
|||||||
@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-11-22 09:00
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('calendars', '0005_game_number_of_innings_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='event',
|
||||||
|
name='is_recurring',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='event',
|
||||||
|
name='parent_event',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_events', to='calendars.event'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='event',
|
||||||
|
name='recurrence_end_date',
|
||||||
|
field=models.DateField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='event',
|
||||||
|
name='recurrence_interval',
|
||||||
|
field=models.PositiveIntegerField(blank=True, help_text='In days', null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -11,6 +11,12 @@ class Event(models.Model):
|
|||||||
maps_shortlink = models.URLField(blank=True, editable=False)
|
maps_shortlink = models.URLField(blank=True, editable=False)
|
||||||
team = models.ForeignKey('clubs.Team', on_delete=models.CASCADE, related_name='events')
|
team = models.ForeignKey('clubs.Team', on_delete=models.CASCADE, related_name='events')
|
||||||
|
|
||||||
|
# Fields for recurring events
|
||||||
|
is_recurring = models.BooleanField(default=False)
|
||||||
|
recurrence_interval = models.PositiveIntegerField(null=True, blank=True, help_text="In days")
|
||||||
|
recurrence_end_date = models.DateField(null=True, blank=True)
|
||||||
|
parent_event = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='child_events')
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.location_address and not self.maps_shortlink:
|
if self.location_address and not self.maps_shortlink:
|
||||||
self.maps_shortlink = f"https://www.google.com/maps/search/?api=1&query={urllib.parse.quote(self.location_address)}"
|
self.maps_shortlink = f"https://www.google.com/maps/search/?api=1&query={urllib.parse.quote(self.location_address)}"
|
||||||
|
|||||||
@ -8,9 +8,28 @@
|
|||||||
<h2>Delete Event</h2>
|
<h2>Delete Event</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p>Are you sure you want to delete "{{ object.title }}"?</p>
|
<p>Are you sure you want to delete the event: <strong>"{{ object.title }}"</strong> on <strong>{{ object.start_time|date:"d.m.Y" }}</strong>?</p>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% if is_series %}
|
||||||
|
<fieldset class="mb-3">
|
||||||
|
<legend class="h6">Deletion Scope</legend>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="delete_scope" id="delete_one" value="one" checked>
|
||||||
|
<label class="form-check-label" for="delete_one">
|
||||||
|
Delete only this event
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="delete_scope" id="delete_all" value="all">
|
||||||
|
<label class="form-check-label" for="delete_all">
|
||||||
|
Delete the entire series
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<button type="submit" class="btn btn-danger">Confirm Delete</button>
|
<button type="submit" class="btn btn-danger">Confirm Delete</button>
|
||||||
<a href="{% url 'dashboard' %}" class="btn btn-secondary">Cancel</a>
|
<a href="{% url 'dashboard' %}" class="btn btn-secondary">Cancel</a>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,40 +1,83 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load l10n %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-md-6">
|
<div class="col-md-8">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2>{% if object %}Edit Event{% else %}Create Event{% endif %}</h2>
|
<h2>{% if object %}Edit Event{% else %}Create Training{% endif %}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
{% for field in form %}
|
<!-- Standard Fields -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">{{ form.title.label_tag }} {{ form.title }}</div>
|
||||||
<label for="{{ field.id_for_label }}" class="form-label">{{ field.label }}</label>
|
<div class="mb-3">{{ form.description.label_tag }} {{ form.description }}</div>
|
||||||
{% if object and field.name == 'start_time' %}
|
<div class="row">
|
||||||
<p>Current: {{ object.start_time|localize }}</p>
|
<div class="col-md-6 mb-3">{{ form.start_time.label_tag }} {{ form.start_time }}</div>
|
||||||
{% endif %}
|
<div class="col-md-6 mb-3">{{ form.end_time.label_tag }} {{ form.end_time }}</div>
|
||||||
{% if object and field.name == 'end_time' %}
|
</div>
|
||||||
<p>Current: {{ object.end_time|localize }}</p>
|
<div class="mb-3">{{ form.location_address.label_tag }} {{ form.location_address }}</div>
|
||||||
{% endif %}
|
<div class="mb-3">{{ form.team.label_tag }} {{ form.team }}</div>
|
||||||
{{ field }}
|
|
||||||
{% if field.help_text %}
|
<!-- Recurrence Fields -->
|
||||||
<small class="form-text text-muted">{{ field.help_text }}</small>
|
{% if 'is_recurring' in form.fields %}
|
||||||
{% endif %}
|
<hr>
|
||||||
{% for error in field.errors %}
|
<div class="form-check mb-3">
|
||||||
<div class="alert alert-danger">{{ error }}</div>
|
{{ form.is_recurring }}
|
||||||
{% endfor %}
|
<label class="form-check-label" for="{{ form.is_recurring.id_for_label }}">{{ form.is_recurring.label }}</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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 %}
|
{% endfor %}
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="{% url 'dashboard' %}" class="btn btn-secondary">Cancel</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block javascript %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const recurringCheckbox = document.getElementById('{{ form.is_recurring.id_for_label }}');
|
||||||
|
const recurrenceOptions = document.getElementById('recurrence-options');
|
||||||
|
|
||||||
|
function toggleRecurrenceOptions() {
|
||||||
|
if (recurringCheckbox.checked) {
|
||||||
|
recurrenceOptions.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
recurrenceOptions.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recurringCheckbox) {
|
||||||
|
// Set initial state on page load
|
||||||
|
toggleRecurrenceOptions();
|
||||||
|
|
||||||
|
// Add event listener
|
||||||
|
recurringCheckbox.addEventListener('change', toggleRecurrenceOptions);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@ -65,6 +65,38 @@ class TrainingCreateView(LoginRequiredMixin, ManageableTeamsMixin, CreateView):
|
|||||||
template_name = 'calendars/event_form.html'
|
template_name = 'calendars/event_form.html'
|
||||||
success_url = reverse_lazy('dashboard')
|
success_url = reverse_lazy('dashboard')
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
is_recurring = form.cleaned_data.get('is_recurring')
|
||||||
|
interval = form.cleaned_data.get('recurrence_interval')
|
||||||
|
end_date = form.cleaned_data.get('recurrence_end_date')
|
||||||
|
|
||||||
|
if is_recurring and interval and end_date:
|
||||||
|
# Save the first event, which will be the parent
|
||||||
|
self.object = form.save()
|
||||||
|
|
||||||
|
# Get details from the parent event
|
||||||
|
start_time = self.object.start_time
|
||||||
|
duration = self.object.end_time - start_time if self.object.end_time else datetime.timedelta(hours=1)
|
||||||
|
|
||||||
|
current_start_time = start_time + datetime.timedelta(days=interval)
|
||||||
|
|
||||||
|
while current_start_time.date() <= end_date:
|
||||||
|
Training.objects.create(
|
||||||
|
title=self.object.title,
|
||||||
|
description=self.object.description,
|
||||||
|
start_time=current_start_time,
|
||||||
|
end_time=current_start_time + duration,
|
||||||
|
location_address=self.object.location_address,
|
||||||
|
team=self.object.team,
|
||||||
|
parent_event=self.object,
|
||||||
|
is_recurring=False # Child events are not themselves recurring
|
||||||
|
)
|
||||||
|
current_start_time += datetime.timedelta(days=interval)
|
||||||
|
|
||||||
|
return redirect(self.get_success_url())
|
||||||
|
else:
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
class GameCreateView(LoginRequiredMixin, ManageableTeamsMixin, CreateView):
|
class GameCreateView(LoginRequiredMixin, ManageableTeamsMixin, CreateView):
|
||||||
model = Game
|
model = Game
|
||||||
form_class = GameForm
|
form_class = GameForm
|
||||||
@ -76,10 +108,20 @@ class EventUpdateView(LoginRequiredMixin, CoachCheckMixin, UpdateView):
|
|||||||
template_name = 'calendars/event_form.html'
|
template_name = 'calendars/event_form.html'
|
||||||
success_url = reverse_lazy('dashboard')
|
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):
|
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
|
return GameForm
|
||||||
if hasattr(self.object, 'training'):
|
if isinstance(self.object, Training):
|
||||||
return TrainingForm
|
return TrainingForm
|
||||||
return EventForm
|
return EventForm
|
||||||
|
|
||||||
@ -88,6 +130,29 @@ class EventDeleteView(LoginRequiredMixin, CoachCheckMixin, DeleteView):
|
|||||||
template_name = 'calendars/event_confirm_delete.html'
|
template_name = 'calendars/event_confirm_delete.html'
|
||||||
success_url = reverse_lazy('dashboard')
|
success_url = reverse_lazy('dashboard')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
# An event is part of a series if it has a parent or is a parent
|
||||||
|
context['is_series'] = self.object.parent_event is not None or self.object.child_events.exists()
|
||||||
|
return context
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
self.object = self.get_object()
|
||||||
|
delete_scope = request.POST.get('delete_scope')
|
||||||
|
|
||||||
|
if delete_scope == 'all':
|
||||||
|
# Identify the parent of the series
|
||||||
|
parent = self.object.parent_event if self.object.parent_event else self.object
|
||||||
|
|
||||||
|
# Delete all children and the parent itself
|
||||||
|
parent.child_events.all().delete()
|
||||||
|
parent.delete()
|
||||||
|
|
||||||
|
return redirect(self.success_url)
|
||||||
|
else:
|
||||||
|
# Default behavior: delete only the single event
|
||||||
|
return super().post(request, *args, **kwargs)
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def manage_participation(request, child_id, event_id, status):
|
def manage_participation(request, child_id, event_id, status):
|
||||||
child = get_object_or_404(CustomUser, id=child_id)
|
child = get_object_or_404(CustomUser, id=child_id)
|
||||||
|
|||||||
63
dashboard/templates/dashboard/past_games.html
Normal file
63
dashboard/templates/dashboard/past_games.html
Normal 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 %}
|
||||||
@ -4,4 +4,5 @@ from . import views
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views.dashboard, name='dashboard'),
|
path('', views.dashboard, name='dashboard'),
|
||||||
path('players/', views.player_list, name='player_list'),
|
path('players/', views.player_list, name='player_list'),
|
||||||
|
path('past-games/', views.past_games, name='past_games'),
|
||||||
]
|
]
|
||||||
@ -1,11 +1,12 @@
|
|||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.contrib.auth.decorators import login_required
|
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 clubs.models import Team
|
||||||
from accounts.models import CustomUser
|
from accounts.models import CustomUser
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
import datetime
|
import datetime
|
||||||
|
from itertools import chain
|
||||||
|
|
||||||
def get_all_child_teams(parent_team):
|
def get_all_child_teams(parent_team):
|
||||||
"""
|
"""
|
||||||
@ -40,7 +41,7 @@ def dashboard(request):
|
|||||||
|
|
||||||
assisted_teams = user.assisted_teams.all()
|
assisted_teams = user.assisted_teams.all()
|
||||||
|
|
||||||
from itertools import chain
|
|
||||||
all_teams = list(set(chain(player_teams, expanded_coached_teams, assisted_teams)))
|
all_teams = list(set(chain(player_teams, expanded_coached_teams, assisted_teams)))
|
||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
@ -87,6 +88,9 @@ def dashboard(request):
|
|||||||
'local_start_time_iso': local_start_time.isoformat()
|
'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
|
# Get children's events
|
||||||
if hasattr(user, 'children'):
|
if hasattr(user, 'children'):
|
||||||
print('haschild')
|
print('haschild')
|
||||||
@ -102,6 +106,9 @@ def dashboard(request):
|
|||||||
for event in child_events:
|
for event in child_events:
|
||||||
participation, created = EventParticipation.objects.get_or_create(user=child, event=event)
|
participation, created = EventParticipation.objects.get_or_create(user=child, event=event)
|
||||||
child_events_list.append({'event': event, 'participation': participation})
|
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})
|
children_events.append({'child': child, 'events': child_events_list})
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
@ -130,3 +137,68 @@ def player_list(request):
|
|||||||
'teams': all_teams
|
'teams': all_teams
|
||||||
}
|
}
|
||||||
return render(request, 'dashboard/player_list.html', context)
|
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
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=5, 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'),
|
||||||
|
]
|
||||||
128
polls/views.py
Normal file
128
polls/views.py
Normal 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'
|
||||||
107
team_stats/templates/team_stats/season_report.html
Normal file
107
team_stats/templates/team_stats/season_report.html
Normal 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>
|
||||||
@ -22,6 +22,11 @@
|
|||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<button type="submit" class="btn btn-primary">Filter</button>
|
<button type="submit" class="btn btn-primary">Filter</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -67,9 +72,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Luck-O-Meter and Inning Heatmap -->
|
<!-- Luck-O-Meter, Supporter Stats -->
|
||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
<div class="col-md-4">
|
<div class="col-md-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">Luck-O-Meter</div>
|
<div class="card-header">Luck-O-Meter</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@ -78,7 +83,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
||||||
<div class="card-header">Inning Heatmap (Runs Scored)</div>
|
<div class="card-header">Inning Heatmap (Runs Scored)</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|||||||
@ -5,4 +5,5 @@ app_name = 'team_stats'
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('team/<int:team_id>/', views.team_statistics, name='team_statistics'),
|
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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -2,13 +2,13 @@ from django.shortcuts import render, get_object_or_404
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.http import HttpResponseForbidden
|
from django.http import HttpResponseForbidden
|
||||||
from clubs.models import Team
|
from clubs.models import Team
|
||||||
from calendars.models import Game
|
from calendars.models import Game, EventParticipation
|
||||||
|
|
||||||
def _calculate_statistics(team, season=None):
|
def _calculate_statistics(team, season=None):
|
||||||
"""
|
"""
|
||||||
Calculates statistics for a given team and season.
|
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:
|
if season:
|
||||||
games_query = games_query.filter(season=season)
|
games_query = games_query.filter(season=season)
|
||||||
|
|
||||||
@ -20,10 +20,25 @@ def _calculate_statistics(team, season=None):
|
|||||||
runs_allowed = 0
|
runs_allowed = 0
|
||||||
inning_runs = {i: 0 for i in range(1, 10)}
|
inning_runs = {i: 0 for i in range(1, 10)}
|
||||||
streak_counter = 0
|
streak_counter = 0
|
||||||
current_streak_type = None
|
|
||||||
last_game_result = None
|
last_game_result = None
|
||||||
|
|
||||||
|
games_with_supporters = 0
|
||||||
|
total_supporter_player_percentage = 0
|
||||||
|
|
||||||
for game in games:
|
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
|
result = game.result
|
||||||
|
|
||||||
sorted_items = sorted(result.inning_results.items(), key=lambda x: int(x[0].split('_')[1]))
|
sorted_items = sorted(result.inning_results.items(), key=lambda x: int(x[0].split('_')[1]))
|
||||||
@ -85,6 +100,8 @@ def _calculate_statistics(team, season=None):
|
|||||||
else:
|
else:
|
||||||
pythagorean_pct = 0
|
pythagorean_pct = 0
|
||||||
|
|
||||||
|
avg_supporter_player_percentage = (total_supporter_player_percentage / games_with_supporters) if games_with_supporters > 0 else 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'team': team,
|
'team': team,
|
||||||
'wins': wins,
|
'wins': wins,
|
||||||
@ -96,6 +113,8 @@ def _calculate_statistics(team, season=None):
|
|||||||
'pythagorean_pct': pythagorean_pct,
|
'pythagorean_pct': pythagorean_pct,
|
||||||
'inning_runs': inning_runs,
|
'inning_runs': inning_runs,
|
||||||
'total_games': total_games,
|
'total_games': total_games,
|
||||||
|
'games_with_supporters': games_with_supporters,
|
||||||
|
'avg_supporter_player_percentage': avg_supporter_player_percentage,
|
||||||
}
|
}
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@ -128,3 +147,50 @@ def team_statistics(request, team_id):
|
|||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'team_stats/team_statistics.html', context)
|
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)
|
||||||
@ -38,6 +38,12 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'edit_profile' %}">Profile</a>
|
<a class="nav-link" href="{% url 'edit_profile' %}">Profile</a>
|
||||||
</li>
|
</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 %}
|
{% 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