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 %} + +
+ {% csrf_token %} + +
+
+ {% 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')