From 48638e76f836726c7eea42317d809723c013ea91 Mon Sep 17 00:00:00 2001
From: Raz
Date: Fri, 28 Mar 2025 12:12:33 +0100
Subject: [PATCH 01/87] wip
---
...yerregistration_payment_status_and_more.py | 28 +++
tournaments/models/player_registration.py | 16 +-
tournaments/models/team_registration.py | 38 ++++-
tournaments/models/tournament.py | 161 +++++++++++++++++-
tournaments/services/email_service.py | 56 ++++--
tournaments/signals.py | 23 ++-
.../tournaments/tournament_info.html | 33 ++++
tournaments/urls.py | 1 +
tournaments/views.py | 28 +++
9 files changed, 365 insertions(+), 19 deletions(-)
create mode 100644 tournaments/migrations/0113_playerregistration_payment_status_and_more.py
diff --git a/tournaments/migrations/0113_playerregistration_payment_status_and_more.py b/tournaments/migrations/0113_playerregistration_payment_status_and_more.py
new file mode 100644
index 0000000..edd28b1
--- /dev/null
+++ b/tournaments/migrations/0113_playerregistration_payment_status_and_more.py
@@ -0,0 +1,28 @@
+# Generated by Django 5.1 on 2025-03-28 07:54
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tournaments', '0112_tournament_disable_ranking_federal_ruling_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='playerregistration',
+ name='payment_status',
+ field=models.CharField(choices=[('UNPAID', 'Unpaid'), ('PAID', 'Paid'), ('FAILED', 'Failed')], default='UNPAID', max_length=20),
+ ),
+ migrations.AddField(
+ model_name='playerregistration',
+ name='registration_status',
+ field=models.CharField(choices=[('PENDING', 'Pending'), ('PAID', 'Paid'), ('CANCELED', 'Canceled')], default='PENDING', max_length=20),
+ ),
+ migrations.AddField(
+ model_name='playerregistration',
+ name='time_to_confirm',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ ]
diff --git a/tournaments/models/player_registration.py b/tournaments/models/player_registration.py
index e23be85..c855053 100644
--- a/tournaments/models/player_registration.py
+++ b/tournaments/models/player_registration.py
@@ -1,8 +1,15 @@
from django.db import models
-from . import SideStoreModel, TeamRegistration, PlayerSexType, PlayerDataSource, PlayerPaymentType, FederalCategory
+from . import SideStoreModel, TeamRegistration, PlayerSexType, PlayerDataSource, PlayerPaymentType
import uuid
from django.utils import timezone
+class RegistrationStatus(models.TextChoices):
+ PENDING = 'PENDING', 'Pending'
+ CONFIRMED = 'CONFIRMED', 'Confirmed'
+ PAID = 'PAID', 'Paid'
+ CANCELED = 'CANCELED', 'Canceled'
+
+
class PlayerRegistration(SideStoreModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
team_registration = models.ForeignKey(TeamRegistration, on_delete=models.SET_NULL, related_name='player_registrations', null=True)
@@ -37,6 +44,13 @@ class PlayerRegistration(SideStoreModel):
captain = models.BooleanField(default=False)
coach = models.BooleanField(default=False)
registered_online = models.BooleanField(default=False)
+ time_to_confirm = models.DateTimeField(null=True, blank=True)
+ registration_status = models.CharField(max_length=20, choices=RegistrationStatus.choices, default=RegistrationStatus.PENDING)
+ payment_status = models.CharField(max_length=20, default='UNPAID', choices=[
+ ('UNPAID', 'Unpaid'),
+ ('PAID', 'Paid'),
+ ('FAILED', 'Failed'),
+ ])
def delete_dependencies(self):
pass
diff --git a/tournaments/models/team_registration.py b/tournaments/models/team_registration.py
index 5d468d1..c841b4d 100644
--- a/tournaments/models/team_registration.py
+++ b/tournaments/models/team_registration.py
@@ -1,9 +1,7 @@
from django.db import models
-from django.db.models.sql.query import Q
from . import SideStoreModel, Tournament, GroupStage, Match
import uuid
from django.utils import timezone
-from django.db.models import Count
class TeamRegistration(SideStoreModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
@@ -300,3 +298,39 @@ class TeamRegistration(SideStoreModel):
if self.wild_card_group_stage:
return "(wildcard poule)"
return ""
+
+ def set_time_to_confirm(self, ttc):
+ from .player_registration import RegistrationStatus
+ for p in self.player_registrations.all():
+ if p.registered_online:
+ p.time_to_confirm = ttc
+ p.registration_status = RegistrationStatus.PENDING
+ p.save()
+
+ def needs_confirmation(self):
+ from .player_registration import RegistrationStatus
+ """Check if this team needs to confirm their registration"""
+ # Check if any player has status PENDING and is registered online
+ return any(p.registration_status == RegistrationStatus.PENDING and p.registered_online
+ for p in self.player_registrations.all())
+
+ def get_confirmation_deadline(self):
+ """Get the confirmation deadline for this team"""
+ deadlines = [p.time_to_confirm for p in self.player_registrations.all() if p.time_to_confirm is not None]
+ return max(deadlines) if deadlines else None
+
+ def confirm_registration(self):
+ """Confirm the team's registration after being moved from waiting list"""
+ now = timezone.now()
+ from .player_registration import RegistrationStatus
+
+ # Update all players in the team
+ for player in self.player_registrations.all():
+ if player.time_to_confirm is not None:
+ player.time_to_confirm = None
+ player.registration_status = RegistrationStatus.CONFIRMED
+ player.save()
+
+ # Save confirmation date
+ self.confirmation_date = now
+ self.save()
diff --git a/tournaments/models/tournament.py b/tournaments/models/tournament.py
index 24af6d5..691c163 100644
--- a/tournaments/models/tournament.py
+++ b/tournaments/models/tournament.py
@@ -1290,12 +1290,20 @@ class Tournament(BaseModel):
def min_player_rank(self):
return FederalLevelCategory.min_player_rank(self.federal_level_category, self.federal_category, self.federal_age_category)
- def first_waiting_list_team(self, teams):
+ def waiting_list_teams(self, teams):
if len(teams)<=self.team_count:
return None
waiting_teams = [team for team in teams if team.stage == "Attente"]
- if len(waiting_teams) > 0:
- return waiting_teams[0].team_registration
+ return waiting_teams
+
+ def first_waiting_list_team(self, teams):
+ waiting_list_team = self.waiting_list_teams(teams)
+ if waiting_list_team is None:
+ return None
+ if len(waiting_list_team) > 0:
+ return waiting_list_team[0].team_registration
+ else:
+ return None
def broadcasted_prog(self):
# Get matches from broadcasted_matches_and_group_stages
@@ -1434,6 +1442,153 @@ class Tournament(BaseModel):
return self.umpire_custom_phone
return self.event.creator.phone
+ def calculate_time_to_confirm(self, waiting_list_count):
+ """
+ Calculate the time a team has to confirm their registration
+ based on tournament proximity, waiting list pressure, and business hours.
+
+ Args:
+ tournament: The Tournament instance
+ waiting_list_count: Waiting List count
+
+ Returns:
+ datetime: The confirmation deadline datetime
+ """
+ # Skip if feature not enabled
+ # if not tournament.has_time_to_confirm:
+ # return None
+
+ if waiting_list_count <= 1:
+ return None
+
+ # Configuration rules
+ TIME_PROXIMITY_RULES = {
+ 48: 30, # within 48h → 30 min
+ 24: 20, # within 24h → 20 min
+ 72: 60, # within 72h → 60 min
+ "default": 120
+ }
+
+ WAITING_LIST_RULES = {
+ 15: 15, # 15+ teams → 15 min
+ 10: 30, # 10+ teams → 30 min
+ 5: 60, # 5+ teams → 60 min
+ "default": 120
+ }
+
+ BUSINESS_RULES = {
+ "hours": {
+ "start": 8, # 8:00
+ "end": 20, # 20:00
+ "default_confirmation_hour": 10 # When extending to next day
+ },
+ "days": {
+ "working_days": [0, 1, 2, 3, 4, 5, 6], # Monday = 0, Friday = 4
+ "weekend": [] # Saturday = 5, Sunday = 6
+ }
+ }
+
+ URGENCY_OVERRIDE = {
+ "thresholds": {
+ 24: True, # If ≤ 24h until tournament: ignore business hours
+ 12: True # If ≤ 12h until tournament: ignore all restrictions
+ },
+ "minimum_response_time": 15 # minutes
+ }
+
+ # 1. Get current time in tournament's timezone
+ current_time = timezone.now()
+ current_time = current_time.astimezone(self.timezone())
+ tournament_start_date = self.local_start_date()
+
+ # 2. Calculate tournament proximity (hours until tournament starts)
+ hours_until_tournament = (tournament_start_date - current_time).total_seconds() / 3600
+
+ # 3. Calculate waiting list pressure
+
+ # teams = self.teams(True)
+ # waiting_teams = self.waiting_list_team(teams)
+ # if waiting_teams is None:
+ # return None
+
+ # waiting_list_count = len(waiting_teams)
+
+ # 4. Determine base minutes to confirm based on time proximity
+ time_based_minutes = TIME_PROXIMITY_RULES["default"]
+ for hours_threshold, minutes in TIME_PROXIMITY_RULES.items():
+ if hours_threshold != "default" and hours_until_tournament <= hours_threshold:
+ time_based_minutes = minutes
+ break
+
+ # 5. Determine waiting list based minutes
+ waitlist_based_minutes = WAITING_LIST_RULES["default"]
+ for teams_threshold, minutes in WAITING_LIST_RULES.items():
+ if teams_threshold != "default" and waiting_list_count >= teams_threshold:
+ waitlist_based_minutes = minutes
+ break
+
+ # 6. Use the more restrictive rule (smaller time window)
+ minutes_to_confirm = min(time_based_minutes, waitlist_based_minutes)
+
+ # 7. Check urgency overrides
+ apply_business_rules = True
+ for hours_threshold, override in URGENCY_OVERRIDE["thresholds"].items():
+ if hours_until_tournament <= hours_threshold:
+ apply_business_rules = False
+ # Ensure minimum response time
+ minutes_to_confirm = max(minutes_to_confirm, URGENCY_OVERRIDE["minimum_response_time"])
+ break
+
+ # 8. Calculate raw deadline
+ raw_deadline = current_time + timezone.timedelta(minutes=minutes_to_confirm)
+
+ # 9. Round up to next 30-minute mark
+ minute = raw_deadline.minute
+ if minute % 30 != 0:
+ # Minutes to next 30-minute mark
+ minutes_to_add = 30 - (minute % 30)
+ raw_deadline += timezone.timedelta(minutes=minutes_to_add)
+
+ # 10. Apply business hours rules if needed
+ if apply_business_rules:
+ # Check if deadline falls outside business hours
+ is_weekend = raw_deadline.weekday() in BUSINESS_RULES["days"]["weekend"]
+ before_hours = raw_deadline.hour < BUSINESS_RULES["hours"]["start"]
+ after_hours = raw_deadline.hour >= BUSINESS_RULES["hours"]["end"]
+
+ if is_weekend or before_hours or after_hours:
+ # Extend to next business day
+ if after_hours or is_weekend:
+ # Move to next day
+ days_to_add = 1
+ if is_weekend:
+ # If Saturday, move to Monday
+ if raw_deadline.weekday() == 5: # Saturday
+ days_to_add = 2
+ # If Sunday, move to Monday
+ elif raw_deadline.weekday() == 6: # Sunday
+ days_to_add = 1
+
+ raw_deadline += timezone.timedelta(days=days_to_add)
+
+ # Set to business start hour
+ raw_deadline = raw_deadline.replace(
+ hour=BUSINESS_RULES["hours"]["default_confirmation_hour"],
+ minute=0,
+ second=0,
+ microsecond=0
+ )
+
+ print(f"Current time: {current_time}")
+ print(f"Minutes to confirm: {minutes_to_confirm}")
+ print(f"Raw deadline before rounding: {current_time + timezone.timedelta(minutes=minutes_to_confirm)}")
+ print(f"Raw deadline after rounding: {raw_deadline}")
+ print(f"Is weekend: {is_weekend}, Before hours: {before_hours}, After hours: {after_hours}")
+ print(f"Apply business rules: {apply_business_rules}")
+
+ return raw_deadline
+
+
class MatchGroup:
def __init__(self, name, matches, formatted_schedule, round_id=None):
diff --git a/tournaments/services/email_service.py b/tournaments/services/email_service.py
index 6a3a59d..d01d2b6 100644
--- a/tournaments/services/email_service.py
+++ b/tournaments/services/email_service.py
@@ -2,6 +2,7 @@ from django.core.mail import EmailMessage
from django.utils import timezone
from django.urls import reverse
from enum import Enum
+from ..models.player_registration import RegistrationStatus
class TeamEmailType(Enum):
REGISTERED = "registered"
@@ -137,11 +138,8 @@ class TournamentEmailService:
f"\nVoici le partenaire indiqué dans l'inscription : {other_player.name()}, n'oubliez pas de le prévenir."
)
- body_parts.append(
- "\n\nSi vous n'êtes plus disponible pour participer à ce tournoi, cliquez sur ce lien ou contactez rapidement le juge-arbitre."
- f"\n{absolute_url}"
- "\nPour vous désinscrire en ligne vous devez avoir un compte Padel Club."
- )
+ confirmation_message = TournamentEmailService._build_confirmation_message(captain, tournament, absolute_url)
+ body_parts.append(confirmation_message)
body_parts.extend([
f"\n\n{TournamentEmailService._format_umpire_contact(tournament)}",
@@ -186,11 +184,8 @@ class TournamentEmailService:
f"\nVoici le partenaire indiqué dans l'inscription : {other_player.name()}, n'oubliez pas de le prévenir."
)
- body_parts.append(
- "\n\nSi vous n'êtes plus disponible pour participer à ce tournoi, cliquez sur ce lien ou contactez rapidement le juge-arbitre."
- f"\n{absolute_url}"
- "\nPour vous désinscrire en ligne vous devez avoir un compte Padel Club."
- )
+ confirmation_message = TournamentEmailService._build_confirmation_message(captain, tournament, absolute_url)
+ body_parts.append(confirmation_message)
body_parts.extend([
f"\n\n{TournamentEmailService._format_umpire_contact(tournament)}",
@@ -270,6 +265,9 @@ class TournamentEmailService:
f"\nVous étiez inscrit avec {other_player.name()}, n'oubliez pas de prévenir votre partenaire."
)
+ confirmation_message = TournamentEmailService._build_confirmation_message(captain, tournament, absolute_url)
+ body_parts.append(confirmation_message)
+
body_parts.extend([
"\n\nPour toute question, veuillez contacter votre juge-arbitre : ",
f"\n{TournamentEmailService._format_umpire_contact(tournament)}",
@@ -348,6 +346,44 @@ class TournamentEmailService:
return "\n".join(contact_parts)
+ @staticmethod
+ def _build_confirmation_message(captain, tournament, absolute_url):
+ """
+ Build a standardized confirmation message for emails.
+
+ Args:
+ captain: The player (captain) receiving the email
+ tournament: The tournament
+ absolute_url: The URL for confirmation/unregistration
+
+ Returns:
+ str: Formatted confirmation message
+ """
+ time_to_confirm = getattr(captain, 'time_to_confirm', None)
+
+ # Common URL and account info text
+ account_info = "\nVous devez avoir un compte Padel Club."
+ url_info = f"\n{absolute_url}"
+
+ # Base message varies based on whether confirmation is needed
+ if time_to_confirm is not None:
+ # Format the deadline time with proper timezone
+ deadline_str = time_to_confirm.astimezone(tournament.timezone()).strftime("%d/%m/%Y à %H:%M (%Z)")
+
+ # Confirmation required message
+ action_text = "Pour confirmer votre participation au tournoi, cliquez sur ce lien ou contactez rapidement le juge-arbitre."
+ warning_text = f"⚠️ ATTENTION : Vous avez jusqu'au {deadline_str} pour confirmer votre participation. Passé ce délai, votre place sera automatiquement proposée à l'équipe suivante sur liste d'attente.\n\n"
+ elif captain.registration_status == RegistrationStatus.PENDING:
+ action_text = "Pour confirmer votre participation au tournoi, cliquez sur ce lien ou contactez rapidement le juge-arbitre."
+ warning_text = ""
+ else:
+ # Standard message for teams already confirmed
+ action_text = "Si vous n'êtes plus disponible pour participer à ce tournoi, cliquez sur ce lien ou contactez rapidement le juge-arbitre."
+ warning_text = ""
+
+ # Construct the complete message
+ return f"\n\n{warning_text}{action_text}{url_info}{account_info}"
+
@staticmethod
def notify(captain, other_player, tournament, message_type: TeamEmailType):
print("TournamentEmailService.notify", captain.email, captain.registered_online, tournament, message_type)
diff --git a/tournaments/signals.py b/tournaments/signals.py
index 48859cb..8950193 100644
--- a/tournaments/signals.py
+++ b/tournaments/signals.py
@@ -82,6 +82,10 @@ def unregister_team(sender, instance, **kwargs):
teams = instance.tournament.teams(True)
first_waiting_list_team = instance.tournament.first_waiting_list_team(teams)
if first_waiting_list_team and first_waiting_list_team.id != instance.id:
+ waiting_list_teams = instance.tournament.waiting_list_teams(teams)
+ if waiting_list_teams is not None:
+ ttc = instance.tournament.calculate_time_to_confirm(len(waiting_list_teams))
+ first_waiting_list_team.set_time_to_confirm(ttc)
notify_team(first_waiting_list_team, instance.tournament, TeamEmailType.OUT_OF_WAITING_LIST)
@receiver(post_save, sender=Tournament)
@@ -113,10 +117,9 @@ def check_waiting_list(sender, instance, **kwargs):
teams_out_to_warn = []
teams_in_to_warn = []
-
+ previous_state_teams = previous_state.teams(True)
if previous_state.team_count > instance.team_count:
teams_to_remove_count = previous_state.team_count - instance.team_count
- previous_state_teams = previous_state.teams(True)
sorted_teams = sorted(
[team for team in previous_state_teams if team.stage != "Attente" and not (team.wildcard_bracket or team.wildcard_groupstage)],
key=lambda t: (
@@ -126,12 +129,17 @@ def check_waiting_list(sender, instance, **kwargs):
)
teams_out_to_warn = sorted_teams[-teams_to_remove_count:]
elif previous_state.team_count < instance.team_count:
+ slice_start = instance.team_count - previous_state.team_count
teams_in_to_warn = [
- team for team in previous_state.teams(True)[(instance.team_count - previous_state.team_count):]
+ team for team in previous_state_teams[slice_start:]
if team.stage == "Attente"
]
+ waiting_list_teams = previous_state.waiting_list_teams(previous_state_teams)
for team in teams_in_to_warn:
+ if waiting_list_teams is not None:
+ ttc = previous_state.calculate_time_to_confirm(len(waiting_list_teams))
+ team.team_registration.set_time_to_confirm(ttc)
notify_team(team.team_registration, instance, TeamEmailType.IN_TOURNAMENT_STRUCTURE)
for team in teams_out_to_warn:
@@ -164,6 +172,10 @@ def warn_team_walkout_status_change(sender, instance, **kwargs):
if not instance.out_of_tournament() and is_out and (previous_instance.out_of_tournament() or not was_out):
notify_team(instance, instance.tournament, TeamEmailType.OUT_OF_WALKOUT_WAITING_LIST)
elif was_out and not is_out:
+ waiting_list_teams = instance.tournament.waiting_list_teams(previous_teams)
+ if waiting_list_teams is not None:
+ ttc = instance.tournament.calculate_time_to_confirm(len(waiting_list_teams))
+ instance.set_time_to_confirm(ttc)
notify_team(instance, instance.tournament, TeamEmailType.OUT_OF_WALKOUT_IS_IN)
elif not previous_instance.out_of_tournament() and instance.out_of_tournament():
notify_team(instance, instance.tournament, TeamEmailType.WALKOUT)
@@ -175,4 +187,9 @@ def warn_team_walkout_status_change(sender, instance, **kwargs):
elif not was_out and is_out:
first_waiting_list_team = instance.tournament.first_waiting_list_team(previous_teams)
if first_waiting_list_team:
+ waiting_list_teams = instance.tournament.waiting_list_teams(previous_teams)
+ if waiting_list_teams is not None:
+ ttc = instance.tournament.calculate_time_to_confirm(len(waiting_list_teams))
+ first_waiting_list_team.set_time_to_confirm(ttc)
+
notify_team(first_waiting_list_team, instance.tournament, TeamEmailType.OUT_OF_WAITING_LIST)
diff --git a/tournaments/templates/tournaments/tournament_info.html b/tournaments/templates/tournaments/tournament_info.html
index 9c93a55..7699b56 100644
--- a/tournaments/templates/tournaments/tournament_info.html
+++ b/tournaments/templates/tournaments/tournament_info.html
@@ -44,6 +44,39 @@
Inscrits le {{ team.local_registration_date }}
+ {% if team and team.needs_confirmation %}
+
+
Confirmation requise
+
Votre place dans le tournoi a été libérée suite à une désinscription.
+
+ {% if team.get_confirmation_deadline %}
+ {% timezone tournament.timezone %}
+ {% with time_now=now time_to_confirm=team.get_confirmation_deadline %}
+ {% if time_to_confirm > time_now %}
+
Vous devez confirmer votre participation avant le:
+
{{ time_to_confirm|date:"d/m/Y à H:i" }}
+
+ {% with time_diff=time_to_confirm|timeuntil:time_now %}
+
Temps restant: {{ time_diff }}
+ {% endwith %}
+ {% else %}
+
+ Le délai de confirmation est expiré. Votre place a été proposée à une autre équipe.
+
+ {% endif %}
+ {% endwith %}
+ {% endtimezone %}
+ {% endif %}
+
+
+
+ {% endif %}
+
{% if tournament.is_unregistration_possible %}
diff --git a/tournaments/urls.py b/tournaments/urls.py
index f544567..c951d80 100644
--- a/tournaments/urls.py
+++ b/tournaments/urls.py
@@ -77,4 +77,5 @@ urlpatterns = [
path('admin/users-export/', views.UserListExportView.as_view(), name='users_export'),
path('activation-success/', views.activation_success, name='activation_success'),
path('activation-failed/', views.activation_failed, name='activation_failed'),
+ path('tournaments//confirm/', views.confirm_tournament_registration, name='confirm_tournament_registration'),
]
diff --git a/tournaments/views.py b/tournaments/views.py
index a3dfa5f..66680b2 100644
--- a/tournaments/views.py
+++ b/tournaments/views.py
@@ -1090,6 +1090,34 @@ def custom_logout(request):
logout(request)
return redirect('index') # or whatever URL you want to redirect to
+@login_required
+def confirm_tournament_registration(request, tournament_id):
+ if request.method == 'POST':
+ tournament = get_object_or_404(Tournament, pk=tournament_id)
+ # Find the team registration for this user
+ user_licence_id = request.user.licence_id
+ if user_licence_id is not None:
+ validator = LicenseValidator(user_licence_id)
+ stripped_license = validator.stripped_license
+ # Check if there is a PlayerRegistration for this user in this tournament
+ team_registrations = TeamRegistration.objects.filter(
+ tournament=tournament,
+ player_registrations__licence_id__icontains=stripped_license,
+ )
+
+ if not team_registrations.exists():
+ messages.error(request, "Aucune inscription trouvée pour ce tournoi.")
+ return redirect('tournament_info', tournament_id=tournament_id)
+
+ team_registration = team_registrations.first()
+ # Confirm registration
+ team_registration.confirm_registration()
+
+ messages.success(request, "Votre participation est confirmée !")
+ return redirect('tournament_info', tournament_id=tournament_id)
+
+ return redirect('tournament_info', tournament_id=tournament_id)
+
class UserListExportView(LoginRequiredMixin, View):
def get(self, request, *args, **kwargs):
users = CustomUser.objects.order_by('date_joined')
From dc5a033e63b494e9b189d47ab3ba42448beb87d7 Mon Sep 17 00:00:00 2001
From: Raz
Date: Sat, 29 Mar 2025 06:36:14 +0100
Subject: [PATCH 02/87] handle tasks
---
padelclub_backend/settings.py | 2 +
tournaments/apps.py | 16 ++++
.../management/commands/check_deadlines.py | 10 +++
.../management/commands/schedule_tasks.py | 63 +++++++++++++
tournaments/models/player_registration.py | 3 +-
tournaments/models/tournament.py | 68 ++++++++++++--
tournaments/services/email_service.py | 8 ++
tournaments/tasks.py | 89 +++++++++++++++++++
.../tournaments/tournament_info.html | 17 ++--
tournaments/urls.py | 11 ++-
10 files changed, 269 insertions(+), 18 deletions(-)
create mode 100644 tournaments/management/commands/check_deadlines.py
create mode 100644 tournaments/management/commands/schedule_tasks.py
create mode 100644 tournaments/tasks.py
diff --git a/padelclub_backend/settings.py b/padelclub_backend/settings.py
index 87ad1e0..1e95c73 100644
--- a/padelclub_backend/settings.py
+++ b/padelclub_backend/settings.py
@@ -49,6 +49,8 @@ INSTALLED_APPS = [
'qr_code',
'channels_redis',
'django_filters',
+ 'background_task',
+
]
AUTH_USER_MODEL = "tournaments.CustomUser"
diff --git a/tournaments/apps.py b/tournaments/apps.py
index 1c6c698..c904e83 100644
--- a/tournaments/apps.py
+++ b/tournaments/apps.py
@@ -6,3 +6,19 @@ class TournamentsConfig(AppConfig):
def ready(self):
import tournaments.signals # This will ensure the signals are registered
+ # Start background tasks in production
+ # Schedule background tasks on startup
+ from django.conf import settings
+ if not settings.DEBUG: # Only in production
+ try:
+ from background_task.models import Task
+ from tournaments.tasks import check_confirmation_deadlines
+
+ # Clear existing tasks first to avoid duplicates
+ Task.objects.filter(task_name='tournaments.tasks.check_confirmation_deadlines').delete()
+
+ # Schedule the task to run every 30 minutes (1800 seconds)
+ check_confirmation_deadlines(repeat=1800)
+ except:
+ # Handle exceptions during startup
+ pass
diff --git a/tournaments/management/commands/check_deadlines.py b/tournaments/management/commands/check_deadlines.py
new file mode 100644
index 0000000..e828846
--- /dev/null
+++ b/tournaments/management/commands/check_deadlines.py
@@ -0,0 +1,10 @@
+from django.core.management.base import BaseCommand
+from tournaments.tasks import check_confirmation_deadlines
+
+class Command(BaseCommand):
+ help = 'Run confirmation deadline check immediately'
+
+ def handle(self, *args, **options):
+ # Run the function directly (not through the task queue)
+ check_confirmation_deadlines(schedule=0)
+ self.stdout.write(self.style.SUCCESS('Successfully checked confirmation deadlines'))
diff --git a/tournaments/management/commands/schedule_tasks.py b/tournaments/management/commands/schedule_tasks.py
new file mode 100644
index 0000000..a1d241f
--- /dev/null
+++ b/tournaments/management/commands/schedule_tasks.py
@@ -0,0 +1,63 @@
+from django.core.management.base import BaseCommand
+from tournaments.tasks import check_confirmation_deadlines
+from django.utils import timezone
+import datetime
+import pytz
+
+class Command(BaseCommand):
+ help = 'Schedule background tasks to run at :00 and :30 of every hour'
+
+ def handle(self, *args, **options):
+ # Clear existing tasks first to avoid duplicates
+ from background_task.models import Task
+ Task.objects.filter(task_name='tournaments.tasks.check_confirmation_deadlines').delete()
+
+ # Get the current timezone-aware time
+ now = timezone.now()
+
+ # Get local timezone for display purposes
+ local_timezone = timezone.get_current_timezone()
+ local_now = now.astimezone(local_timezone)
+
+ # Calculate time until next half-hour mark (either :00 or :30)
+ current_minute = local_now.minute
+
+ if current_minute < 30:
+ # Next run at XX:30:00
+ next_minute = 30
+ next_hour = local_now.hour
+ else:
+ # Next run at (XX+1):00:00
+ next_minute = 0
+ next_hour = local_now.hour + 1
+
+ # Create a datetime with exactly XX:30:00 or XX:00:00 in local time
+ first_run_local = local_now.replace(
+ hour=next_hour,
+ minute=next_minute + 1, #let the expiration time be off first
+ second=0,
+ microsecond=0
+ )
+
+ # Handle day rollover if needed
+ if first_run_local < local_now: # This would happen if we crossed midnight
+ first_run_local += datetime.timedelta(days=1)
+
+ # Calculate seconds from now until the first run
+ seconds_until_first_run = (first_run_local - local_now).total_seconds()
+ if seconds_until_first_run < 0:
+ seconds_until_first_run = 0 # If somehow negative, run immediately
+
+ # Schedule with seconds delay instead of a specific datetime
+ check_confirmation_deadlines(
+ schedule=int(seconds_until_first_run), # Delay in seconds before first run
+ repeat=1800 # 30 minutes in seconds
+ )
+
+ # Show the message with proper timezone info
+ local_timezone_name = local_timezone.tzname(local_now)
+ self.stdout.write(self.style.SUCCESS(
+ f'Task scheduled to first run at {first_run_local.strftime("%H:%M:%S")} {local_timezone_name} '
+ f'(in {int(seconds_until_first_run)} seconds) '
+ f'and then every 30 minutes'
+ ))
diff --git a/tournaments/models/player_registration.py b/tournaments/models/player_registration.py
index c855053..4a9c340 100644
--- a/tournaments/models/player_registration.py
+++ b/tournaments/models/player_registration.py
@@ -4,6 +4,7 @@ import uuid
from django.utils import timezone
class RegistrationStatus(models.TextChoices):
+ WAITING = 'WAITING', 'Waiting'
PENDING = 'PENDING', 'Pending'
CONFIRMED = 'CONFIRMED', 'Confirmed'
PAID = 'PAID', 'Paid'
@@ -45,7 +46,7 @@ class PlayerRegistration(SideStoreModel):
coach = models.BooleanField(default=False)
registered_online = models.BooleanField(default=False)
time_to_confirm = models.DateTimeField(null=True, blank=True)
- registration_status = models.CharField(max_length=20, choices=RegistrationStatus.choices, default=RegistrationStatus.PENDING)
+ registration_status = models.CharField(max_length=20, choices=RegistrationStatus.choices, default=RegistrationStatus.WAITING)
payment_status = models.CharField(max_length=20, default='UNPAID', choices=[
('UNPAID', 'Unpaid'),
('PAID', 'Paid'),
diff --git a/tournaments/models/tournament.py b/tournaments/models/tournament.py
index 691c163..8da031e 100644
--- a/tournaments/models/tournament.py
+++ b/tournaments/models/tournament.py
@@ -325,6 +325,7 @@ class Tournament(BaseModel):
# Use the teams method to get sorted list of teams
all_teams = self.teams(True)
index = -1
+ print(all_teams)
# Find position of team in all teams list
for i, team in enumerate(all_teams):
if team.team_registration.id == team_registration.id:
@@ -397,7 +398,7 @@ class Tournament(BaseModel):
complete_teams.append(team)
else:
waiting_teams.append(team)
- wildcard_bracket = []
+
return complete_teams, wildcard_bracket, wildcard_group_stage, waiting_teams
def sort_teams(self, include_waiting_list, complete_teams, wildcard_bracket, wildcard_group_stage, waiting_teams):
@@ -1062,6 +1063,8 @@ class Tournament(BaseModel):
date = formats.date_format(timezone.localtime(self.registration_date_limit), format='j F Y H:i')
options.append(f"Clôture des inscriptions le {date}")
+ options.append(self.get_selection_status_localized)
+
# Cible d'équipes
if self.team_count:
options.append(f"Maximum {self.team_count} équipes")
@@ -1122,11 +1125,17 @@ class Tournament(BaseModel):
return False
return True
+ def get_selection_status_localized(self):
+ if self.team_sorting == TeamSortingType.RANK:
+ return "La sélection se fait par le poids de l'équipe"
+ else:
+ return "La sélection se fait par date d'inscription"
+
def get_online_registration_status(self):
if self.supposedly_in_progress():
return OnlineRegistrationStatus.ENDED
if self.closed_registration_date is not None:
- return OnlineRegistrationStatus.ENDED
+ return OnlineRegistrationStatus.WAITING_LIST_POSSIBLE
if self.end_date is not None:
return OnlineRegistrationStatus.ENDED_WITH_RESULTS
@@ -1140,7 +1149,10 @@ class Tournament(BaseModel):
if self.registration_date_limit is not None:
timezoned_datetime = timezone.localtime(self.registration_date_limit)
if now > timezoned_datetime:
- return OnlineRegistrationStatus.ENDED
+ return OnlineRegistrationStatus.WAITING_LIST_POSSIBLE
+
+ if self.team_sorting == TeamSortingType.RANK:
+ return OnlineRegistrationStatus.OPEN
if self.team_count is not None:
# Get all team registrations excluding walk_outs
@@ -1174,6 +1186,15 @@ class Tournament(BaseModel):
return True
def get_waiting_list_position(self):
+ current_time = timezone.now()
+ current_time = current_time.astimezone(self.timezone())
+ local_registration_federal_limit = self.local_registration_federal_limit()
+ if self.team_sorting == TeamSortingType.RANK and local_registration_federal_limit is not None:
+ if current_time < local_registration_federal_limit:
+ return -1
+ else:
+ return 0
+
# If no target team count exists, no one goes to waiting list
if self.team_count is None:
return -1
@@ -1290,7 +1311,37 @@ class Tournament(BaseModel):
def min_player_rank(self):
return FederalLevelCategory.min_player_rank(self.federal_level_category, self.federal_category, self.federal_age_category)
+ def local_registration_federal_limit(self):
+ if self.registration_date_limit is not None:
+ return self.registration_date_limit
+
+ if self.closed_registration_date is not None:
+ return self.closed_registration_date
+
+ local_start_date = self.local_start_date()
+
+ if local_start_date is None:
+ return None
+
+ if self.federal_level_category == FederalLevelCategory.P500:
+ # 7 days before at 23:59
+ return (local_start_date - timedelta(days=7)).replace(hour=23, minute=59, second=59)
+ elif self.federal_level_category in [FederalLevelCategory.P1000,
+ FederalLevelCategory.P1500,
+ FederalLevelCategory.P2000]:
+ # 14 days before at 23:59
+ return (local_start_date - timedelta(days=14)).replace(hour=23, minute=59, second=59)
+ return None
+
def waiting_list_teams(self, teams):
+ current_time = timezone.now()
+ current_time = current_time.astimezone(self.timezone())
+ local_registration_federal_limit = self.local_registration_federal_limit()
+ if self.team_sorting == TeamSortingType.RANK and local_registration_federal_limit is not None:
+ if current_time < local_registration_federal_limit:
+ print("current_time < local_registration_federal_limit")
+ return None
+
if len(teams)<=self.team_count:
return None
waiting_teams = [team for team in teams if team.stage == "Attente"]
@@ -1298,6 +1349,7 @@ class Tournament(BaseModel):
def first_waiting_list_team(self, teams):
waiting_list_team = self.waiting_list_teams(teams)
+ print("waiting_list_team", waiting_list_team)
if waiting_list_team is None:
return None
if len(waiting_list_team) > 0:
@@ -1458,7 +1510,7 @@ class Tournament(BaseModel):
# if not tournament.has_time_to_confirm:
# return None
- if waiting_list_count <= 1:
+ if waiting_list_count == 0:
return None
# Configuration rules
@@ -1479,7 +1531,7 @@ class Tournament(BaseModel):
BUSINESS_RULES = {
"hours": {
"start": 8, # 8:00
- "end": 20, # 20:00
+ "end": 21, # 20:00
"default_confirmation_hour": 10 # When extending to next day
},
"days": {
@@ -1641,6 +1693,12 @@ class TeamSummon:
}
class TeamItem:
+ def __str__(self):
+ return f"TeamItem({self.team_registration.id}, names={self.names}, stage={self.stage})"
+
+ def __repr__(self):
+ return self.__str__()
+
def __init__(self, team_registration):
self.names = team_registration.team_names()
self.date = team_registration.local_call_date()
diff --git a/tournaments/services/email_service.py b/tournaments/services/email_service.py
index d01d2b6..fa4ba04 100644
--- a/tournaments/services/email_service.py
+++ b/tournaments/services/email_service.py
@@ -3,6 +3,7 @@ from django.utils import timezone
from django.urls import reverse
from enum import Enum
from ..models.player_registration import RegistrationStatus
+from ..models.tournament import TeamSortingType
class TeamEmailType(Enum):
REGISTERED = "registered"
@@ -75,6 +76,13 @@ class TournamentEmailService:
body_parts.append(f"Votre inscription en liste d'attente du tournoi {tournament_details_str} est confirmée.")
else:
body_parts.append(f"Votre inscription au tournoi {tournament_details_str} est confirmée.")
+ if tournament.team_sort == TeamSortingType.RANK:
+ cloture_date = tournament.local_registration_federal_limit().strftime("%d/%m/%Y à %H:%M")
+ loc = ""
+ if cloture_date is not None:
+ loc = f", prévu le {cloture_date}"
+ body_parts.append(f"Attention, la sélection définitive se fera par poids d'équipe à la clôture des inscriptions{loc}.")
+
absolute_url = f"https://padelclub.app/tournament/{tournament.id}/info"
link_text = "informations sur le tournoi"
diff --git a/tournaments/tasks.py b/tournaments/tasks.py
new file mode 100644
index 0000000..3cf2d3b
--- /dev/null
+++ b/tournaments/tasks.py
@@ -0,0 +1,89 @@
+from background_task import background
+from django.utils import timezone
+from django.db import transaction
+
+from .models import PlayerRegistration
+from .models.player_registration import RegistrationStatus
+from .services.email_service import TournamentEmailService, TeamEmailType
+
+@background(schedule=1) # Run every 30 minutes (30*60 seconds)
+def check_confirmation_deadlines():
+ """
+ Periodic task to check for expired confirmation deadlines
+ and notify the next team in the waiting list.
+ """
+ now = timezone.now()
+ print(f"[{now}] Running confirmation deadline check...")
+
+ # Find players with expired confirmation deadlines
+ expired_confirmations = PlayerRegistration.objects.filter(
+ registration_status=RegistrationStatus.PENDING,
+ registered_online=True,
+ team_registration__isnull=False
+ ).select_related('team_registration', 'team_registration__tournament')
+
+ print(f"Found {expired_confirmations.count()} expired confirmations")
+
+ # Process each expired confirmation
+ processed_teams = set() # To avoid processing the same team multiple times
+
+ for player in expired_confirmations:
+ team_registration = player.team_registration
+
+ # Skip if we've already processed this team
+ if team_registration.id in processed_teams:
+ continue
+
+ processed_teams.add(team_registration.id)
+ tournament = team_registration.tournament
+
+ if not tournament or not tournament.enable_online_registration:
+ continue
+
+ teams = tournament.teams(True)
+ waiting_list_teams = tournament.waiting_list_teams(teams)
+ if waiting_list_teams is not None:
+ ttc = tournament.calculate_time_to_confirm(len(waiting_list_teams))
+ else:
+ ttc = None
+ first_waiting_list_team = tournament.first_waiting_list_team(teams)
+
+ # Process in a transaction to ensure atomic operations
+ with transaction.atomic():
+ # Get all players in this team and mark them as expired
+ team_players = PlayerRegistration.objects.filter(
+ team_registration=team_registration,
+ registered_online=True
+ )
+
+ should_update_team = False
+ should_send_mail = False
+ for team_player in team_players:
+ if team_player.time_to_confirm is None and first_waiting_list_team is not None:
+ team_registration.set_time_to_confirm(ttc)
+ team_player.save()
+ should_send_mail = True
+ print(team_player, "team_player.time_to_confirm is None and", ttc)
+ elif team_player.time_to_confirm is not None and now > team_player.time_to_confirm:
+ team_player.registration_status = RegistrationStatus.CANCELED
+ team_player.time_to_confirm = None
+ team_player.save()
+ team_registration.registration_date = now
+ print(team_player, "time_to_confirm = ", team_player.time_to_confirm)
+ should_update_team = True
+ # elif team_player.time_to_confirm is not None and team_player.time_to_confirm > ttc:
+ # team_player.registration_status = RegistrationStatus.PENDING
+ # team_player.time_to_confirm = ttc
+ # team_player.save()
+ # should_update_team = True
+
+ if should_send_mail:
+ TournamentEmailService.notify_team(
+ team_registration,
+ tournament,
+ TeamEmailType.OUT_OF_WAITING_LIST
+ )
+
+ if should_update_team:
+ team_registration.save()
+ print(f"Team {team_registration} confirmation expired in tournament {tournament.id}")
diff --git a/tournaments/templates/tournaments/tournament_info.html b/tournaments/templates/tournaments/tournament_info.html
index 7699b56..56c23b8 100644
--- a/tournaments/templates/tournaments/tournament_info.html
+++ b/tournaments/templates/tournaments/tournament_info.html
@@ -50,22 +50,23 @@
Votre place dans le tournoi a été libérée suite à une désinscription.
{% if team.get_confirmation_deadline %}
- {% timezone tournament.timezone %}
- {% with time_now=now time_to_confirm=team.get_confirmation_deadline %}
- {% if time_to_confirm > time_now %}
+ {% timezone tournament.timezone %}
+ {% with time_to_confirm=team.get_confirmation_deadline %}
+ {% now "Y-m-d H:i:s" as current_time_str %}
+ {% if time_to_confirm %}
+ {% if time_to_confirm|date:"Y-m-d H:i:s" > current_time_str %}
Vous devez confirmer votre participation avant le:
{{ time_to_confirm|date:"d/m/Y à H:i" }}
- {% with time_diff=time_to_confirm|timeuntil:time_now %}
- Temps restant: {{ time_diff }}
- {% endwith %}
+ Temps restant: {{ time_to_confirm|timeuntil }}
{% else %}
Le délai de confirmation est expiré. Votre place a été proposée à une autre équipe.
{% endif %}
- {% endwith %}
- {% endtimezone %}
+ {% endif %}
+ {% endwith %}
+ {% endtimezone %}
{% endif %}
- Pas encore de compte ? Créer le tout de suite !
+ Pas encore de compte ? Créez le tout de suite !
diff --git a/tournaments/templates/tournaments/tournament_info.html b/tournaments/templates/tournaments/tournament_info.html
index 06839f7..6b2411f 100644
--- a/tournaments/templates/tournaments/tournament_info.html
+++ b/tournaments/templates/tournaments/tournament_info.html
@@ -179,7 +179,7 @@
Vous avez besoin d'un compte Padel Club pour pouvoir vous inscrire en ligne.
{% endif %}
From f01f87e1eb260aa9141024fd2a38ade2e6536c43 Mon Sep 17 00:00:00 2001
From: Raz
Date: Tue, 8 Apr 2025 09:57:10 +0200
Subject: [PATCH 05/87] fix settings and shop signal, add admin related_user
search
---
padelclub_backend/settings.py | 6 +----
shop/signals.py | 46 +++++++++++++++++++++++------------
tournaments/admin.py | 10 ++++----
3 files changed, 36 insertions(+), 26 deletions(-)
diff --git a/padelclub_backend/settings.py b/padelclub_backend/settings.py
index ed2fe42..5bb9987 100644
--- a/padelclub_backend/settings.py
+++ b/padelclub_backend/settings.py
@@ -159,11 +159,7 @@ AUTHENTICATION_BACKENDS = [
]
CSRF_COOKIE_SECURE = True # if using HTTPS
-if DEBUG: # Development environment
- SESSION_COOKIE_SECURE = False
-else: # Production environment
- SESSION_COOKIE_SECURE = True
-
+SESSION_COOKIE_SECURE = True
LOGGING = {
'version': 1,
diff --git a/shop/signals.py b/shop/signals.py
index 025c7a5..f449550 100644
--- a/shop/signals.py
+++ b/shop/signals.py
@@ -1,4 +1,4 @@
-from django.db.models.signals import post_save, post_delete
+from django.db.models.signals import pre_save, post_delete
from django.dispatch import receiver
from django.core.mail import send_mail
from django.conf import settings
@@ -8,18 +8,38 @@ from django.db import transaction
from django.contrib.auth.signals import user_logged_in
from .cart import transfer_cart
-@receiver([post_save, post_delete], sender=Order)
+@receiver([pre_save, post_delete], sender=Order)
def send_order_notification(sender, instance, **kwargs):
"""Send an email notification when an order is created, updated, or deleted."""
- transaction.on_commit(lambda: _send_order_email(instance, **kwargs))
+ # For pre_save, we need to check if the instance exists in the database
+ if kwargs.get('signal', None) == pre_save:
+ try:
+ # Get the current instance from the database
+ old_instance = Order.objects.get(pk=instance.pk)
+ # Only send notification if status has changed
+ if old_instance.status != instance.status:
+ # Execute on commit to ensure DB consistency
+ transaction.on_commit(lambda: _send_order_email(instance, old_status=old_instance.status, **kwargs))
+ except Order.DoesNotExist:
+ # This is a new instance (creation)
+ # You might want to handle creation differently or just pass
+ pass
+ else:
+ # Handle post_delete
+ transaction.on_commit(lambda: _send_order_email(instance, **kwargs))
-def _send_order_email(instance, **kwargs):
+def _send_order_email(instance, old_status=None, **kwargs):
# Skip processing for PENDING orders
if instance.status == OrderStatus.PENDING:
return
# Determine action type
- action = _determine_action_type(kwargs)
+ if 'signal' in kwargs and kwargs['signal'] == post_delete:
+ action = "DELETED"
+ elif old_status is None:
+ action = "CREATED"
+ else:
+ action = "UPDATED"
if action in ["DELETED", "CREATED"]:
return # No emails for these actions
@@ -34,15 +54,6 @@ def _send_order_email(instance, **kwargs):
if order_details['customer_email']:
_send_customer_notification(instance, order_details, items_list)
-def _determine_action_type(kwargs):
- """Determine the action type from signal kwargs."""
- if 'signal' in kwargs and kwargs['signal'] == post_delete:
- return "DELETED"
- elif kwargs.get('created', False):
- return "CREATED"
- else:
- return "UPDATED"
-
def _get_order_details(instance):
"""Extract and build order details dictionary."""
# Get customer info
@@ -122,14 +133,17 @@ def _send_internal_notification(instance, action, order_details, items_list):
# Build price information with coupon details if applicable
price_info = f"Prix total: {order_details['total_price']}€"
+ server = ""
+ if settings.DEBUG:
+ server = "DEBUG: "
+
if order_details['has_coupon']:
price_info = f"""
Prix total: {order_details['total_price']}€
{order_details['coupon_info']}
Réduction: -{order_details['discount_amount']}€
Montant payé: {order_details['final_price']}€"""
-
- subject = f"Commande #{order_details['order_id']} {action_fr}: {order_details['status_fr']}"
+ subject = f"{server}Commande #{order_details['order_id']} {action_fr}: {order_details['status_fr']}"
message = f"""
La commande #{order_details['order_id']} a été {action_fr.lower()}
diff --git a/tournaments/admin.py b/tournaments/admin.py
index 1248c10..e06bb99 100644
--- a/tournaments/admin.py
+++ b/tournaments/admin.py
@@ -13,7 +13,7 @@ class CustomUserAdmin(UserAdmin):
form = CustomUserChangeForm
add_form = CustomUserCreationForm
model = CustomUser
- search_fields = ('username', 'email', 'phone', 'first_name', 'last_name', 'licence_id')
+ search_fields = ['username', 'email', 'phone', 'first_name', 'last_name', 'licence_id']
list_display = ['email', 'first_name', 'last_name', 'username', 'licence_id', 'date_joined', 'latest_event_club_name', 'is_active', 'event_count', 'origin']
list_filter = ['is_active', 'origin']
@@ -83,7 +83,7 @@ class RoundAdmin(SyncedObjectAdmin):
class PlayerRegistrationAdmin(SyncedObjectAdmin):
list_display = ['first_name', 'last_name', 'licence_id', 'rank']
- search_fields = ('id', 'first_name', 'last_name', 'licence_id__icontains')
+ search_fields = ['id', 'first_name', 'last_name', 'licence_id__icontains']
list_filter = ['registered_online', TeamScoreTournamentListFilter]
ordering = ['last_name', 'first_name']
raw_id_fields = ['team_registration'] # Add this line
@@ -111,9 +111,9 @@ class GroupStageAdmin(SyncedObjectAdmin):
class ClubAdmin(SyncedObjectAdmin):
list_display = ['name', 'acronym', 'city', 'creator', 'events_count', 'broadcast_code']
- search_fields = ('name', 'acronym', 'city')
+ search_fields = ['name', 'acronym', 'city']
ordering = ['creator']
- raw_id_fields = ['creator']
+ raw_id_fields = ['creator', 'related_user']
class PurchaseAdmin(SyncedObjectAdmin):
list_display = ['id', 'user', 'product_id', 'quantity', 'purchase_date', 'revocation_date', 'expiration_date']
@@ -150,7 +150,7 @@ class UnregisteredTeamAdmin(admin.ModelAdmin):
class UnregisteredPlayerAdmin(admin.ModelAdmin):
list_display = ['first_name', 'last_name', 'licence_id']
- search_fields = ('first_name', 'last_name')
+ search_fields = ['first_name', 'last_name']
list_filter = []
ordering = ['last_name', 'first_name']
From a321c4c154b7293ad701a2a8b5ee9a60fe199c93 Mon Sep 17 00:00:00 2001
From: Raz
Date: Wed, 9 Apr 2025 13:39:17 +0200
Subject: [PATCH 06/87] fix registration process
---
padelclub_backend/settings.py | 1 +
tournaments/middleware.py | 38 +-
...erregistration_payment_status_and_more.py} | 9 +-
tournaments/models/tournament.py | 25 ++
tournaments/services/registration_cart.py | 346 ++++++++++++++++++
tournaments/signals.py | 4 +-
.../templates/register_tournament.html | 8 +
tournaments/urls.py | 1 +
tournaments/views.py | 231 +++++++++++-
9 files changed, 648 insertions(+), 15 deletions(-)
rename tournaments/migrations/{0114_playerregistration_payment_status_and_more.py => 0116_playerregistration_payment_status_and_more.py} (67%)
create mode 100644 tournaments/services/registration_cart.py
diff --git a/padelclub_backend/settings.py b/padelclub_backend/settings.py
index e2ef565..b6c8262 100644
--- a/padelclub_backend/settings.py
+++ b/padelclub_backend/settings.py
@@ -64,6 +64,7 @@ MIDDLEWARE = [
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'tournaments.middleware.ReferrerMiddleware', # Add this line
+ 'tournaments.middleware.RegistrationCartCleanupMiddleware',
]
diff --git a/tournaments/middleware.py b/tournaments/middleware.py
index ad517b2..ad4b5d2 100644
--- a/tournaments/middleware.py
+++ b/tournaments/middleware.py
@@ -1,5 +1,6 @@
-from django.conf import settings
-from django.urls import resolve, reverse
+from django.urls import reverse
+from django.utils import timezone
+import datetime
class ReferrerMiddleware:
def __init__(self, get_response):
@@ -17,3 +18,36 @@ class ReferrerMiddleware:
response = self.get_response(request)
return response
+
+class RegistrationCartCleanupMiddleware:
+ def __init__(self, get_response):
+ self.get_response = get_response
+
+ def __call__(self, request):
+ self._check_and_clean_expired_cart(request)
+ response = self.get_response(request)
+ return response
+
+ def _check_and_clean_expired_cart(self, request):
+ if 'registration_cart_expiry' in request.session:
+ try:
+ expiry_str = request.session['registration_cart_expiry']
+ expiry = datetime.datetime.fromisoformat(expiry_str)
+ if timezone.now() > expiry:
+ # Clear expired cart
+ keys_to_delete = [
+ 'registration_cart_id',
+ 'registration_tournament_id',
+ 'registration_cart_players',
+ 'registration_cart_expiry',
+ 'registration_mobile_number'
+ ]
+ for key in keys_to_delete:
+ if key in request.session:
+ del request.session[key]
+ request.session.modified = True
+ except (ValueError, TypeError):
+ # Invalid expiry format, clear it
+ if 'registration_cart_expiry' in request.session:
+ del request.session['registration_cart_expiry']
+ request.session.modified = True
diff --git a/tournaments/migrations/0114_playerregistration_payment_status_and_more.py b/tournaments/migrations/0116_playerregistration_payment_status_and_more.py
similarity index 67%
rename from tournaments/migrations/0114_playerregistration_payment_status_and_more.py
rename to tournaments/migrations/0116_playerregistration_payment_status_and_more.py
index 92fcaf2..4593c49 100644
--- a/tournaments/migrations/0114_playerregistration_payment_status_and_more.py
+++ b/tournaments/migrations/0116_playerregistration_payment_status_and_more.py
@@ -1,4 +1,4 @@
-# Generated by Django 5.1 on 2025-03-29 14:28
+# Generated by Django 5.1 on 2025-04-08 08:43
from django.db import migrations, models
@@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('tournaments', '0113_tournament_team_count_limit'),
+ ('tournaments', '0115_auto_20250403_1503'),
]
operations = [
@@ -25,4 +25,9 @@ class Migration(migrations.Migration):
name='time_to_confirm',
field=models.DateTimeField(blank=True, null=True),
),
+ migrations.AlterField(
+ model_name='tournament',
+ name='federal_level_category',
+ field=models.IntegerField(choices=[(0, 'Animation'), (25, 'P25'), (100, 'P100'), (250, 'P250'), (500, 'P500'), (1000, 'P1000'), (1500, 'P1500'), (2000, 'P2000'), (1, 'Championnat')], default=100),
+ ),
]
diff --git a/tournaments/models/tournament.py b/tournaments/models/tournament.py
index 5d63190..e58ed9c 100644
--- a/tournaments/models/tournament.py
+++ b/tournaments/models/tournament.py
@@ -1674,6 +1674,31 @@ class Tournament(BaseModel):
return None
+ def is_user_registered(self, user):
+ """
+ Check if a user is already registered for this tournament.
+ Returns True if the user is registered, False otherwise.
+ """
+ if not user.is_authenticated or not user.licence_id:
+ return False
+
+ # Validate the license format
+ validator = LicenseValidator(user.licence_id)
+ if not validator.validate_license():
+ return False
+
+ # Get the stripped license (without check letter)
+ stripped_license = validator.stripped_license
+ PlayerRegistration = apps.get_model('tournaments', 'PlayerRegistration')
+
+ # Check if there's a player registration with this license in the tournament
+ # that hasn't walked out
+ return PlayerRegistration.objects.filter(
+ team_registration__tournament=self,
+ licence_id__icontains=stripped_license,
+ team_registration__walk_out=False
+ ).exists()
+
class MatchGroup:
def __init__(self, name, matches, formatted_schedule, round_id=None):
diff --git a/tournaments/services/registration_cart.py b/tournaments/services/registration_cart.py
new file mode 100644
index 0000000..ab67176
--- /dev/null
+++ b/tournaments/services/registration_cart.py
@@ -0,0 +1,346 @@
+from django.utils import timezone
+import uuid
+import datetime
+from ..models import PlayerRegistration, TeamRegistration, Tournament
+from ..utils.licence_validator import LicenseValidator
+from ..utils.player_search import get_player_name_from_csv
+
+def get_or_create_registration_cart_id(request):
+ """Get or create a registration cart ID in the session"""
+ if 'registration_cart_id' not in request.session:
+ request.session['registration_cart_id'] = str(uuid.uuid4())
+ return request.session['registration_cart_id']
+
+def get_cart_expiry(request):
+ """Get the cart expiry time from the session"""
+ if 'registration_cart_expiry' not in request.session:
+ # Set default expiry to 30 minutes from now
+ expiry = timezone.now() + datetime.timedelta(minutes=30)
+ request.session['registration_cart_expiry'] = expiry.isoformat()
+ return request.session['registration_cart_expiry']
+
+def is_cart_expired(request):
+ """Check if the registration cart is expired"""
+ if 'registration_cart_expiry' not in request.session:
+ return False
+
+ expiry_str = request.session['registration_cart_expiry']
+ try:
+ expiry = datetime.datetime.fromisoformat(expiry_str)
+ return timezone.now() > expiry
+ except (ValueError, TypeError):
+ return True
+
+def reset_cart_expiry(request):
+ """Reset the cart expiry time"""
+ expiry = timezone.now() + datetime.timedelta(minutes=30)
+ request.session['registration_cart_expiry'] = expiry.isoformat()
+ request.session.modified = True
+
+def get_tournament_from_cart(request):
+ """Get the tournament ID associated with the current cart"""
+ return request.session.get('registration_tournament_id')
+
+def initialize_registration_cart(request, tournament_id):
+ """Initialize a new registration cart for a tournament"""
+ # Clear any existing cart
+ clear_registration_cart(request)
+
+ # Set up the new cart
+ request.session['registration_cart_id'] = str(uuid.uuid4())
+ request.session['registration_tournament_id'] = tournament_id
+ request.session['registration_cart_players'] = []
+ reset_cart_expiry(request)
+ request.session.modified = True
+
+def get_registration_cart_data(request):
+ """Get the data for the current registration cart"""
+ # Ensure cart players array exists
+ if 'registration_cart_players' not in request.session:
+ request.session['registration_cart_players'] = []
+ request.session.modified = True
+
+ # Ensure tournament ID exists
+ if 'registration_tournament_id' not in request.session:
+ # If no tournament ID but we have players, this is an inconsistency
+ if request.session.get('registration_cart_players'):
+ print("WARNING: Found players but no tournament ID - clearing players")
+ request.session['registration_cart_players'] = []
+ request.session.modified = True
+
+ cart_data = {
+ 'cart_id': get_or_create_registration_cart_id(request),
+ 'tournament_id': request.session.get('registration_tournament_id'),
+ 'players': request.session.get('registration_cart_players', []),
+ 'expiry': get_cart_expiry(request),
+ 'mobile_number': request.session.get('registration_mobile_number',
+ request.user.phone if hasattr(request.user, 'phone') else '')
+ }
+
+ # Debug: print the cart content
+ print(f"Cart data - Tournament ID: {cart_data['tournament_id']}")
+ print(f"Cart data - Players count: {len(cart_data['players'])}")
+
+ return cart_data
+
+def add_player_to_cart(request, player_data):
+ """Add a player to the registration cart"""
+ if is_cart_expired(request):
+ return False, "Votre session d'inscription a expiré, veuillez réessayer."
+
+ # Get cart data
+ tournament_id = request.session.get('registration_tournament_id')
+ if not tournament_id:
+ return False, "Pas d'inscription active."
+
+ # Get tournament
+ try:
+ tournament = Tournament.objects.get(id=tournament_id)
+ except Tournament.DoesNotExist:
+ return False, "Tournoi introuvable."
+
+ # Get existing players directly from session
+ players = request.session.get('registration_cart_players', [])
+
+ # Debug: Initial players count
+ print(f"Before adding - Players in session: {len(players)}")
+
+ # Check if we've reached the team limit (usually 2 for padel)
+ if len(players) >= 2: # Assuming teams of 2 for padel
+ return False, "Nombre maximum de joueurs déjà ajouté."
+
+ # Process player data
+ licence_id = player_data.get('licence_id', '').upper() if player_data.get('licence_id') else None
+ first_name = player_data.get('first_name', '')
+ last_name = player_data.get('last_name', '').upper()
+
+ # Handle case where user is authenticated, has no license, and license is required
+ if tournament.license_is_required:
+ # If license is required but not provided
+ if not licence_id:
+ # First player (authentication check) or partner
+ user_message = "Le numéro de licence est obligatoire." if len(players) == 0 else "Le numéro de licence de votre partenaire est obligatoire."
+ return False, user_message
+
+ # Validate the license format
+ validator = LicenseValidator(licence_id)
+ if not validator.validate_license():
+ return False, "Le numéro de licence est invalide, la lettre ne correspond pas."
+
+ # Check if player is already registered in tournament
+ stripped_license = validator.stripped_license
+ if _is_player_already_registered(stripped_license, tournament):
+ return False, "Un joueur avec ce numéro de licence est déjà inscrit dans une équipe."
+
+ # Check if this is the authenticated user trying to register as first player
+ if request.user.is_authenticated and len(players) == 0 and request.user.licence_id is None:
+ # Try to update the user's license ID in the database
+ try:
+ request.user.licence_id = validator.computed_licence_id
+ request.user.save()
+ request.user.refresh_from_db()
+ except:
+ return False, "Erreur lors de la mise à jour de votre licence: cette licence est déjà utilisée par un autre joueur."
+
+ # Check for duplicate licenses in cart
+ existing_licenses = [p.get('licence_id') for p in players if p.get('licence_id')]
+ if licence_id and licence_id in existing_licenses:
+ return False, "Ce joueur est déjà dans l'équipe."
+
+ # Process based on whether license ID was provided and tournament rules
+ if licence_id:
+ # Get federation data
+ fed_data, found = get_player_name_from_csv(tournament.federal_category, licence_id)
+ if found and fed_data:
+ # Use federation data (including check for eligibility)
+ player_register_check = tournament.player_register_check(licence_id)
+ if player_register_check:
+ return False, ", ".join(player_register_check)
+
+ # Update player data from federation data
+ player_data.update({
+ 'first_name': fed_data['first_name'],
+ 'last_name': fed_data['last_name'],
+ 'rank': fed_data['rank'],
+ 'is_woman': fed_data['is_woman'],
+ 'points': fed_data.get('points'),
+ 'assimilation': fed_data.get('assimilation'),
+ 'tournament_count': fed_data.get('tournament_count'),
+ 'ligue_name': fed_data.get('ligue_name'),
+ 'club_name': fed_data.get('club_name'),
+ 'birth_year': fed_data.get('birth_year'),
+ 'found_in_french_federation': True,
+ })
+ elif tournament.license_is_required:
+ # License required but not found in federation data
+ return False, "La licence fournit n'a pas été trouvée dans la base FFT. Contactez le juge arbitre si cette licence est valide."
+ elif not first_name or not last_name:
+ # License not required or not found, but name is needed
+ return False, "Le prénom et le nom sont obligatoires pour les joueurs sans licence."
+ elif not tournament.license_is_required:
+ # License not required, check if name is provided
+ if not first_name or not last_name:
+ return False, "Le prénom et le nom sont obligatoires pour les joueurs sans licence."
+
+ # Set default rank for players without a license
+ if player_data.get('rank') is None:
+ default_data, _ = get_player_name_from_csv(tournament.federal_category, None)
+ if default_data:
+ player_data['rank'] = default_data.get('rank')
+ player_data['is_woman'] = default_data.get('is_woman', False)
+ else:
+ # License is required but not provided
+ return False, "Le numéro de licence est obligatoire."
+
+ # Add player to cart
+ players.append(player_data)
+ request.session['registration_cart_players'] = players
+ reset_cart_expiry(request)
+ request.session.modified = True
+
+ return True, "Joueur ajouté avec succès."
+
+def remove_player_from_cart(request):
+ """Remove the last player from the cart"""
+ if is_cart_expired(request):
+ return False, "Votre session d'inscription a expiré, veuillez réessayer."
+
+ players = request.session.get('registration_cart_players', [])
+ if not players:
+ return False, "Pas de joueur à supprimer."
+
+ # Remove last player
+ players.pop()
+ request.session['registration_cart_players'] = players
+ reset_cart_expiry(request)
+ request.session.modified = True
+
+ # If cart is now empty and user is authenticated with license, re-add them automatically
+ if not players:
+ add_authenticated_user_to_cart(request)
+
+ return True, "Joueur retiré."
+
+def update_cart_contact_info(request, mobile_number=None):
+ """Update contact info for the cart"""
+ if is_cart_expired(request):
+ return False, "Votre session d'inscription a expiré, veuillez réessayer."
+
+ if mobile_number is not None:
+ request.session['registration_mobile_number'] = mobile_number
+
+ reset_cart_expiry(request)
+ request.session.modified = True
+
+ return True, "Informations de contact mises à jour."
+
+def checkout_registration_cart(request):
+ """Convert cart to an actual tournament registration"""
+ if is_cart_expired(request):
+ return False, "Votre session d'inscription a expiré, veuillez réessayer."
+
+ # Get cart data
+ cart_data = get_registration_cart_data(request)
+ tournament_id = cart_data.get('tournament_id')
+ players = cart_data.get('players')
+ mobile_number = cart_data.get('mobile_number')
+
+ # Validate cart data
+ if not tournament_id:
+ return False, "Aucun tournoi sélectionné."
+
+ if not players:
+ return False, "Aucun joueur dans l'inscription."
+
+ # Get tournament
+ try:
+ tournament = Tournament.objects.get(id=tournament_id)
+ except Tournament.DoesNotExist:
+ return False, "Tournoi introuvable."
+
+ # Check minimum players
+ if len(players) < tournament.minimum_player_per_team:
+ return False, f"Vous avez besoin d'au moins {tournament.minimum_player_per_team} joueurs pour vous inscrire."
+
+ # Create team registration
+ team_registration = TeamRegistration.objects.create(
+ tournament=tournament,
+ registration_date=timezone.now(),
+ walk_out=False
+ )
+
+ # Create player registrations
+ for idx, player_data in enumerate(players):
+ PlayerRegistration.objects.create(
+ team_registration=team_registration,
+ first_name=player_data.get('first_name', ''),
+ last_name=player_data.get('last_name', '').upper(),
+ licence_id=player_data.get('licence_id'),
+ rank=player_data.get('rank'),
+ points=player_data.get('points'),
+ club_name=player_data.get('club_name'),
+ ligue_name=player_data.get('ligue_name'),
+ email=player_data.get('email'),
+ phone_number=player_data.get('phone'),
+ assimilation=player_data.get('assimilation'),
+ tournament_played=player_data.get('tournament_count'),
+ birthdate=str(player_data.get('birth_year', '')),
+ captain=(idx == 0), # First player is captain
+ registered_online=True,
+ registration_status='CONFIRMED' if tournament.get_waiting_list_position() < 0 else 'WAITING'
+ )
+
+ # Update user phone if provided
+ if request.user.is_authenticated and mobile_number:
+ request.user.phone = mobile_number
+ request.user.save(update_fields=['phone'])
+
+ # Clear the cart
+ clear_registration_cart(request)
+
+ return True, team_registration
+
+def clear_registration_cart(request):
+ """Clear the registration cart"""
+ keys_to_clear = [
+ 'registration_cart_id',
+ 'registration_tournament_id',
+ 'registration_cart_players',
+ 'registration_cart_expiry',
+ 'registration_mobile_number'
+ ]
+
+ for key in keys_to_clear:
+ if key in request.session:
+ del request.session[key]
+
+ request.session.modified = True
+
+def _is_player_already_registered(stripped_license, tournament):
+ """Check if a player is already registered in the tournament"""
+ return PlayerRegistration.objects.filter(
+ team_registration__tournament=tournament,
+ licence_id__icontains=stripped_license,
+ team_registration__walk_out=False
+ ).exists()
+
+def add_authenticated_user_to_cart(request):
+ """
+ Adds the authenticated user to the cart if they have a valid license.
+ Returns True if added, False otherwise.
+ """
+ if not request.user.is_authenticated or not request.user.licence_id:
+ return False
+
+ # Create player data for the authenticated user
+ player_data = {
+ 'first_name': request.user.first_name,
+ 'last_name': request.user.last_name,
+ 'licence_id': request.user.licence_id,
+ 'email': request.user.email,
+ 'phone': request.user.phone
+ }
+
+ # Add the user to the cart
+ success, _ = add_player_to_cart(request, player_data)
+ return success
diff --git a/tournaments/signals.py b/tournaments/signals.py
index b352c49..8144283 100644
--- a/tournaments/signals.py
+++ b/tournaments/signals.py
@@ -109,7 +109,7 @@ def check_waiting_list(sender, instance, **kwargs):
teams_out_to_warn = []
teams_in_to_warn = []
-
+ previous_state_teams = previous_state.teams(True)
if previous_state.team_count > instance.team_count:
teams_that_will_be_out = instance.teams(True)[instance.team_count:]
teams_out_to_warn = [
@@ -117,7 +117,7 @@ def check_waiting_list(sender, instance, **kwargs):
if team.stage != "Attente"
]
elif previous_state.team_count < instance.team_count:
- teams_that_will_be_in = previous_state.teams(True)[previous_state.team_count:instance.team_count]
+ teams_that_will_be_in = previous_state_teams[previous_state.team_count:instance.team_count]
teams_in_to_warn = [
team for team in teams_that_will_be_in
if team.stage == "Attente"
diff --git a/tournaments/templates/register_tournament.html b/tournaments/templates/register_tournament.html
index 66c4dd7..456ca64 100644
--- a/tournaments/templates/register_tournament.html
+++ b/tournaments/templates/register_tournament.html
@@ -26,6 +26,11 @@
Un email de confirmation a été envoyé à l'adresse associée à votre compte Padel Club ({{ user.email }}). Pensez à vérifier vos spams si vous ne recevez pas l'email. En cas de problème, contactez le juge-arbitre.
{% else %}
+ {% if not registration_successful %}
+
+
Votre session d'inscription est active. Complétez le formulaire pour confirmer votre participation.
+
+ {% endif %}
{% if team_form.errors %}
@@ -126,10 +131,13 @@
Précisez les informations du joueur :
{% endif %}
+
+ {% if not add_player_form.user_without_licence %}
{{ add_player_form.first_name.label_tag }}
{{ add_player_form.first_name }}
{{ add_player_form.last_name.label_tag }}
{{ add_player_form.last_name }}
+ {% endif %}
{% if tournament.license_is_required is False %}
{{ add_player_form.licence_id.label_tag }}
{% if tournament.license_is_required is False %}(facultatif){% endif %}
diff --git a/tournaments/urls.py b/tournaments/urls.py
index fde41e3..150c1dc 100644
--- a/tournaments/urls.py
+++ b/tournaments/urls.py
@@ -57,6 +57,7 @@ urlpatterns = [
# path('profile/', views.profile, name='profile'), # URL pattern for signup
path('my-tournaments/', views.my_tournaments, name='my-tournaments'), # URL pattern for signup
path('all_my_ended_tournaments/', views.all_my_ended_tournaments, name='all-my-ended-tournaments'), # URL pattern for signup
+ path('tournaments//cancel-registration/', views.cancel_registration, name='cancel_registration'),
path('tournaments//register/', views.register_tournament, name='register_tournament'),
path('tournaments//unregister/', views.unregister_tournament, name='unregister_tournament'),
path('password_reset/', auth_views.PasswordResetView.as_view(), name='password_reset'),
diff --git a/tournaments/views.py b/tournaments/views.py
index a69b4f7..778aad9 100644
--- a/tournaments/views.py
+++ b/tournaments/views.py
@@ -6,6 +6,7 @@ from .utils.extensions import create_random_filename
from api.serializers import GroupStageSerializer, MatchSerializer, PlayerRegistrationSerializer, TeamRegistrationSerializer, TeamScoreSerializer
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth import logout
+from .utils.extensions import is_not_sqlite_backend
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.views import PasswordResetCompleteView
@@ -49,13 +50,15 @@ from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import PasswordResetConfirmView
from django.core.mail import EmailMessage
from django.views.decorators.csrf import csrf_protect
-from .services.tournament_registration import TournamentRegistrationService
+from .services import registration_cart
from .services.tournament_unregistration import TournamentUnregistrationService
from django.core.exceptions import ValidationError
from .forms import (
ProfileUpdateForm,
SimpleCustomUserCreationForm,
- SimpleForm
+ SimpleForm,
+ TournamentRegistrationForm,
+ AddPlayerForm
)
from .utils.apns import send_push_notification
from .utils.licence_validator import LicenseValidator
@@ -723,15 +726,225 @@ def profile(request):
@csrf_protect
def register_tournament(request, tournament_id):
tournament = get_object_or_404(Tournament, id=tournament_id)
- service = TournamentRegistrationService(request, tournament)
- service.initialize_context()
- print("initialize_context")
- if request.method == 'POST':
- service.handle_post_request()
+
+ # Debug session content
+ print("===== SESSION DUMP AT START =====")
+ for key, value in request.session.items():
+ if key.startswith('registration_'):
+ print(f"{key}: {value}")
+ print("================================")
+
+ # Check if tournament is open for registration
+ if not tournament.enable_online_registration:
+ messages.error(request, "L'inscription en ligne n'est pas activée pour ce tournoi.")
+ return redirect('tournament-info', tournament_id=tournament_id)
+
+ # Check if user is already registered
+ if request.user.is_authenticated:
+ user_licence_id = request.user.licence_id
+ if user_licence_id and tournament.is_user_registered(request.user):
+ messages.info(request, "Vous êtes déjà inscrit à ce tournoi.")
+ return redirect('tournament-info', tournament_id=tournament_id)
+
+ # Only initialize a fresh cart for GET requests
+ # For POST requests, use the existing cart to maintain state
+ if request.method == 'GET':
+ # ALWAYS initialize a fresh cart when entering the registration page (GET request)
+ # This ensures no old cart data persists
+ registration_cart.initialize_registration_cart(request, tournament_id)
+
+ # Auto-add the authenticated user with license
+ if request.user.is_authenticated and request.user.licence_id:
+ player_data = {
+ 'first_name': request.user.first_name,
+ 'last_name': request.user.last_name,
+ 'licence_id': request.user.licence_id,
+ 'email': request.user.email,
+ 'phone': request.user.phone
+ }
+ registration_cart.add_player_to_cart(request, player_data)
else:
- service.handle_get_request()
+ # For POST, ensure tournament ID is correct
+ current_tournament_id = registration_cart.get_tournament_from_cart(request)
+ if current_tournament_id != str(tournament_id):
+ registration_cart.initialize_registration_cart(request, tournament_id)
+
+ # Re-add the authenticated user if they have a license
+ if request.user.is_authenticated and request.user.licence_id:
+ player_data = {
+ 'first_name': request.user.first_name,
+ 'last_name': request.user.last_name,
+ 'licence_id': request.user.licence_id,
+ 'email': request.user.email,
+ 'phone': request.user.phone
+ }
+ registration_cart.add_player_to_cart(request, player_data)
+
+ # Get cart data
+ cart_data = registration_cart.get_registration_cart_data(request)
+
+ # Debug print
+ print(f"View - Cart Players Count: {len(cart_data['players'])}")
+
+ # Initialize context with cart data
+ context = {
+ 'tournament': tournament,
+ 'current_players': cart_data['players'],
+ 'registration_successful': False
+ }
- return render(request, 'register_tournament.html', service.context)
+ # Initialize forms
+ context['team_form'] = TournamentRegistrationForm(initial={
+ 'email': request.user.email if request.user.is_authenticated else '',
+ 'mobile_number': request.user.phone if request.user.is_authenticated else cart_data.get('mobile_number', '')
+ })
+
+ # Initialize the add player form
+ add_player_form = AddPlayerForm()
+
+ # Special handling for authenticated user without license
+ if request.user.is_authenticated and not request.user.licence_id and not cart_data['players']:
+ # Setup form for user without license
+ initial_data = {
+ 'first_name': request.user.first_name,
+ 'last_name': request.user.last_name
+ }
+
+ # If license is required, only show license field initially
+ if tournament.license_is_required:
+ add_player_form = AddPlayerForm(initial=initial_data)
+ add_player_form.user_without_licence = True
+ else:
+ # If license not required, show all fields
+ add_player_form = AddPlayerForm(initial=initial_data)
+ elif not cart_data['players'] or len(cart_data['players']) < tournament.minimum_player_per_team:
+ # Regular partner addition form
+ add_player_form = AddPlayerForm()
+
+ context['add_player_form'] = add_player_form
+
+ # Handle POST requests
+ if request.method == 'POST':
+ if 'add_player' in request.POST:
+ add_player_form = AddPlayerForm(request.POST)
+ if add_player_form.is_valid():
+ success, message = registration_cart.add_player_to_cart(request, add_player_form.cleaned_data)
+ if success:
+ messages.success(request, message)
+ # Refresh cart data
+ cart_data = registration_cart.get_registration_cart_data(request)
+ context['current_players'] = cart_data['players']
+
+ # Prepare a fresh form for the next player if needed
+ if len(cart_data['players']) < tournament.minimum_player_per_team:
+ context['add_player_form'] = AddPlayerForm()
+ else:
+ # Remove the form if we've reached the team limit
+ context['add_player_form'] = None
+ else:
+ messages.error(request, message)
+ context['add_player_form'] = add_player_form
+ else:
+ for field, errors in add_player_form.errors.items():
+ for error in errors:
+ messages.error(request, f"{field}: {error}")
+ context['add_player_form'] = add_player_form
+
+ elif 'remove_player' in request.POST:
+ success, message = registration_cart.remove_player_from_cart(request)
+ if success:
+ messages.info(request, message)
+ # Refresh cart data
+ cart_data = registration_cart.get_registration_cart_data(request)
+ context['current_players'] = cart_data['players']
+
+ # If after removing, the cart is empty and user has no license but license is required
+ if not cart_data['players'] and request.user.is_authenticated:
+ if not request.user.licence_id and tournament.license_is_required:
+ initial_data = {
+ 'first_name': request.user.first_name,
+ 'last_name': request.user.last_name
+ }
+ add_player_form = AddPlayerForm(initial=initial_data)
+ add_player_form.user_without_licence = True
+ else:
+ # This will handle re-adding the user with license
+ add_player_form = AddPlayerForm()
+
+ context['add_player_form'] = add_player_form
+ else:
+ add_player_form = AddPlayerForm()
+ context['add_player_form'] = add_player_form
+ else:
+ messages.error(request, message)
+
+ elif 'register_team' in request.POST:
+ team_form = TournamentRegistrationForm(request.POST)
+ if team_form.is_valid():
+ # Debug print before checkout
+ print(f"Before checkout - Players in cart: {len(cart_data['players'])}")
+
+ # Ensure the session data is correctly saved before proceeding
+ request.session.modified = True
+ request.session.save()
+
+ # Update cart with contact info
+ registration_cart.update_cart_contact_info(
+ request,
+ mobile_number=team_form.cleaned_data.get('mobile_number')
+ )
+
+ # Get fresh cart data again after updating contact info
+ fresh_cart_data = registration_cart.get_registration_cart_data(request)
+ print(f"After contact update - Players in cart: {len(fresh_cart_data['players'])}")
+
+ # Debug session content
+ print("===== SESSION DUMP BEFORE CHECKOUT =====")
+ for key, value in request.session.items():
+ if key.startswith('registration_'):
+ print(f"{key}: {value}")
+ print("================================")
+
+ # Checkout and create registration
+ success, result = registration_cart.checkout_registration_cart(request)
+ if success:
+ waiting_list_position = tournament.get_waiting_list_position()
+ if is_not_sqlite_backend():
+ from .services.email_service import TournamentEmailService
+ email_service = TournamentEmailService()
+ email_service.send_registration_confirmation(
+ request,
+ tournament,
+ result, # team_registration
+ waiting_list_position
+ )
+
+ context['registration_successful'] = True
+ context['current_players'] = []
+ context['add_player_form'] = None # No more adding players after success
+ else:
+ messages.error(request, result)
+ else:
+ for field, errors in team_form.errors.items():
+ for error in errors:
+ messages.error(request, f"{field}: {error}")
+ context['team_form'] = team_form
+
+ # Debug session content before rendering
+ print("===== SESSION DUMP BEFORE RENDER =====")
+ for key, value in request.session.items():
+ if key.startswith('registration_'):
+ print(f"{key}: {value}")
+ print("================================")
+
+ return render(request, 'register_tournament.html', context)
+
+@login_required
+def cancel_registration(request, tournament_id):
+ """Cancel the current registration process and clear the cart"""
+ registration_cart.clear_registration_cart(request)
+ messages.info(request, "Processus d'inscription annulé.")
+ return redirect('tournament-info', tournament_id=tournament_id)
@login_required
def unregister_tournament(request, tournament_id):
From a0427b81c2de61c7ed40ca482294f19d3f565f8b Mon Sep 17 00:00:00 2001
From: Laurent
Date: Wed, 9 Apr 2025 15:48:37 +0200
Subject: [PATCH 07/87] fix issue where matches were displayed for the teams
when no match should be shown
---
tournaments/models/team_registration.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/tournaments/models/team_registration.py b/tournaments/models/team_registration.py
index d2360fe..f39453e 100644
--- a/tournaments/models/team_registration.py
+++ b/tournaments/models/team_registration.py
@@ -181,6 +181,9 @@ class TeamRegistration(SideStoreModel):
return matches
def get_upcoming_matches(self):
+ if self.tournament and self.tournament.display_matches() is False:
+ return []
+
matches = self.get_matches()
upcoming = matches.filter(end_date__isnull=True).order_by('start_date')
print(f"Upcoming matches count: {upcoming.count()}")
From 42090b5ab7f7d672d11d582c3dc41273e407981c Mon Sep 17 00:00:00 2001
From: Raz
Date: Wed, 9 Apr 2025 18:40:33 +0200
Subject: [PATCH 08/87] add payment for registration
---
..._playerregistration_payment_id_and_more.py | 23 ++
...yerregistration_payment_status_and_more.py | 22 ++
tournaments/models/player_registration.py | 10 +-
tournaments/models/team_registration.py | 51 ++++
tournaments/models/tournament.py | 6 +
tournaments/services/email_service.py | 38 ++-
tournaments/services/registration_cart.py | 21 +-
.../templates/register_tournament.html | 33 ++-
.../tournaments/tournament_info.html | 31 +-
tournaments/urls.py | 2 +
tournaments/views.py | 277 ++++++++++++++++--
11 files changed, 471 insertions(+), 43 deletions(-)
create mode 100644 tournaments/migrations/0117_playerregistration_payment_id_and_more.py
create mode 100644 tournaments/migrations/0118_remove_playerregistration_payment_status_and_more.py
diff --git a/tournaments/migrations/0117_playerregistration_payment_id_and_more.py b/tournaments/migrations/0117_playerregistration_payment_id_and_more.py
new file mode 100644
index 0000000..faf0fb1
--- /dev/null
+++ b/tournaments/migrations/0117_playerregistration_payment_id_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.1 on 2025-04-09 13:14
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tournaments', '0116_playerregistration_payment_status_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='playerregistration',
+ name='payment_id',
+ field=models.CharField(blank=True, max_length=255, null=True),
+ ),
+ migrations.AddField(
+ model_name='tournament',
+ name='reserved_spots',
+ field=models.IntegerField(default=0),
+ ),
+ ]
diff --git a/tournaments/migrations/0118_remove_playerregistration_payment_status_and_more.py b/tournaments/migrations/0118_remove_playerregistration_payment_status_and_more.py
new file mode 100644
index 0000000..6c2702d
--- /dev/null
+++ b/tournaments/migrations/0118_remove_playerregistration_payment_status_and_more.py
@@ -0,0 +1,22 @@
+# Generated by Django 5.1 on 2025-04-09 13:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tournaments', '0117_playerregistration_payment_id_and_more'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='playerregistration',
+ name='payment_status',
+ ),
+ migrations.AlterField(
+ model_name='playerregistration',
+ name='registration_status',
+ field=models.CharField(choices=[('WAITING', 'Waiting'), ('PENDING', 'Pending'), ('CONFIRMED', 'Confirmed'), ('CANCELED', 'Canceled')], default='WAITING', max_length=20),
+ ),
+ ]
diff --git a/tournaments/models/player_registration.py b/tournaments/models/player_registration.py
index 2c40b10..e907e94 100644
--- a/tournaments/models/player_registration.py
+++ b/tournaments/models/player_registration.py
@@ -7,7 +7,6 @@ class RegistrationStatus(models.TextChoices):
WAITING = 'WAITING', 'Waiting'
PENDING = 'PENDING', 'Pending'
CONFIRMED = 'CONFIRMED', 'Confirmed'
- PAID = 'PAID', 'Paid'
CANCELED = 'CANCELED', 'Canceled'
@@ -47,11 +46,7 @@ class PlayerRegistration(SideStoreModel):
registered_online = models.BooleanField(default=False)
time_to_confirm = models.DateTimeField(null=True, blank=True)
registration_status = models.CharField(max_length=20, choices=RegistrationStatus.choices, default=RegistrationStatus.WAITING)
- payment_status = models.CharField(max_length=20, default='UNPAID', choices=[
- ('UNPAID', 'Unpaid'),
- ('PAID', 'Paid'),
- ('FAILED', 'Failed'),
- ])
+ payment_id = models.CharField(max_length=255, blank=True, null=True)
def delete_dependencies(self):
pass
@@ -172,3 +167,6 @@ class PlayerRegistration(SideStoreModel):
status['short_label'] = 'inscrit'
return status
+
+ def has_paid(self):
+ return self.payment_type is not None
diff --git a/tournaments/models/team_registration.py b/tournaments/models/team_registration.py
index 26195a6..4eed0d6 100644
--- a/tournaments/models/team_registration.py
+++ b/tournaments/models/team_registration.py
@@ -340,3 +340,54 @@ class TeamRegistration(SideStoreModel):
# Save confirmation date
self.confirmation_date = now
self.save()
+
+ # Add to TeamRegistration class in team_registration.py
+ def get_payment_status(self):
+ """
+ Gets the payment status for this team.
+ Returns:
+ - 'PAID': If all players in the team have paid
+ - 'UNPAID': If no player has paid
+ - 'MIXED': If some players have paid and others haven't (unusual case)
+ """
+ # Get all player registrations for this team
+ player_registrations = self.player_registrations.all()
+
+ # If we have no players, return None
+ if not player_registrations.exists():
+ return None
+
+ # Check payment status for each player
+ payment_statuses = [player.has_paid() for player in player_registrations]
+
+ print(f"Payment statuses: {payment_statuses}")
+ print(all(payment_statuses))
+ # If all players have paid
+ if all(payment_statuses):
+ return 'PAID'
+
+ # If no players have paid
+ if not any(payment_statuses):
+ return 'UNPAID'
+
+ # If some players have paid and others haven't (unusual case)
+ return 'MIXED'
+
+ def is_payment_required(self):
+ """Check if payment is required for this team"""
+ return self.tournament.should_request_payment() and self.is_in_waiting_list() < 0
+
+ def is_paid(self):
+ """Check if this team has paid"""
+ status = self.get_payment_status()
+ return status == 'PAID'
+
+ def get_remaining_fee(self):
+ """Get the remaining fee for this team"""
+ status = self.get_payment_status()
+ if status == 'PAID':
+ return 0
+ elif status == 'UNPAID':
+ return self.tournament.entry_fee * 2
+ elif status == 'MIXED':
+ return self.tournament.entry_fee
diff --git a/tournaments/models/tournament.py b/tournaments/models/tournament.py
index e58ed9c..44c0bde 100644
--- a/tournaments/models/tournament.py
+++ b/tournaments/models/tournament.py
@@ -78,6 +78,7 @@ class Tournament(BaseModel):
hide_umpire_mail = models.BooleanField(default=False)
hide_umpire_phone = models.BooleanField(default=True)
disable_ranking_federal_ruling = models.BooleanField(default=False)
+ reserved_spots = models.IntegerField(default=0)
def delete_dependencies(self):
for team_registration in self.team_registrations.all():
@@ -1699,6 +1700,11 @@ class Tournament(BaseModel):
team_registration__walk_out=False
).exists()
+ def should_request_payment(self):
+ return True
+
+ def team_fee(self):
+ return self.entry_fee * 2
class MatchGroup:
def __init__(self, name, matches, formatted_schedule, round_id=None):
diff --git a/tournaments/services/email_service.py b/tournaments/services/email_service.py
index ca0ca3e..6e2b40a 100644
--- a/tournaments/services/email_service.py
+++ b/tournaments/services/email_service.py
@@ -90,7 +90,15 @@ class TournamentEmailService:
f"\nDate d'inscription: {inscription_date}",
f"\nÉquipe inscrite: {captain.name()} et {other_player.name()}",
f"\nLe tournoi commencera le {tournament.formatted_start_date()} au club {tournament.event.club.name}",
- f"\nVoir les {absolute_url}",
+ f"\nVoir les {absolute_url}"
+ ])
+
+ # Add payment information if applicable
+ if tournament.should_request_payment:
+ payment_info = TournamentEmailService._build_payment_info(tournament, captain.team_registration)
+ body_parts.append(payment_info)
+
+ body_parts.extend([
"\nPour toute question, veuillez contacter votre juge-arbitre. Si vous n'êtes pas à l'origine de cette inscription, merci de le contacter rapidement.",
f"\n{TournamentEmailService._format_umpire_contact(tournament)}",
"\nCeci est un e-mail automatique, veuillez ne pas y répondre.",
@@ -485,3 +493,31 @@ class TournamentEmailService:
print("TournamentEmailService.notify_team 1p", team)
# If there's only one player, just send them the notification
TournamentEmailService.notify(players[0], None, tournament, message_type)
+
+ @staticmethod
+ def _build_payment_info(tournament, team_registration):
+ """
+ Build payment information section for emails
+ """
+ if not tournament.should_request_payment:
+ return ""
+
+ # Check payment status
+ payment_status = team_registration.get_payment_status()
+
+ if payment_status == 'PAID':
+ return "\n\n✅ Le paiement de votre inscription a bien été reçu."
+
+ # If the team is on the waiting list, don't mention payment
+ if team_registration.is_in_waiting_list() >= 0:
+ return ""
+
+ # For unpaid teams, add payment instructions
+ payment_info = [
+ "\n\n⚠️ Paiement des frais d'inscription requis",
+ f"Les frais d'inscription de {tournament.entry_fee}€ doivent être payés pour confirmer votre participation.",
+ "Vous pouvez effectuer le paiement en vous connectant à votre compte Padel Club.",
+ f"Lien pour payer: https://padelclub.app/tournament/{tournament.id}/info"
+ ]
+
+ return "\n".join(payment_info)
diff --git a/tournaments/services/registration_cart.py b/tournaments/services/registration_cart.py
index ab67176..82e976e 100644
--- a/tournaments/services/registration_cart.py
+++ b/tournaments/services/registration_cart.py
@@ -46,8 +46,24 @@ def initialize_registration_cart(request, tournament_id):
# Clear any existing cart
clear_registration_cart(request)
+ try:
+ tournament = Tournament.objects.get(id=tournament_id)
+ except Tournament.DoesNotExist:
+ return False, "Tournoi introuvable."
+
+ tournament.reserved_spots = max(0, tournament.reserved_spots - 1)
+ waiting_list_position = tournament.get_waiting_list_position()
+
+ if waiting_list_position >= 0:
+ tournament.reserved_spots = 0
+ else:
+ tournament.reserved_spots += 1
+
+ tournament.save()
+
# Set up the new cart
request.session['registration_cart_id'] = str(uuid.uuid4())
+ request.session['waiting_list_position'] = waiting_list_position
request.session['registration_tournament_id'] = tournament_id
request.session['registration_cart_players'] = []
reset_cart_expiry(request)
@@ -71,6 +87,7 @@ def get_registration_cart_data(request):
cart_data = {
'cart_id': get_or_create_registration_cart_id(request),
'tournament_id': request.session.get('registration_tournament_id'),
+ 'waiting_list_position': request.session.get('waiting_list_position'),
'players': request.session.get('registration_cart_players', []),
'expiry': get_cart_expiry(request),
'mobile_number': request.session.get('registration_mobile_number',
@@ -287,7 +304,7 @@ def checkout_registration_cart(request):
birthdate=str(player_data.get('birth_year', '')),
captain=(idx == 0), # First player is captain
registered_online=True,
- registration_status='CONFIRMED' if tournament.get_waiting_list_position() < 0 else 'WAITING'
+ registration_status='CONFIRMED' if request.session['waiting_list_position'] < 0 else 'WAITING'
)
# Update user phone if provided
@@ -297,6 +314,8 @@ def checkout_registration_cart(request):
# Clear the cart
clear_registration_cart(request)
+ tournament.reserved_spots = max(0, tournament.reserved_spots - 1)
+ tournament.save()
return True, team_registration
diff --git a/tournaments/templates/register_tournament.html b/tournaments/templates/register_tournament.html
index 456ca64..45b23d8 100644
--- a/tournaments/templates/register_tournament.html
+++ b/tournaments/templates/register_tournament.html
@@ -29,6 +29,7 @@
{% if not registration_successful %}
Votre session d'inscription est active. Complétez le formulaire pour confirmer votre participation.
+
DEBUG reserved_spots: {{ tournament.reserved_spots }}
{% endif %}
@@ -164,23 +165,29 @@
- {% if tournament.get_waiting_list_position == 1 %}
- Tournoi complet, {{ tournament.get_waiting_list_position }} équipe en liste d'attente actuellement.
- {% elif tournament.get_waiting_list_position > 1 %}
- Tournoi complet, {{ tournament.get_waiting_list_position }} équipes en liste d'attente actuellement.
- {% elif tournament.get_waiting_list_position == 0 %}
- Tournoi complet, vous seriez la première équipe en liste d'attente.
+ {% if cart_data.waiting_list_position == 1 %}
+ Tournoi complet, {{ cart_data.waiting_list_position }} équipe en liste d'attente actuellement.
+ {% elif cart_data.waiting_list_position > 1 %}
+ Tournoi complet, {{ cart_data.waiting_list_position }} équipes en liste d'attente actuellement.
+ {% elif cart_data.waiting_list_position == 0 %}
+ Tournoi complet, vous seriez la première équipe en liste d'attente.
{% endif %}
-
- {% if tournament.get_waiting_list_position < 0 %}
- Confirmer l'inscription
- {% else %}
- Se mettre en liste d'attente
- {% endif %}
-
+ {% if tournament.should_request_payment and cart_data.waiting_list_position < 0 %}
+
+ Procéder au paiement ({{ tournament.team_fee }}€)
+
+ {% else %}
+
+ {% if cart_data.waiting_list_position < 0 %}
+ Confirmer l'inscription
+ {% else %}
+ Se mettre en liste d'attente
+ {% endif %}
+
+ {% endif %}
{% endif %}
diff --git a/tournaments/templates/tournaments/tournament_info.html b/tournaments/templates/tournaments/tournament_info.html
index c1fab19..a1b72f2 100644
--- a/tournaments/templates/tournaments/tournament_info.html
+++ b/tournaments/templates/tournaments/tournament_info.html
@@ -43,8 +43,31 @@
Inscrits le {{ team.local_registration_date }}
-
- {% if team and team.needs_confirmation %}
+ {% if messages %}
+
+ {% for message in messages %}
+
+ {{ message }}
+
+ {% endfor %}
+
+ {% endif %}
+
+ {% if tournament.should_request_payment and team.is_in_waiting_list < 0 %}
+
+ {% if team.is_paid %}
+
+ ✅ Paiement confirmé
+
+ {% else %}
+
+ {% endif %}
+
+ {% elif team.needs_confirmation %}
Confirmation requise
Votre place dans le tournoi a été libérée suite à une désinscription.
@@ -79,7 +102,7 @@
{% endif %}
{% if tournament.is_unregistration_possible %}
-
+
{% if tournament.should_request_payment and team.is_in_waiting_list < 0 %}
@@ -62,36 +91,18 @@
{% else %}
{% endif %}
- {% elif team.needs_confirmation %}
+ {% endif %}
+ {% if team.needs_confirmation and tournament.online_payment_is_mandatory is False %}
-
Confirmation requise
-
Votre place dans le tournoi a été libérée suite à une désinscription.
-
- {% if team.get_confirmation_deadline %}
- {% timezone tournament.timezone %}
- {% with time_to_confirm=team.get_confirmation_deadline %}
- {% now "Y-m-d H:i:s" as current_time_str %}
- {% if time_to_confirm %}
- {% if time_to_confirm|date:"Y-m-d H:i:s" > current_time_str %}
-
Vous devez confirmer votre participation avant le:
-
{{ time_to_confirm|date:"d/m/Y à H:i" }}
-
-
Temps restant: {{ time_to_confirm|timeuntil }}
- {% else %}
-
- Le délai de confirmation est expiré. Votre place a été proposée à une autre équipe.
-
- {% endif %}
- {% endif %}
- {% endwith %}
- {% endtimezone %}
- {% endif %}
-