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 %} + +
+ {% 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') 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 %}
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() From 1f64fd0a7d600194b596f5cadf48fa0b8d56d2ab Mon Sep 17 00:00:00 2001 From: Raz Date: Mon, 7 Apr 2025 14:12:31 +0200 Subject: [PATCH 03/87] add order recap --- shop/admin.py | 64 ++++++++++- .../admin/shop/order/change_list.html | 18 +++ .../admin/shop/order/preparation_view.html | 103 ++++++++++++++++++ 3 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 shop/templates/admin/shop/order/change_list.html create mode 100644 shop/templates/admin/shop/order/preparation_view.html diff --git a/shop/admin.py b/shop/admin.py index 62fa1b3..72d69c6 100644 --- a/shop/admin.py +++ b/shop/admin.py @@ -1,5 +1,6 @@ from django.contrib import admin -from .models import Product, Color, Size, Order, OrderItem, GuestUser, Coupon, CouponUsage +from django.shortcuts import render +from .models import Product, Color, Size, Order, OrderItem, GuestUser, Coupon, CouponUsage, OrderStatus from django.utils.html import format_html @admin.register(Product) @@ -40,6 +41,67 @@ class OrderItemInline(admin.TabularInline): class OrderAdmin(admin.ModelAdmin): list_display = ('id', 'date_ordered', 'status', 'total_price') inlines = [OrderItemInline] + list_filter = ('status', 'payment_status') + + def changelist_view(self, request, extra_context=None): + # If 'show_preparation' parameter is in the request, show the preparation view + if 'show_preparation' in request.GET: + return self.preparation_view(request) + + # Otherwise show the normal change list + extra_context = extra_context or {} + paid_orders_count = Order.objects.filter(status=OrderStatus.PAID).count() + extra_context['paid_orders_count'] = paid_orders_count + return super().changelist_view(request, extra_context=extra_context) + + def preparation_view(self, request): + """View for items that need to be prepared""" + # Get paid orders + orders = Order.objects.filter(status=OrderStatus.PAID).order_by('-date_ordered') + + # Group items by product, color, size + items_by_variant = {} + all_items = OrderItem.objects.filter(order__status=OrderStatus.PAID) + + for item in all_items: + # Create a key for grouping items + key = ( + str(item.product.id), + str(item.color.id) if item.color else 'none', + str(item.size.id) if item.size else 'none' + ) + + if key not in items_by_variant: + items_by_variant[key] = { + 'product': item.product, + 'color': item.color, + 'size': item.size, + 'quantity': 0, + 'orders': set() + } + + items_by_variant[key]['quantity'] += item.quantity + items_by_variant[key]['orders'].add(item.order.id) + + # Convert to list and sort + items_list = list(items_by_variant.values()) + items_list.sort(key=lambda x: x['product'].title) + + context = { + 'title': 'Orders to Prepare', + 'app_label': 'shop', + 'opts': Order._meta, + 'orders': orders, + 'items': items_list, + 'total_orders': orders.count(), + 'total_items': sum(i['quantity'] for i in items_list) + } + + return render( + request, + 'admin/shop/order/preparation_view.html', + context + ) class GuestUserOrderInline(admin.TabularInline): model = Order diff --git a/shop/templates/admin/shop/order/change_list.html b/shop/templates/admin/shop/order/change_list.html new file mode 100644 index 0000000..e3e7fdc --- /dev/null +++ b/shop/templates/admin/shop/order/change_list.html @@ -0,0 +1,18 @@ +{% extends "admin/change_list.html" %} + +{% block object-tools %} + +{% endblock %} diff --git a/shop/templates/admin/shop/order/preparation_view.html b/shop/templates/admin/shop/order/preparation_view.html new file mode 100644 index 0000000..63213a5 --- /dev/null +++ b/shop/templates/admin/shop/order/preparation_view.html @@ -0,0 +1,103 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +
+

Total orders with status PAID: {{ total_orders }}

+

Total items to prepare: {{ total_items }}

+ + + Back to Orders + +

Items Summary

+ + + + + + + + + + + + {% for item in items %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
ProductColorSizeQuantityOrders
{{ item.product.title }} + {% if item.color %} + + {{ item.color.name }} + {% else %} + - + {% endif %} + {{ item.size.name|default:"-" }}{{ item.quantity }} + {% for order_id in item.orders %} + Order #{{ order_id }}{% if not forloop.last %}, {% endif %} + {% endfor %} +
No items to prepare
+ +

Order Details

+ + + + + + + + + + + {% for order in orders %} + + + + + + + {% empty %} + + + + {% endfor %} + +
Order #DateCustomerItems
Order #{{ order.id }}{{ order.date_ordered|date:"Y-m-d H:i" }} + {% if order.user %} + {{ order.user.email }} + {% elif order.guest_user %} + {{ order.guest_user.email }} (Guest) + {% else %} + Unknown + {% endif %} + + {% for item in order.items.all %} + {{ item.quantity }}x {{ item.product.title }} + {% if item.color %} ({{ item.color.name }}){% endif %} + {% if item.size %} [{{ item.size.name }}]{% endif %} +
+ {% endfor %} +
No orders found
+ + +
+{% endblock %} From b9c384e769c04b5f46b0abe54e123a45f4b65070 Mon Sep 17 00:00:00 2001 From: Laurent Date: Mon, 7 Apr 2025 17:47:21 +0200 Subject: [PATCH 04/87] Fix typo --- tournaments/templates/registration/login.html | 2 +- tournaments/templates/tournaments/tournament_info.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tournaments/templates/registration/login.html b/tournaments/templates/registration/login.html index 2188b41..d4ac8e7 100644 --- a/tournaments/templates/registration/login.html +++ b/tournaments/templates/registration/login.html @@ -43,7 +43,7 @@

-

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.should_request_payment and cart_data.waiting_list_position < 0 %} + + {% else %} + + {% 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 %} - {% 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 %} -
{% csrf_token %}