Compare commits
No commits in common. "fd47f25c36a67948a038fbd0121fa2e0e885d584" and "c63ad532b583280f8bf2cde969829de45456c73c" have entirely different histories.
fd47f25c36
...
c63ad532b5
@ -1,5 +1,18 @@
|
||||
from django import forms
|
||||
from .models import CustomUser
|
||||
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
|
||||
|
||||
class CustomUserCreationForm(forms.ModelForm):
|
||||
password = forms.CharField(widget=forms.PasswordInput)
|
||||
@ -24,22 +37,3 @@ 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
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
# 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,4 +1,3 @@
|
||||
import uuid
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
@ -9,11 +8,6 @@ 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):
|
||||
@ -22,6 +16,23 @@ 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()
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
<!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>
|
||||
@ -1,14 +0,0 @@
|
||||
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
|
||||
@ -1,15 +0,0 @@
|
||||
<!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>
|
||||
@ -1,14 +0,0 @@
|
||||
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
|
||||
@ -1,51 +0,0 @@
|
||||
{% 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,7 +3,8 @@ from . import views
|
||||
from django.contrib.auth import views as auth_views
|
||||
|
||||
urlpatterns = [
|
||||
path('verify/<uuid:verification_code>/', views.verify_account, name='verify_account'),
|
||||
path('invitation/', views.invitation_code_view, name='invitation_code'),
|
||||
path('register/', views.register_view, name='register'),
|
||||
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'),
|
||||
|
||||
@ -1,74 +0,0 @@
|
||||
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,58 +3,128 @@ 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, login
|
||||
from django.contrib import messages
|
||||
from django.db import Q, IntegrityError
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.db.models import Q
|
||||
from django.http import JsonResponse
|
||||
from .forms import CustomUserCreationForm, CustomUserChangeForm, PlayerCreationForm, PlayerVerificationForm, ParentVerificationForm
|
||||
from .models import CustomUser
|
||||
from .utils import send_verification_email
|
||||
from .forms import InvitationCodeForm, CustomUserCreationForm, CustomUserChangeForm, PlayerCreationForm
|
||||
from .models import CustomUser, InvitationCode
|
||||
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:
|
||||
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.")
|
||||
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)
|
||||
|
||||
if form.errors:
|
||||
return self.form_invalid(form)
|
||||
...
|
||||
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(self.success_url)
|
||||
|
||||
class MyLoginView(auth_views.LoginView):
|
||||
def get(self, request, *args, **kwargs):
|
||||
if request.user.is_authenticated:
|
||||
return redirect('dashboard')
|
||||
else:
|
||||
form = FormClass()
|
||||
|
||||
return render(request, 'accounts/verify_account.html', {'form': form, 'is_parent': is_parent})
|
||||
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)
|
||||
@ -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,7 +42,6 @@ INSTALLED_APPS = [
|
||||
'calendars',
|
||||
'dashboard',
|
||||
'team_stats',
|
||||
'polls',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@ -141,6 +140,3 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
AUTH_USER_MODEL = 'accounts.CustomUser'
|
||||
|
||||
LOGIN_REDIRECT_URL = '/'
|
||||
|
||||
MTP_EMAIL_SEND = 0
|
||||
DEFAULT_FROM_EMAIL = 'webmaster@localhost'
|
||||
|
||||
@ -24,6 +24,5 @@ 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')),
|
||||
]
|
||||
|
||||
@ -12,14 +12,9 @@ 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', 'is_recurring', 'recurrence_interval', 'recurrence_end_date']
|
||||
|
||||
fields = ['title', 'description', 'start_time', 'end_time', 'location_address', 'team']
|
||||
|
||||
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'}))
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-11-22 09:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('calendars', '0005_game_number_of_innings_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='is_recurring',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='parent_event',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_events', to='calendars.event'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='recurrence_end_date',
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='recurrence_interval',
|
||||
field=models.PositiveIntegerField(blank=True, help_text='In days', null=True),
|
||||
),
|
||||
]
|
||||
@ -10,12 +10,6 @@ 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:
|
||||
|
||||
@ -8,28 +8,9 @@
|
||||
<h2>Delete Event</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<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>
|
||||
<p>Are you sure you want to delete "{{ object.title }}"?</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>
|
||||
|
||||
@ -1,83 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
{% load l10n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>{% if object %}Edit Event{% else %}Create Training{% endif %}</h2>
|
||||
<h2>{% if object %}Edit Event{% else %}Create Event{% endif %}</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- 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>
|
||||
{% 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 %}
|
||||
</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 %}
|
||||
|
||||
|
||||
<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 %}
|
||||
|
||||
@ -65,38 +65,6 @@ 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
|
||||
@ -108,20 +76,10 @@ 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):
|
||||
# self.object is now the correct child instance because of the overridden get_object
|
||||
if isinstance(self.object, Game):
|
||||
if hasattr(self.object, 'game'):
|
||||
return GameForm
|
||||
if isinstance(self.object, Training):
|
||||
if hasattr(self.object, 'training'):
|
||||
return TrainingForm
|
||||
return EventForm
|
||||
|
||||
@ -130,29 +88,6 @@ 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)
|
||||
|
||||
@ -1,63 +0,0 @@
|
||||
{% 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,5 +4,4 @@ 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'),
|
||||
]
|
||||
@ -1,12 +1,11 @@
|
||||
from django.shortcuts import render
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from calendars.models import Event, EventParticipation, Game
|
||||
from calendars.models import Event, EventParticipation
|
||||
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):
|
||||
"""
|
||||
@ -41,7 +40,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()
|
||||
@ -87,9 +86,6 @@ 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'):
|
||||
@ -106,9 +102,6 @@ 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 = {
|
||||
@ -137,68 +130,3 @@ 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)
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PollsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'polls'
|
||||
@ -1,23 +0,0 @@
|
||||
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)
|
||||
@ -1,38 +0,0 @@
|
||||
# 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -1,21 +0,0 @@
|
||||
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
|
||||
@ -1,29 +0,0 @@
|
||||
{% 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 %}
|
||||
@ -1,40 +0,0 @@
|
||||
{% 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 %}
|
||||
@ -1,31 +0,0 @@
|
||||
{% 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 %}
|
||||
@ -1,22 +0,0 @@
|
||||
{% 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 %}
|
||||
@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@ -1,12 +0,0 @@
|
||||
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
128
polls/views.py
@ -1,128 +0,0 @@
|
||||
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'
|
||||
@ -1,107 +0,0 @@
|
||||
<!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,11 +22,6 @@
|
||||
<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>
|
||||
@ -72,9 +67,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Luck-O-Meter, Supporter Stats -->
|
||||
<!-- Luck-O-Meter and Inning Heatmap -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">Luck-O-Meter</div>
|
||||
<div class="card-body">
|
||||
@ -83,20 +78,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">Inning Heatmap (Runs Scored)</div>
|
||||
<div class="card-body">
|
||||
|
||||
@ -5,5 +5,4 @@ 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'),
|
||||
]
|
||||
|
||||
@ -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, EventParticipation
|
||||
from calendars.models import Game
|
||||
|
||||
def _calculate_statistics(team, season=None):
|
||||
"""
|
||||
Calculates statistics for a given team and season.
|
||||
"""
|
||||
games_query = Game.objects.filter(team=team, result__isnull=False).prefetch_related('opened_for_teams')
|
||||
games_query = Game.objects.filter(team=team, result__isnull=False)
|
||||
if season:
|
||||
games_query = games_query.filter(season=season)
|
||||
|
||||
@ -20,25 +20,10 @@ 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]))
|
||||
@ -99,8 +84,6 @@ 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,
|
||||
@ -113,8 +96,6 @@ 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
|
||||
@ -147,50 +128,3 @@ 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)
|
||||
@ -38,12 +38,6 @@
|
||||
<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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user