From 48638e76f836726c7eea42317d809723c013ea91 Mon Sep 17 00:00:00 2001
From: Raz
Date: Fri, 28 Mar 2025 12:12:33 +0100
Subject: [PATCH] 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')