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 %}
diff --git a/tournaments/urls.py b/tournaments/urls.py index c951d80..65999d2 100644 --- a/tournaments/urls.py +++ b/tournaments/urls.py @@ -1,10 +1,8 @@ +from django.apps import apps from django.contrib.auth import views as auth_views from django.urls import include, path -from django.contrib import admin from django.conf import settings -from django.conf.urls.static import static - -from .forms import EmailOrUsernameAuthenticationForm, CustomPasswordChangeForm +from .forms import CustomPasswordChangeForm from .custom_views import CustomLoginView from . import views @@ -79,3 +77,8 @@ urlpatterns = [ path('activation-failed/', views.activation_failed, name='activation_failed'), path('tournaments//confirm/', views.confirm_tournament_registration, name='confirm_tournament_registration'), ] + +# Start background tasks when Django starts +if apps.is_installed('django_background_tasks') and not settings.DEBUG: + from django_background_tasks import background_task_scheduler + background_task_scheduler.start()