timetoconfirm
Raz 8 months ago
parent d264179306
commit 48638e76f8
  1. 28
      tournaments/migrations/0113_playerregistration_payment_status_and_more.py
  2. 16
      tournaments/models/player_registration.py
  3. 38
      tournaments/models/team_registration.py
  4. 161
      tournaments/models/tournament.py
  5. 56
      tournaments/services/email_service.py
  6. 23
      tournaments/signals.py
  7. 33
      tournaments/templates/tournaments/tournament_info.html
  8. 1
      tournaments/urls.py
  9. 28
      tournaments/views.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),
),
]

@ -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

@ -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()

@ -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):

@ -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)

@ -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)

@ -44,6 +44,39 @@
<div>Inscrits le {{ team.local_registration_date }}</div>
</p>
{% if team and team.needs_confirmation %}
<div class="alert {% if team.get_confirmation_deadline %}alert-warning{% else %}alert-info{% endif %}">
<h4 class="semibold">Confirmation requise</h4>
<p>Votre place dans le tournoi a été libérée suite à une désinscription.</p>
{% 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 %}
<p>Vous devez confirmer votre participation avant le:</p>
<p class="semibold highlight">{{ time_to_confirm|date:"d/m/Y à H:i" }}</p>
{% with time_diff=time_to_confirm|timeuntil:time_now %}
<p>Temps restant: <span class="countdown-timer">{{ time_diff }}</span></p>
{% endwith %}
{% else %}
<p class="alert alert-danger">
Le délai de confirmation est expiré. Votre place a été proposée à une autre équipe.
</p>
{% endif %}
{% endwith %}
{% endtimezone %}
{% endif %}
<form method="post" action="{% url 'confirm_tournament_registration' tournament.id %}">
{% csrf_token %}
<button type="submit" class="rounded-button positive-button">
Confirmer ma participation
</button>
</form>
</div>
{% endif %}
{% if tournament.is_unregistration_possible %}
<p>

@ -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/<str:tournament_id>/confirm/', views.confirm_tournament_registration, name='confirm_tournament_registration'),
]

@ -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')

Loading…
Cancel
Save