You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2466 lines
99 KiB
2466 lines
99 KiB
from zoneinfo import ZoneInfo
|
|
from django.db import models
|
|
from . import BaseModel, Event, TournamentPayment, FederalMatchCategory, FederalCategory, FederalLevelCategory, FederalAgeCategory, OnlineRegistrationStatus, RegistrationStatus, AnimationType
|
|
|
|
import uuid
|
|
from django.utils import timezone, formats
|
|
from datetime import datetime, timedelta, time
|
|
from tournaments.utils.player_search import get_player_name_from_csv
|
|
from shared.cryptography import encryption_util
|
|
from ..utils.extensions import plural_format
|
|
from django.utils.formats import date_format
|
|
from ..utils.licence_validator import LicenseValidator
|
|
from django.apps import apps
|
|
from django.conf import settings
|
|
from tournaments.services.currency_service import CurrencyService
|
|
|
|
class TeamSortingType(models.IntegerChoices):
|
|
RANK = 1, 'Rank'
|
|
INSCRIPTION_DATE = 2, 'Inscription Date'
|
|
|
|
class Tournament(BaseModel):
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
|
|
event = models.ForeignKey(Event, blank=True, null=True, on_delete=models.SET_NULL, related_name='tournaments')
|
|
name = models.CharField(max_length=200, null=True, blank=True)
|
|
start_date = models.DateTimeField()
|
|
end_date = models.DateTimeField(null=True, blank=True)
|
|
creation_date = models.DateTimeField()
|
|
is_private = models.BooleanField(default=False)
|
|
# format = models.IntegerField(default=FederalMatchCategory.NINE_GAMES, choices=FederalMatchCategory.choices, null=True, blank=True)
|
|
round_format = models.IntegerField(default=FederalMatchCategory.NINE_GAMES, choices=FederalMatchCategory.choices, null=True, blank=True)
|
|
group_stage_format = models.IntegerField(default=FederalMatchCategory.NINE_GAMES, choices=FederalMatchCategory.choices, null=True, blank=True)
|
|
loser_round_format = models.IntegerField(default=FederalMatchCategory.NINE_GAMES, choices=FederalMatchCategory.choices, null=True, blank=True)
|
|
group_stage_sort_mode = models.IntegerField(default=0)
|
|
group_stage_count = models.IntegerField(default=0)
|
|
rank_source_date = models.DateTimeField(null=True, blank=True)
|
|
day_duration = models.IntegerField(default=0)
|
|
team_count = models.IntegerField(default=0)
|
|
team_sorting = models.IntegerField(default=TeamSortingType.INSCRIPTION_DATE, choices=TeamSortingType.choices)
|
|
federal_category = models.IntegerField(default=FederalCategory.MEN, choices=FederalCategory.choices) # optional ?
|
|
federal_level_category = models.IntegerField(default=FederalLevelCategory.P100, choices=FederalLevelCategory.choices)
|
|
federal_age_category = models.IntegerField(default=FederalAgeCategory.SENIOR, choices=FederalAgeCategory.choices)
|
|
#group_stage_court_count = models.IntegerField(null=True, blank=True)
|
|
#seed_count = models.IntegerField(default=0)
|
|
closed_registration_date = models.DateTimeField(null=True, blank=True)
|
|
group_stage_additional_qualified = models.IntegerField(default=0)
|
|
court_count = models.IntegerField(default=2)
|
|
prioritize_club_members = models.BooleanField()
|
|
qualified_per_group_stage = models.IntegerField(default=0)
|
|
teams_per_group_stage = models.IntegerField(default=0)
|
|
entry_fee = models.FloatField(default=20.0, null=True, blank=True)
|
|
global_id = models.CharField(max_length=100, null=True, blank=True) #represent the payment crypted string
|
|
is_deleted = models.BooleanField(default=False)
|
|
local_id = models.CharField(max_length=100, null=True, blank=True) #represent the is_canceled crypted string
|
|
additional_estimation_duration = models.IntegerField(default=0)
|
|
publish_teams = models.BooleanField(default=False)
|
|
hide_teams_weight = models.BooleanField(default=False)
|
|
publish_summons = models.BooleanField(default=False)
|
|
publish_group_stages = models.BooleanField(default=False)
|
|
publish_brackets = models.BooleanField(default=False)
|
|
should_verify_bracket = models.BooleanField(default=False)
|
|
should_verify_group_stage = models.BooleanField(default=False)
|
|
publish_tournament = models.BooleanField(default=False)
|
|
hide_points_earned = models.BooleanField(default=False)
|
|
publish_rankings = models.BooleanField(default=False)
|
|
loser_bracket_mode = models.IntegerField(default=0)
|
|
initial_seed_round = models.IntegerField(default=0)
|
|
initial_seed_count = models.IntegerField(default=0)
|
|
enable_online_registration = models.BooleanField(default=False) # Equivalent to Bool = false
|
|
team_count_limit = models.BooleanField(default=True)
|
|
registration_date_limit = models.DateTimeField(null=True, blank=True) # Equivalent to Date? = nil
|
|
opening_registration_date = models.DateTimeField(null=True, blank=True) # Equivalent to Date? = nil
|
|
waiting_list_limit = models.IntegerField(null=True, blank=True) # Equivalent to Int? = nil
|
|
account_is_required = models.BooleanField(default=True)
|
|
license_is_required = models.BooleanField(default=True)
|
|
minimum_player_per_team = models.IntegerField(default=2)
|
|
maximum_player_per_team = models.IntegerField(default=2)
|
|
information = models.CharField(max_length=4000, null=True, blank=True)
|
|
umpire_custom_mail = models.EmailField(null=True, blank=True)
|
|
umpire_custom_contact = models.CharField(max_length=200, null=True, blank=True)
|
|
umpire_custom_phone = models.CharField(max_length=15, null=True, blank=True)
|
|
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)
|
|
enable_online_payment = models.BooleanField(default=False)
|
|
online_payment_is_mandatory = models.BooleanField(default=False)
|
|
enable_online_payment_refund = models.BooleanField(default=False)
|
|
refund_date_limit = models.DateTimeField(null=True, blank=True) # Equivalent to Date? = nil
|
|
stripe_account_id = models.CharField(max_length=255, blank=True, null=True)
|
|
enable_time_to_confirm = models.BooleanField(default=False)
|
|
is_corporate_tournament = models.BooleanField(default=False)
|
|
is_template = models.BooleanField(default=False)
|
|
animation_type = models.IntegerField(default=AnimationType.TOURNAMENT, choices=AnimationType.choices)
|
|
publish_prog = models.BooleanField(default=False)
|
|
show_teams_in_prog = models.BooleanField(default=False)
|
|
club_member_fee_deduction = models.FloatField(null=True, blank=True)
|
|
unregister_delta_in_hours = models.IntegerField(default=24)
|
|
currency_code = models.CharField(null=True, blank=True, max_length=3, default='EUR')
|
|
# parent = models.ForeignKey('self', blank=True, null=True, on_delete=models.SET_NULL, related_name='children')
|
|
# loser_index = models.IntegerField(default=0)
|
|
custom_club_name = models.CharField(null=True, blank=True, max_length=100)
|
|
|
|
def delete_dependencies(self):
|
|
for team_registration in self.team_registrations.all():
|
|
team_registration.delete_dependencies()
|
|
team_registration.delete()
|
|
for gs in self.group_stages.all():
|
|
gs.delete_dependencies()
|
|
gs.delete()
|
|
for round in self.rounds.filter(parent=None).all():
|
|
round.delete_dependencies()
|
|
round.delete()
|
|
for draw_log in self.draw_logs.all():
|
|
# draw_log.delete_dependencies()
|
|
draw_log.delete()
|
|
|
|
def __str__(self):
|
|
if self.name:
|
|
return self.name
|
|
else:
|
|
return self.display_name()
|
|
|
|
def is_canceled(self):
|
|
if self.local_id:
|
|
decrypted = encryption_util.decrypt_aes_gcm(self.local_id)
|
|
value = int(decrypted[18])
|
|
if 0 <= value <= 4:
|
|
return True
|
|
else:
|
|
return False
|
|
else:
|
|
return False
|
|
|
|
def payment(self):
|
|
if self.global_id:
|
|
decrypted = encryption_util.decrypt_aes_gcm(self.global_id)
|
|
value = int(decrypted[18])
|
|
return TournamentPayment(value)
|
|
else:
|
|
return None
|
|
|
|
def display_name(self):
|
|
if self.name:
|
|
return self.short_base_name() + " " + self.name
|
|
else:
|
|
return self.base_name()
|
|
|
|
def broadcast_display_name(self):
|
|
if self.name:
|
|
return self.short_base_name() + " " + self.name
|
|
else:
|
|
return self.base_name()
|
|
|
|
def broadcast_event_display_name(self):
|
|
if self.event is not None:
|
|
return self.event.display_name()
|
|
else:
|
|
return " "
|
|
|
|
def base_name(self):
|
|
return f"{self.level()} {self.category()}"
|
|
|
|
def full_name(self):
|
|
age = self.age()
|
|
str = f"{self.level()} {self.category()}"
|
|
if self.name:
|
|
str = f"{self.level()} {self.name} {self.category()}"
|
|
if age is not None:
|
|
str = f"{str} {age}"
|
|
return str
|
|
|
|
def short_full_name(self):
|
|
# For animation tournaments with custom names, just return the name
|
|
if self.federal_level_category == 0 and self.name: # FederalLevelCategory.UNLISTED (Animation) with custom name
|
|
return self.name
|
|
|
|
age = self.age()
|
|
str = f"{self.level()}{self.category()[0]}"
|
|
if age is not None and self.federal_age_category != 200:
|
|
str = f"{str} {age}"
|
|
return str
|
|
|
|
def short_base_name(self):
|
|
category = self.category()
|
|
if len(category) > 0 and self.federal_level_category > 1:
|
|
return f"{self.short_level()}{category[0]}"
|
|
else:
|
|
return self.short_level()
|
|
|
|
def filter_name(self):
|
|
components = [self.formatted_start_date(), self.short_base_name()]
|
|
if self.event and self.event.club and self.event.club.name:
|
|
components.append(self.event.club.name)
|
|
elif self.event and self.event.name:
|
|
components.append(self.event.name)
|
|
elif self.name:
|
|
components.append(self.name)
|
|
return (' ').join(components)
|
|
|
|
def timezone(self):
|
|
tz = 'CET'
|
|
if self.event and self.event.club:
|
|
tz = self.event.club.timezone
|
|
return ZoneInfo(tz)
|
|
|
|
def local_start_date(self):
|
|
timezone = self.timezone()
|
|
return self.start_date.astimezone(timezone)
|
|
|
|
def local_end_date(self):
|
|
timezone = self.timezone()
|
|
if self.end_date:
|
|
return self.end_date.astimezone(timezone)
|
|
else:
|
|
return None
|
|
|
|
def local_start_date_formatted(self):
|
|
return formats.date_format(self.local_start_date(), format='l j F Y H:i').capitalize()
|
|
|
|
def level(self):
|
|
return self.get_federal_level_category_display()
|
|
|
|
def is_custom_animation(self):
|
|
return self.animation_type == AnimationType.CUSTOM
|
|
|
|
def short_level(self):
|
|
if self.federal_level_category == 0:
|
|
match self.animation_type:
|
|
case AnimationType.TOURNAMENT:
|
|
return "Anim."
|
|
case AnimationType.MELEE:
|
|
return "Mêlée"
|
|
case AnimationType.LOSER_BRACKET:
|
|
return "Classement"
|
|
case AnimationType.CONSOLATION_BRACKET:
|
|
return "Consolante"
|
|
case AnimationType.CUSTOM:
|
|
return "Soirée"
|
|
case _:
|
|
return "Anim."
|
|
if self.federal_level_category == 1:
|
|
return "CHPT"
|
|
return self.get_federal_level_category_display()
|
|
|
|
def category(self):
|
|
if self.federal_age_category > 100 and self.federal_age_category < 200:
|
|
if self.federal_category == 0:
|
|
return "Garçon"
|
|
if self.federal_category == 1:
|
|
return "Fille"
|
|
return self.get_federal_category_display()
|
|
|
|
def age(self):
|
|
if self.federal_age_category == 0:
|
|
return None
|
|
return self.get_federal_age_category_display()
|
|
|
|
def formatted_start_date(self):
|
|
return self.local_start_date().strftime("%d/%m/%y")
|
|
|
|
def in_progress(self):
|
|
return self.end_date is None
|
|
|
|
def sorting_finished_date(self):
|
|
if self.end_date:
|
|
return self.end_date
|
|
else:
|
|
return self.start_date
|
|
|
|
def creator(self):
|
|
if self.event and self.event.creator:
|
|
return self.event.creator.username
|
|
else:
|
|
return None
|
|
|
|
def private_label(self):
|
|
if self.is_private:
|
|
return "Privé"
|
|
else:
|
|
return "Public"
|
|
|
|
def summon_count_display(self):
|
|
teams = self.team_summons()
|
|
if teams is not None and len(teams) > 0:
|
|
return f"{len(teams)} équipes convoquées"
|
|
else:
|
|
return None
|
|
|
|
def ranking_count_display(self):
|
|
teams = self.rankings()
|
|
if teams is not None and len(teams) > 0:
|
|
return f"{len(teams)} équipes"
|
|
else:
|
|
return None
|
|
|
|
def registration_count_display(self):
|
|
teams = self.teams(True)
|
|
if teams is not None and len(teams) == 1:
|
|
return f"{len(teams)} équipe inscrite"
|
|
elif teams is not None and len(teams) > 1:
|
|
return f"{len(teams)} équipes inscrites"
|
|
else:
|
|
return None
|
|
|
|
def get_tournament_status(self):
|
|
return self.get_online_registration_status().status_localized()
|
|
|
|
def is_team_tournament(self):
|
|
return self.minimum_player_per_team >= 2
|
|
|
|
def get_tournament_status_registration_count(self):
|
|
active_teams_count = self.team_registrations.filter(walk_out=False).count()
|
|
if self.is_team_tournament() is False:
|
|
# Count players instead of teams when minimum players per team is under 2
|
|
PlayerRegistration = apps.get_model('tournaments', 'PlayerRegistration')
|
|
active_players_count = PlayerRegistration.objects.filter(
|
|
team_registration__tournament=self,
|
|
team_registration__walk_out=False
|
|
).count()
|
|
return active_players_count
|
|
return min(active_teams_count, self.team_count)
|
|
|
|
def name_and_event(self):
|
|
event_name = None
|
|
if self.event:
|
|
event_name = self.event.name
|
|
|
|
if event_name and self.name:
|
|
return event_name + " : " + self.name
|
|
elif event_name:
|
|
return event_name
|
|
elif self.name:
|
|
return self.name
|
|
else:
|
|
return None
|
|
|
|
def team_summons(self):
|
|
summons = []
|
|
if self.supposedly_in_progress() and self.end_date is None:
|
|
# print('>>> team_summons supposedly_in_progress')
|
|
for team in self.teams(False):
|
|
names = team.names
|
|
stage = team.stage
|
|
weight = team.weight
|
|
summon = TeamSummon(team.team_registration.id, names, team.date, weight, stage, "", team.image, self.day_duration)
|
|
summons.append(summon)
|
|
else:
|
|
# print('>>> team_summons')
|
|
for team_registration in self.team_registrations.all():
|
|
if team_registration.is_valid_for_summon():
|
|
next_match = team_registration.next_match()
|
|
if next_match and next_match.start_date is not None:
|
|
names = team_registration.team_names()
|
|
stage = next_match.summon_stage_name()
|
|
weight = team_registration.weight
|
|
summon = TeamSummon(team_registration.id, names, next_match.local_start_date(), weight, stage, next_match.court_name(next_match.court_index), team_registration.logo, self.day_duration)
|
|
summons.append(summon)
|
|
|
|
summons.sort(key=lambda s: (s.date is None, s.date or datetime.min))
|
|
return summons
|
|
|
|
def has_summons(self):
|
|
for team_registration in self.team_registrations.all():
|
|
if team_registration.is_valid_for_summon():
|
|
next_match = team_registration.next_match()
|
|
if next_match and next_match.start_date is not None:
|
|
return True
|
|
return False
|
|
|
|
def rankings(self):
|
|
rankings = []
|
|
for team_registration in self.team_registrations.all():
|
|
if team_registration.walk_out is False and team_registration.final_ranking is not None:
|
|
names = team_registration.team_names()
|
|
ranking = team_registration.final_ranking
|
|
points = team_registration.points_earned
|
|
team = TeamRanking(team_registration.id, names, ranking, points, team_registration.logo)
|
|
rankings.append(team)
|
|
|
|
rankings.sort(key=lambda r: r.ranking)
|
|
return rankings
|
|
|
|
def get_team_waiting_list_position(self, team_registration):
|
|
# Use the teams method to get sorted list of teams
|
|
now_utc = timezone.now()
|
|
current_time = now_utc.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
|
|
|
|
# Check if team_count exists
|
|
if self.team_count_limit == True:
|
|
all_teams = self.teams(True)
|
|
index = -1
|
|
# Find position of team in all teams list
|
|
for i, team in enumerate(all_teams):
|
|
if team.team_registration.id == team_registration.id:
|
|
index = i
|
|
|
|
# Team is not in list
|
|
if index < 0:
|
|
print("get_team_waiting_list_position: Team is not in list", index, self.team_count)
|
|
return -1
|
|
# Return position in waiting list relative to target count
|
|
print("get_team_waiting_list_position: Return position in waiting list relative to target count", index, self.team_count)
|
|
return index - self.team_count
|
|
else:
|
|
print("get_team_waiting_list_position: Return -1", self.team_count)
|
|
return -1
|
|
|
|
def group_stage_spots(self):
|
|
"""Returns the total number of spots in all group stages."""
|
|
return sum(gs.size for gs in self.group_stages.all())
|
|
|
|
def teams(self, include_waiting_list, un_walk_out_team=None):
|
|
complete_teams, wildcard_bracket, wildcard_group_stage, waiting_teams = self.computed_teams(un_walk_out_team)
|
|
all_teams = self.sort_teams(include_waiting_list, complete_teams, wildcard_bracket, wildcard_group_stage, waiting_teams)
|
|
return all_teams
|
|
|
|
def computed_teams(self, un_walk_out_team=None):
|
|
# Initialize team categories
|
|
complete_teams = []
|
|
wildcard_bracket = []
|
|
wildcard_group_stage = []
|
|
waiting_teams = []
|
|
|
|
# Get registration cutoff date
|
|
closed_date = self.closed_registration_date
|
|
|
|
# Process each team registration
|
|
for db_team_reg in self.team_registrations.all():
|
|
team_reg = db_team_reg
|
|
if un_walk_out_team and team_reg.id == un_walk_out_team.id:
|
|
team_reg = un_walk_out_team
|
|
if team_reg.out_of_tournament():
|
|
continue
|
|
|
|
# Create team item
|
|
team = TeamItem(team_reg)
|
|
|
|
# Determine if registration is valid based on date
|
|
is_valid = (
|
|
closed_date is None or
|
|
team_reg.registration_date is None or
|
|
(team_reg.registration_date and team_reg.registration_date <= closed_date)
|
|
)
|
|
|
|
# Set initial stage
|
|
if team_reg.group_stage_position is not None:
|
|
team.set_stage("Poule")
|
|
elif team_reg.bracket_position is not None:
|
|
team.set_stage("Tableau")
|
|
else:
|
|
team.set_stage("Attente")
|
|
|
|
# Categorize team
|
|
if team_reg.wild_card_bracket:
|
|
wildcard_bracket.append(team)
|
|
elif team_reg.wild_card_group_stage:
|
|
wildcard_group_stage.append(team)
|
|
elif is_valid:
|
|
complete_teams.append(team)
|
|
else:
|
|
waiting_teams.append(team)
|
|
|
|
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):
|
|
# Initialize group stage spots
|
|
group_stage_spots = self.group_stage_spots()
|
|
bracket_seeds = self.team_count - group_stage_spots - len(wildcard_bracket)
|
|
group_stage_team_count = group_stage_spots - len(wildcard_group_stage)
|
|
if group_stage_team_count < 0:
|
|
group_stage_team_count = 0
|
|
if bracket_seeds < 0:
|
|
bracket_seeds = 0
|
|
|
|
# Sort teams based on tournament rules
|
|
if self.team_sorting == TeamSortingType.INSCRIPTION_DATE:
|
|
complete_teams.sort(key=lambda t: (
|
|
t.registration_date is None,
|
|
t.registration_date or datetime.min,
|
|
t.initial_weight,
|
|
t.team_registration.unique_random_index,
|
|
t.team_registration.id
|
|
))
|
|
waiting_teams.sort(key=lambda t: (
|
|
t.registration_date is None,
|
|
t.registration_date or datetime.min,
|
|
t.initial_weight,
|
|
t.team_registration.unique_random_index,
|
|
t.team_registration.id
|
|
))
|
|
else:
|
|
complete_teams.sort(key=lambda t: (t.initial_weight, t.team_registration.unique_random_index, t.team_registration.id))
|
|
waiting_teams.sort(key=lambda t: (t.initial_weight, t.team_registration.unique_random_index, t.team_registration.id))
|
|
|
|
wildcard_group_stage.sort(key=lambda t: (t.initial_weight, t.team_registration.unique_random_index, t.team_registration.id))
|
|
wildcard_bracket.sort(key=lambda t: (t.initial_weight, t.team_registration.unique_random_index, t.team_registration.id))
|
|
|
|
# Split teams into main bracket and waiting list
|
|
computed_team_count = self.team_count - len(wildcard_bracket) - len(wildcard_group_stage)
|
|
if computed_team_count < 0:
|
|
computed_team_count = 0
|
|
qualified_teams = complete_teams[:computed_team_count]
|
|
excess_teams = complete_teams[computed_team_count:]
|
|
|
|
qualified_teams.sort(key=lambda t: (t.initial_weight, t.team_registration.unique_random_index, t.team_registration.id))
|
|
excess_teams.sort(key=lambda t: (t.initial_weight, t.team_registration.unique_random_index, t.team_registration.id))
|
|
|
|
# Combine all waiting list teams
|
|
waiting_list = excess_teams + waiting_teams
|
|
if self.team_sorting == TeamSortingType.INSCRIPTION_DATE:
|
|
waiting_list.sort(key=lambda t: (
|
|
t.registration_date is None,
|
|
t.registration_date or datetime.min,
|
|
t.initial_weight,
|
|
t.team_registration.unique_random_index,
|
|
t.team_registration.id
|
|
))
|
|
else:
|
|
waiting_list.sort(key=lambda t: (t.initial_weight, t.team_registration.unique_random_index, t.team_registration.id))
|
|
|
|
# Return final sorted list
|
|
bracket_teams = qualified_teams[:bracket_seeds] + wildcard_bracket
|
|
gs_teams = qualified_teams[bracket_seeds:(bracket_seeds+group_stage_team_count)] + wildcard_group_stage
|
|
bracket_teams.sort(key=lambda t: (t.initial_weight, t.team_registration.unique_random_index, t.team_registration.id))
|
|
gs_teams.sort(key=lambda t: (t.initial_weight, t.team_registration.unique_random_index, t.team_registration.id))
|
|
all_teams = bracket_teams + gs_teams
|
|
for team in bracket_teams:
|
|
team.set_stage("Tableau")
|
|
for team in gs_teams:
|
|
team.set_stage("Poule")
|
|
|
|
if include_waiting_list:
|
|
all_teams.extend(waiting_list)
|
|
return all_teams
|
|
|
|
def match_groups(self, broadcasted, group_stage_id, round_id):
|
|
|
|
display_brackets = self.display_matches()
|
|
|
|
match_groups = []
|
|
if group_stage_id:
|
|
# Use prefetched data instead of additional query
|
|
group_stage = next((gs for gs in self.group_stages.all() if str(gs.id) == group_stage_id), None)
|
|
if group_stage:
|
|
match_groups.append(self.group_stage_match_group(group_stage, broadcasted, hide_empty_matches=False))
|
|
elif round_id:
|
|
# Use prefetched data instead of additional query
|
|
round = next((r for r in self.rounds.all() if str(r.id) == round_id), None)
|
|
if round and display_brackets is True:
|
|
match_groups = self.round_match_groups(round, broadcasted, hide_empty_matches=False)
|
|
else:
|
|
match_groups = self.all_groups(broadcasted)
|
|
|
|
return match_groups
|
|
|
|
def all_groups(self, broadcasted):
|
|
groups = []
|
|
|
|
if self.display_matches():
|
|
# Use already prefetched rounds to avoid additional queries
|
|
bracket_rounds = [r for r in self.rounds.all() if r.parent is None and not r.group_stage_loser_bracket]
|
|
bracket_rounds.sort(key=lambda r: r.index)
|
|
for round in bracket_rounds:
|
|
groups.extend(self.round_match_groups(round, broadcasted, hide_empty_matches=True))
|
|
|
|
if self.display_group_stages():
|
|
# Use already prefetched rounds to avoid additional queries
|
|
loser_bracket_rounds = [r for r in self.rounds.all() if r.parent is None and r.group_stage_loser_bracket]
|
|
loser_bracket_rounds.sort(key=lambda r: r.index)
|
|
for round in loser_bracket_rounds:
|
|
groups.extend(self.round_match_groups(round, broadcasted, hide_empty_matches=True))
|
|
|
|
group_stages = sorted(self.sorted_group_stages(), key=lambda s: (-s.step, s.index))
|
|
for group_stage in group_stages:
|
|
group = self.group_stage_match_group(group_stage, broadcasted, hide_empty_matches=True)
|
|
if group:
|
|
groups.append(group)
|
|
|
|
return groups
|
|
|
|
def group_stage_match_group(self, group_stage, broadcasted, hide_empty_matches):
|
|
|
|
if group_stage is None:
|
|
return None
|
|
matches = group_stage.matches.all()
|
|
if hide_empty_matches:
|
|
matches = [m for m in matches if m.should_appear()]
|
|
else:
|
|
matches = [m for m in matches if m.disabled is False]
|
|
|
|
matches.sort(key=lambda m: (m.start_date is None, m.end_date is not None, m.start_date, m.index))
|
|
|
|
if matches:
|
|
return self.create_match_group(group_stage.display_name(), matches)
|
|
else:
|
|
return None
|
|
|
|
def round_match_groups(self, round, broadcasted, hide_empty_matches):
|
|
groups = []
|
|
|
|
matches = round.matches.order_by('index').all()
|
|
if hide_empty_matches:
|
|
matches = [m for m in matches if m.should_appear()]
|
|
else:
|
|
matches = [m for m in matches if m.disabled is False]
|
|
|
|
if round and matches:
|
|
matches.sort(key=lambda m: m.index)
|
|
group = self.create_match_group(round.plural_name(), matches)
|
|
groups.append(group)
|
|
|
|
ranking_matches = round.ranking_matches(hide_empty_matches)
|
|
if hide_empty_matches:
|
|
ranking_matches = [m for m in ranking_matches if m.should_appear()]
|
|
else:
|
|
ranking_matches = [m for m in ranking_matches if m.disabled is False]
|
|
|
|
if len(ranking_matches) > 0:
|
|
ranking_matches.sort(
|
|
key=lambda m: (
|
|
m.round.index, # Sort by Round index first
|
|
m.round.get_depth(),
|
|
m.name or '', # Then by Round depth, using empty string if name is None
|
|
)
|
|
)
|
|
group = self.create_match_group('Matchs de classement', ranking_matches)
|
|
groups.append(group)
|
|
|
|
return groups
|
|
|
|
def create_match_group(self, name, matches, round_id=None, round_index=None, hide_teams=False, event_mode=False, short_names=False, broadcast=False):
|
|
matches = list(matches)
|
|
live_matches = [match.live_match(hide_teams, event_mode, short_names, broadcast) for match in matches]
|
|
# Filter out matches that have a start_date of None
|
|
valid_matches = [match for match in matches if match.start_date is not None]
|
|
|
|
formatted_schedule = ''
|
|
if valid_matches and self.day_duration >= 7:
|
|
# Find the first match by start date
|
|
first_match = min(valid_matches, key=lambda match: match.start_date)
|
|
|
|
# Format the date
|
|
timezone = first_match.get_tournament().timezone()
|
|
local_start = first_match.start_date.astimezone(timezone)
|
|
time_format = 'l d F'
|
|
formatted_schedule = f" - {formats.date_format(local_start, format=time_format)}"
|
|
|
|
return MatchGroup(name, live_matches, formatted_schedule, round_id, round_index)
|
|
|
|
def live_group_stages(self):
|
|
group_stages = self.sorted_group_stages()
|
|
return [gs.live_group_stages() for gs in group_stages]
|
|
|
|
def sorted_group_stages(self):
|
|
# Use already prefetched group stages to avoid additional queries
|
|
group_stages = list(self.group_stages.all())
|
|
group_stages.sort(key=lambda gs: (-gs.step, gs.index))
|
|
|
|
# List to collect live group stages from finished steps
|
|
filtered = []
|
|
steps_completed = set()
|
|
|
|
for group_stage in group_stages:
|
|
if group_stage.step > 0:
|
|
# Check the previous step's group stages using already loaded data
|
|
prev_step = group_stage.step - 1
|
|
if prev_step not in steps_completed:
|
|
previous_step_group_stages = [gs for gs in group_stages if gs.step == prev_step]
|
|
# Check if all previous step group stages are completed
|
|
if previous_step_group_stages and all(gs.is_completed() for gs in previous_step_group_stages):
|
|
steps_completed.add(prev_step)
|
|
|
|
if prev_step in steps_completed:
|
|
filtered.append(group_stage)
|
|
else:
|
|
# Always include step 0
|
|
filtered.append(group_stage)
|
|
|
|
return filtered
|
|
|
|
def get_previous_live_group_stages(self, step):
|
|
previous_step_group_stages = self.group_stages.filter(step=step).order_by('index')
|
|
return [gs.live_group_stages() for gs in previous_step_group_stages]
|
|
|
|
def last_group_stage_step(self):
|
|
live_group_stages = self.sorted_group_stages()
|
|
|
|
# Filter to find the last running step
|
|
last_running_step = max(gs.step for gs in live_group_stages) if live_group_stages else None
|
|
|
|
if last_running_step is not None:
|
|
# Get only group stages from the last running step
|
|
group_stages_last_step = [gs for gs in live_group_stages if gs.step == last_running_step]
|
|
return group_stages_last_step
|
|
else:
|
|
return []
|
|
|
|
def broadcast_content(self):
|
|
|
|
matches, group_stages = self.broadcasted_matches_and_group_stages()
|
|
group_stages_dicts = [gs.to_dict() for gs in group_stages]
|
|
|
|
# if now is before the first match, we want to show the summons + group stage or first matches
|
|
# change timezone to datetime to avoid the bug RuntimeWarning: DateTimeField Tournament.start_date received a naive datetime (2024-05-16 00:00:00) while time zone support is active.
|
|
current_time = timezone.localtime()
|
|
tournament_start = self.local_start_date()
|
|
one_hour_before_start = tournament_start - timedelta(hours=1)
|
|
|
|
if current_time < one_hour_before_start:
|
|
team_summons_dicts = [summon.to_dict() for summon in self.team_summons()]
|
|
if group_stages:
|
|
return {
|
|
'matches': [],
|
|
'group_stages': group_stages_dicts,
|
|
'summons': team_summons_dicts,
|
|
'event_title' : self.broadcast_event_display_name(),
|
|
'tournament_title' : self.broadcast_display_name(),
|
|
'rankings' : []
|
|
}
|
|
else:
|
|
live_matches_dicts = [match.live_match().to_dict() for match in matches]
|
|
return {
|
|
'matches': live_matches_dicts,
|
|
'group_stages': [],
|
|
'summons': team_summons_dicts,
|
|
'event_title' : self.broadcast_event_display_name(),
|
|
'tournament_title' : self.broadcast_display_name(),
|
|
'rankings' : []
|
|
}
|
|
elif self.end_date is not None:
|
|
live_matches_dicts = [match.live_match().to_dict() for match in matches]
|
|
team_rankings_dicts = [ranking.to_dict() for ranking in self.rankings()]
|
|
return {
|
|
'matches': live_matches_dicts,
|
|
'group_stages': [],
|
|
'summons': [],
|
|
'event_title' : self.broadcast_event_display_name(),
|
|
'tournament_title' : self.broadcast_display_name(),
|
|
'rankings' : team_rankings_dicts
|
|
}
|
|
else: # we want to display the broadcasted content
|
|
live_matches_dicts = [match.live_match().to_dict() for match in matches]
|
|
return {
|
|
'matches': live_matches_dicts,
|
|
'group_stages': group_stages_dicts,
|
|
'summons': [],
|
|
'rankings' : [],
|
|
'event_title' : self.broadcast_event_display_name(),
|
|
'tournament_title' : self.broadcast_display_name(),
|
|
}
|
|
|
|
def broadcasted_matches_and_group_stages(self):
|
|
matches = []
|
|
group_stages = []
|
|
|
|
if self.group_stages.count() > 0 and self.no_bracket_match_has_started():
|
|
group_stages = [gs.live_group_stages() for gs in self.last_group_stage_step()]
|
|
matches = self.broadcasted_group_stages_matches()
|
|
first_round = self.first_round()
|
|
if first_round and self.has_all_group_stages_started():
|
|
matches.extend(first_round.get_matches_recursive(True))
|
|
else:
|
|
current_round = self.round_to_show()
|
|
if current_round:
|
|
#print(f'current_round = {current_round.index} / parent = {current_round.parent}')
|
|
all_upper_matches_are_over = current_round.all_matches_are_over()
|
|
if all_upper_matches_are_over is False:
|
|
matches.extend(current_round.get_matches_recursive(True))
|
|
|
|
# Add full matches from the next rounds
|
|
next_round = self.round_for_index(current_round.index - 1)
|
|
if next_round:
|
|
# print('next round')
|
|
matches.extend(next_round.get_matches_recursive(True))
|
|
|
|
if all_upper_matches_are_over is True:
|
|
# print('all_upper_matches_are_over')
|
|
matches.extend(current_round.get_matches_recursive(True))
|
|
|
|
# Add matches from the previous round or group_stages
|
|
previous_round = self.round_for_index(current_round.index + 1)
|
|
if previous_round:
|
|
# print('previous_round')
|
|
matches.extend(previous_round.get_matches_recursive(True))
|
|
|
|
previous_previous_round = self.round_for_index(current_round.index + 2)
|
|
if previous_previous_round:
|
|
previous_previous_matches = previous_previous_round.get_matches_recursive(True)
|
|
previous_previous_matches = [m for m in previous_previous_matches if m.end_date is None]
|
|
matches.extend(previous_previous_matches)
|
|
else:
|
|
#print('group_stages')
|
|
group_stages = [gs.live_group_stages() for gs in self.last_group_stage_step()]
|
|
else:
|
|
first_round = self.first_round()
|
|
if first_round:
|
|
matches.extend(first_round.get_matches_recursive(True))
|
|
|
|
return matches, group_stages
|
|
|
|
def no_bracket_match_has_started(self):
|
|
matches = []
|
|
for round in self.rounds.all():
|
|
for match in round.matches.all():
|
|
if match.started():
|
|
return False
|
|
return True
|
|
|
|
def all_matches(self, hide_empty_matches):
|
|
matches = []
|
|
for round in self.rounds.all():
|
|
matches.extend(round.all_matches(hide_empty_matches))
|
|
for group_stage in self.group_stages.all():
|
|
matches.extend(group_stage.matches.all())
|
|
|
|
matches = [m for m in matches if m.should_appear()]
|
|
|
|
return matches
|
|
|
|
def group_stage_matches(self):
|
|
matches = []
|
|
for group_stage in self.group_stages.all():
|
|
matches.extend(group_stage.matches.all())
|
|
return matches
|
|
|
|
def group_stages_running(self):
|
|
if len(self.group_stages.all()) > 0:
|
|
# check le debut des match de Round
|
|
matches = self.group_stage_matches()
|
|
running_group_stage_matches = [m for m in matches if m.end_date is None]
|
|
return len(running_group_stage_matches) > 0
|
|
else:
|
|
return False
|
|
|
|
def first_unfinished_match(self):
|
|
matches = [m for m in self.all_matches(False) if m.start_date and m.end_date is None]
|
|
# print(f'first_unfinished_match > match len: {len(matches)}')
|
|
matches.sort(key=lambda m: m.start_date)
|
|
main_bracket_matches = [m for m in matches if m.round and m.round.parent is None]
|
|
if main_bracket_matches:
|
|
return main_bracket_matches[0]
|
|
|
|
if matches:
|
|
return matches[0]
|
|
else:
|
|
return None
|
|
|
|
def round_to_show(self):
|
|
# print('===== round_to_show')
|
|
last_started_match = self.first_unfinished_match()
|
|
if last_started_match and last_started_match.round:
|
|
# print(f'last_started_match = {last_started_match.name}')
|
|
current_round = last_started_match.round.root_round()
|
|
# print(f'round_to_show > current_round: {current_round.name()}')
|
|
if current_round:
|
|
return current_round
|
|
|
|
# all started matches have ended, possibly
|
|
last_finished_match = self.last_finished_match()
|
|
if last_finished_match:
|
|
round = last_finished_match.round
|
|
if round is None: # when the last finished match is in the group stage
|
|
round = self.rounds.filter(parent__isnull=True).order_by('-index').first()
|
|
|
|
if round:
|
|
# print(f'last_finished_match = {last_finished_match.name}')
|
|
round_root_index = round.root_round().index
|
|
# print(f'round_index = {round_root_index}')
|
|
if round_root_index == 0:
|
|
return round
|
|
else:
|
|
round = self.rounds.filter(parent=None,index=round_root_index-1).first()
|
|
if round:
|
|
return round
|
|
else:
|
|
return None
|
|
return None
|
|
|
|
def last_started_match(self):
|
|
matches = [m for m in self.all_matches(False) if m.start_date]
|
|
matches.sort(key=lambda m: m.start_date, reverse=True)
|
|
return matches[0] if matches else None
|
|
|
|
def last_finished_match(self):
|
|
matches = [m for m in self.all_matches(False) if m.end_date]
|
|
matches.sort(key=lambda m: m.end_date, reverse=True)
|
|
return matches[0] if matches else None
|
|
|
|
def round_for_index(self, index):
|
|
return self.rounds.filter(index=index, parent=None).first()
|
|
|
|
def first_round(self):
|
|
main_rounds = list(self.rounds.filter(parent=None))
|
|
main_rounds.sort(key=lambda r: r.index, reverse=True)
|
|
return main_rounds[0] if main_rounds else None
|
|
|
|
def broadcasted_group_stages_matches(self):
|
|
matches = []
|
|
group_stages = self.elected_broadcast_group_stages()
|
|
group_stages.sort(key=lambda gs: (gs.index, gs.start_date is None, gs.start_date))
|
|
for group_stage in group_stages:
|
|
|
|
matches.extend(group_stage.matches.all())
|
|
|
|
if len(matches) > 16:
|
|
# if more than 16 groupstage matches
|
|
now = timezone.now()
|
|
future_threshold = now + timedelta(hours=1)
|
|
past_threshold = now - timedelta(hours=1)
|
|
matches = [m for m in matches if m.should_appear() and
|
|
(m.start_date is None or m.start_date <= future_threshold) and # Not starting in more than 1h
|
|
(m.end_date is None or m.end_date >= past_threshold)] # Not finished for more than 1h
|
|
matches.sort(key=lambda m: (m.start_date is None)) # display started matches
|
|
matches = matches[:16]
|
|
matches.sort(key=lambda m: (m.start_date is None, m.end_date is not None, m.start_date, m.index))
|
|
|
|
group_stage_loser_bracket = list(self.rounds.filter(parent=None, group_stage_loser_bracket=True).all())
|
|
if len(group_stage_loser_bracket) > 0:
|
|
loser_matches = group_stage_loser_bracket[0].all_matches(True)
|
|
loser_matches = [m for m in loser_matches if m.should_appear()]
|
|
loser_matches.sort(key=lambda m: (m.start_date is None, m.end_date is not None, m.start_date, m.index))
|
|
matches.extend(loser_matches)
|
|
|
|
return matches
|
|
|
|
def elected_broadcast_group_stages(self):
|
|
group_stages = list(self.group_stages.all())
|
|
started = [gs for gs in group_stages if gs.starts_soon()]
|
|
if len(started) > 0:
|
|
return started
|
|
else:
|
|
return group_stages
|
|
|
|
def display_rankings(self):
|
|
if self.supposedly_in_progress():
|
|
return True
|
|
if self.end_date is not None:
|
|
return True
|
|
return False
|
|
|
|
def display_tournament(self):
|
|
if self.publish_tournament or self.enable_online_registration:
|
|
return True
|
|
|
|
is_build_and_not_empty = self.is_build_and_not_empty()
|
|
|
|
if self.end_date is not None:
|
|
return is_build_and_not_empty
|
|
if self.has_started():
|
|
return is_build_and_not_empty
|
|
minimum_publish_date = self.creation_date.replace(hour=7, minute=0) + timedelta(days=1)
|
|
return timezone.now() >= minimum_publish_date
|
|
|
|
def display_teams(self):
|
|
if self.end_date is not None:
|
|
return self.has_team_registrations()
|
|
if self.publish_teams:
|
|
return self.has_team_registrations()
|
|
if self.has_started():
|
|
return self.has_team_registrations()
|
|
if self.will_start_soon(1):
|
|
return True
|
|
return False
|
|
|
|
def display_prog(self):
|
|
if self.publish_prog:
|
|
return True
|
|
if self.has_started():
|
|
return True
|
|
if self.will_start_soon(1):
|
|
return True
|
|
return False
|
|
|
|
def has_team_registrations(self):
|
|
return self.team_registrations.count() > 0
|
|
|
|
def display_summons(self):
|
|
if self.end_date is not None:
|
|
return False
|
|
if self.publish_summons:
|
|
return self.has_summons()
|
|
if self.has_started():
|
|
return self.has_summons()
|
|
if self.will_start_soon(1):
|
|
return True
|
|
return False
|
|
|
|
def display_group_stages(self):
|
|
if len(self.group_stages.all()) == 0:
|
|
return False
|
|
if self.end_date is not None:
|
|
return True
|
|
if self.publish_group_stages:
|
|
return True
|
|
|
|
first_group_stage_start_date = self.group_stage_start_date()
|
|
if first_group_stage_start_date is None:
|
|
return self.has_started()
|
|
else:
|
|
return timezone.now() >= first_group_stage_start_date
|
|
|
|
def group_stage_start_date(self):
|
|
group_stages = [gs for gs in self.group_stages.all() if gs.start_date is not None]
|
|
if len(group_stages) == 0:
|
|
return None
|
|
return min(group_stages, key=lambda gs: gs.start_date).start_date
|
|
|
|
def display_matches(self):
|
|
if self.end_date is not None:
|
|
return True
|
|
bracket_matches = self.bracket_matches()
|
|
if len(bracket_matches) == 0:
|
|
return self.display_group_stages()
|
|
if self.publish_brackets:
|
|
return True
|
|
|
|
first_match_start_date = self.first_match_start_date(bracket_matches)
|
|
if first_match_start_date is None:
|
|
return self.has_started()
|
|
|
|
bracket_start_date = self.getEightAm(first_match_start_date)
|
|
|
|
if bracket_start_date < self.start_date:
|
|
bracket_start_date = self.start_date
|
|
|
|
group_stage_start_date = self.group_stage_start_date()
|
|
|
|
now = timezone.now()
|
|
|
|
if group_stage_start_date is not None:
|
|
if bracket_start_date < group_stage_start_date:
|
|
return now >=first_match_start_date
|
|
|
|
if now >= bracket_start_date:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def bracket_matches(self):
|
|
matches = []
|
|
for round in self.rounds.all():
|
|
matches.extend(round.all_matches(False))
|
|
return matches
|
|
|
|
def first_match_start_date(self, bracket_matches):
|
|
matches = [m for m in bracket_matches if m.start_date is not None]
|
|
if len(matches) == 0:
|
|
return None
|
|
return min(matches, key=lambda m: m.start_date).start_date
|
|
|
|
def getEightAm(self, date):
|
|
return date.replace(hour=8, minute=0, second=0, microsecond=0, tzinfo=date.tzinfo)
|
|
|
|
def has_started(self, hour_delta=None):
|
|
timezoned_datetime = self.local_start_date()
|
|
now_utc = timezone.now()
|
|
now = now_utc.astimezone(self.timezone())
|
|
if hour_delta is not None:
|
|
timezoned_datetime -= timedelta(hours=hour_delta)
|
|
return now >= timezoned_datetime
|
|
|
|
def will_start_soon(self, hour_delta=2):
|
|
return self.has_started(hour_delta=hour_delta)
|
|
|
|
def has_ongoing_matches(self):
|
|
"""
|
|
Returns True if tournament has any matches that are currently ongoing
|
|
"""
|
|
# Check matches in rounds
|
|
for round_obj in self.rounds.all():
|
|
for match in round_obj.matches.all():
|
|
if match.is_ongoing():
|
|
return True
|
|
|
|
# Check matches in group stages
|
|
for group_stage in self.group_stages.all():
|
|
for match in group_stage.matches.all():
|
|
if match.is_ongoing():
|
|
return True
|
|
|
|
return False
|
|
|
|
def has_matches_starting_soon(self):
|
|
"""
|
|
Returns True if tournament has any matches starting within the next hour
|
|
"""
|
|
# Check matches in rounds
|
|
for round_obj in self.rounds.all():
|
|
for match in round_obj.matches.all():
|
|
if match.will_start():
|
|
return True
|
|
|
|
# Check matches in group stages
|
|
for group_stage in self.group_stages.all():
|
|
for match in group_stage.matches.all():
|
|
if match.will_start():
|
|
return True
|
|
|
|
return False
|
|
|
|
def get_next_match_start_time(self):
|
|
"""
|
|
Returns the datetime of the earliest upcoming match, or None if no upcoming matches
|
|
"""
|
|
next_times = []
|
|
|
|
# Check matches in rounds
|
|
for round_obj in self.rounds.all():
|
|
for match in round_obj.matches.all():
|
|
if match.disabled or match.end_date:
|
|
continue
|
|
|
|
if match.start_date and match.start_date > timezone.now():
|
|
next_times.append(match.start_date)
|
|
elif match.planned_start_date and match.planned_start_date > timezone.now():
|
|
next_times.append(match.planned_start_date)
|
|
|
|
# Check matches in group stages
|
|
for group_stage in self.group_stages.all():
|
|
for match in group_stage.matches.all():
|
|
if match.disabled or match.end_date:
|
|
continue
|
|
|
|
if match.start_date and match.start_date > timezone.now():
|
|
next_times.append(match.start_date)
|
|
elif match.planned_start_date and match.planned_start_date > timezone.now():
|
|
next_times.append(match.planned_start_date)
|
|
|
|
return min(next_times) if next_times else None
|
|
|
|
def are_teams_positioned(self):
|
|
teams = self.team_registrations.all()
|
|
filtered_teams = [t for t in teams if t.is_positioned()]
|
|
if len(filtered_teams) > 3:
|
|
return True
|
|
return False
|
|
|
|
def supposedly_in_progress(self):
|
|
start = self.start_date - timedelta(hours=1)
|
|
end = self.start_date + timedelta(days=self.day_duration + 1)
|
|
return start <= timezone.now() <= end
|
|
|
|
# end = self.start_date + timedelta(days=self.day_duration + 1)
|
|
# return self.start_date.replace(hour=0, minute=0) <= timezone.now() <= end
|
|
|
|
# timezoned_datetime = self.local_start_date()
|
|
# end = timezoned_datetime + timedelta(days=self.day_duration + 1)
|
|
# now_utc = timezone.now()
|
|
# now = now_utc.astimezone(self.timezone())
|
|
# start = timezoned_datetime.replace(hour=0, minute=0)
|
|
|
|
# # print(f"timezoned_datetime: {timezoned_datetime}")
|
|
# # print(f"tournament end date: {end}")
|
|
# # print(f"current time: {now}")
|
|
# # print(f"tournament start: {start}")
|
|
# # print(f"start <= now <= end: {start <= now <= end}")
|
|
|
|
# return start <= now <= end
|
|
|
|
def starts_in_the_future(self):
|
|
# tomorrow = datetime.now().date() + timedelta(days=1)
|
|
# timezoned_datetime = self.local_start_date()
|
|
# start = timezoned_datetime.replace(hour=0, minute=0)
|
|
# now_utc = timezone.now()
|
|
# now = now_utc.astimezone(self.timezone())
|
|
start = self.start_date - timedelta(hours=1)
|
|
return start >= timezone.now()
|
|
|
|
def has_ended(self):
|
|
return self.end_date is not None
|
|
|
|
def should_be_over(self):
|
|
if self.has_ended():
|
|
return True
|
|
|
|
timezoned_datetime = self.local_start_date()
|
|
end = timezoned_datetime + timedelta(days=self.day_duration + 1)
|
|
now_utc = timezone.now()
|
|
now = now_utc.astimezone(self.timezone())
|
|
return now >= end and self.is_build_and_not_empty() and self.nearly_over()
|
|
|
|
def nearly_over(self):
|
|
# First check group stages if they exist
|
|
group_stages = list(self.group_stages.all()) # Use prefetched data
|
|
if group_stages:
|
|
# Check if all group stages are completed
|
|
for group_stage in group_stages:
|
|
# Use the is_completed method
|
|
if group_stage.is_completed():
|
|
return True
|
|
|
|
# If no group stages, check semi-finals
|
|
semifinals = self.rounds.filter(index=1, parent=None).first() # Use prefetched data
|
|
if semifinals:
|
|
# Check if any match in semi-finals has started
|
|
for match in semifinals.matches.all(): # Use prefetched data
|
|
if match.start_date is not None and match.is_ready():
|
|
return True
|
|
|
|
return False
|
|
|
|
def display_points_earned(self):
|
|
return self.federal_level_category != FederalLevelCategory.UNLISTED and self.hide_points_earned is False
|
|
|
|
def hide_weight(self):
|
|
return self.hide_teams_weight
|
|
|
|
def is_build_and_not_empty(self):
|
|
if hasattr(self, '_prefetched_objects_cache'):
|
|
# Use prefetched data if available
|
|
has_group_stages = 'group_stages' in self._prefetched_objects_cache and len(self.group_stages.all()) > 0
|
|
has_rounds = 'rounds' in self._prefetched_objects_cache and len(self.rounds.all()) > 0
|
|
has_team_registrations = 'team_registrations' in self._prefetched_objects_cache and len(self.team_registrations.all()) >= 4
|
|
else:
|
|
# Fall back to database queries if not prefetched
|
|
has_group_stages = self.group_stages.count() > 0
|
|
has_rounds = self.rounds.count() > 0
|
|
has_team_registrations = self.team_registrations.count() >= 4
|
|
|
|
return (has_group_stages or has_rounds) and has_team_registrations
|
|
|
|
def day_duration_formatted(self):
|
|
return plural_format("jour", self.day_duration)
|
|
|
|
def has_club_address(self):
|
|
if self.event and self.event.club:
|
|
return self.event.club.has_address()
|
|
else:
|
|
return False
|
|
|
|
def has_all_group_stages_started(self):
|
|
for group_stage in self.group_stages.all():
|
|
if group_stage.has_at_least_one_started_match() is False:
|
|
return False
|
|
return True
|
|
|
|
def options_fee(self):
|
|
options = []
|
|
currency_service = CurrencyService()
|
|
|
|
# Entry fee
|
|
if self.entry_fee is not None and self.entry_fee > 0:
|
|
formatted_fee = currency_service.format_amount(self.entry_fee, self.currency_code)
|
|
if self.is_custom_animation():
|
|
options.append(f"{formatted_fee} par personne")
|
|
else:
|
|
options.append(f"Frais d'inscription: {formatted_fee} par joueur")
|
|
|
|
# Club member fee reduction
|
|
if self.club_member_fee_deduction and self.club_member_fee_deduction > 0:
|
|
formatted_deduction = currency_service.format_amount(self.club_member_fee_deduction, self.currency_code)
|
|
options.append(f"Réduction de {formatted_deduction} pour les membres du club")
|
|
|
|
return options
|
|
|
|
def options_online_registration(self):
|
|
options = []
|
|
timezone = self.timezone()
|
|
|
|
# Date d'ouverture
|
|
if self.opening_registration_date:
|
|
date = formats.date_format(self.opening_registration_date.astimezone(timezone), format='j F Y H:i')
|
|
options.append(f"Ouverture des inscriptions le {date}")
|
|
|
|
# Date limite
|
|
if self.registration_date_limit:
|
|
date = formats.date_format(self.registration_date_limit.astimezone(timezone), format='j F Y H:i')
|
|
options.append(f"Clôture des inscriptions le {date}")
|
|
|
|
# Période de désinscription
|
|
formatted_period = self.format_time_period(self.unregister_delta_in_hours)
|
|
options.append(f"Désinscription possible jusqu'à {formatted_period} avant le tournoi")
|
|
|
|
options.append(self.get_selection_status_localized)
|
|
|
|
# Cible d'équipes
|
|
if self.team_count_limit is True:
|
|
options.append(f"Maximum {self.team_count} équipes")
|
|
|
|
# Liste d'attente
|
|
if self.waiting_list_limit:
|
|
options.append(f"Liste d'attente limitée à {self.waiting_list_limit} équipes")
|
|
|
|
# Options d'inscription
|
|
if self.account_is_required:
|
|
options.append("Compte requis")
|
|
if self.license_is_required:
|
|
options.append("Licence requise")
|
|
|
|
# Options de paiement en ligne
|
|
if self.enable_online_payment:
|
|
if self.online_payment_is_mandatory:
|
|
options.append("Paiement en ligne obligatoire")
|
|
else:
|
|
options.append("Paiement en ligne disponible")
|
|
|
|
if self.enable_online_payment_refund and self.refund_date_limit:
|
|
date = formats.date_format(self.refund_date_limit.astimezone(timezone), format='j F Y H:i')
|
|
options.append(f"Remboursement en ligne possible jusqu'au {date}")
|
|
elif self.enable_online_payment_refund:
|
|
options.append("Remboursement en ligne possible")
|
|
else:
|
|
options.append("Remboursement en ligne impossible")
|
|
|
|
# Joueurs par équipe
|
|
min_players = self.minimum_player_per_team
|
|
max_players = self.maximum_player_per_team
|
|
if min_players == max_players:
|
|
options.append(f"{min_players} joueurs par équipe")
|
|
else:
|
|
options.append(f"Entre {min_players} et {max_players} joueurs par équipe")
|
|
|
|
return options
|
|
|
|
def format_time_period(self, hours):
|
|
"""
|
|
Format time period in hours to a more readable format.
|
|
Examples:
|
|
- 24 hours -> "24h"
|
|
- 48 hours -> "2 jours"
|
|
- 168 hours -> "7 jours"
|
|
- 25 hours -> "25h"
|
|
"""
|
|
if hours % 24 == 0 and hours > 24:
|
|
days = hours // 24
|
|
return f"{days} jours"
|
|
else:
|
|
return f"{hours}h"
|
|
|
|
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 automatic_waiting_list(self):
|
|
"""
|
|
Determines if automatic waiting list processing should be applied based on the tournament's registration status.
|
|
Returns True if automatic waiting list processing should be applied, False otherwise.
|
|
"""
|
|
|
|
if self.enable_time_to_confirm is False:
|
|
return False
|
|
# Get the current registration status
|
|
status = self.get_online_registration_status()
|
|
# Define which status values should allow automatic waiting list
|
|
status_map = {
|
|
OnlineRegistrationStatus.OPEN: True,
|
|
OnlineRegistrationStatus.NOT_ENABLED: False,
|
|
OnlineRegistrationStatus.NOT_STARTED: False,
|
|
OnlineRegistrationStatus.ENDED: False,
|
|
OnlineRegistrationStatus.WAITING_LIST_POSSIBLE: True,
|
|
OnlineRegistrationStatus.WAITING_LIST_FULL: True, # Still manage in case spots open up
|
|
OnlineRegistrationStatus.IN_PROGRESS: False, # Allow for last-minute changes
|
|
OnlineRegistrationStatus.ENDED_WITH_RESULTS: False,
|
|
OnlineRegistrationStatus.CANCELED: False
|
|
}
|
|
|
|
# Return the mapped value or False as default for any unmapped status
|
|
return status_map.get(status, False)
|
|
|
|
def get_online_registration_status(self):
|
|
if self.is_canceled():
|
|
return OnlineRegistrationStatus.CANCELED
|
|
if self.end_date is not None:
|
|
return OnlineRegistrationStatus.ENDED_WITH_RESULTS
|
|
if self.enable_online_registration is False:
|
|
return OnlineRegistrationStatus.NOT_ENABLED
|
|
if self.has_started():
|
|
return OnlineRegistrationStatus.ENDED
|
|
if self.closed_registration_date is not None:
|
|
return OnlineRegistrationStatus.WAITING_LIST_POSSIBLE
|
|
|
|
now = timezone.now()
|
|
|
|
if self.opening_registration_date is not None:
|
|
if now < self.opening_registration_date:
|
|
return OnlineRegistrationStatus.NOT_STARTED
|
|
|
|
if self.registration_date_limit is not None:
|
|
if now > self.registration_date_limit:
|
|
return OnlineRegistrationStatus.WAITING_LIST_POSSIBLE
|
|
|
|
if self.team_sorting == TeamSortingType.RANK:
|
|
return OnlineRegistrationStatus.OPEN
|
|
|
|
if self.team_count_limit is True:
|
|
# Get all team registrations excluding walk_outs
|
|
current_team_count = self.team_registrations.exclude(walk_out=True).count()
|
|
if current_team_count >= self.team_count:
|
|
if self.waiting_list_limit is not None:
|
|
waiting_list_count = current_team_count - self.team_count
|
|
if waiting_list_count >= self.waiting_list_limit:
|
|
return OnlineRegistrationStatus.WAITING_LIST_FULL
|
|
return OnlineRegistrationStatus.WAITING_LIST_POSSIBLE
|
|
return OnlineRegistrationStatus.OPEN
|
|
|
|
def get_registration_status_short_label(self):
|
|
"""Returns a short label for the registration status"""
|
|
status = self.get_online_registration_status()
|
|
return status.short_label()
|
|
|
|
def get_registration_status_class(self):
|
|
"""Returns the CSS class for the registration status box"""
|
|
status = self.get_online_registration_status()
|
|
return status.box_class()
|
|
|
|
def should_display_status_box(self):
|
|
"""Returns whether the registration status box should be displayed"""
|
|
status = self.get_online_registration_status()
|
|
return status.display_box()
|
|
|
|
def is_unregistration_possible(self):
|
|
# Check if tournament has started
|
|
if self.supposedly_in_progress():
|
|
return False
|
|
|
|
if self.will_start_soon(self.unregister_delta_in_hours):
|
|
return False
|
|
|
|
if self.closed_registration_date is not None:
|
|
return False
|
|
|
|
# Check if tournament is finished
|
|
if self.end_date is not None:
|
|
return False
|
|
|
|
# Check if registration is closed
|
|
if self.registration_date_limit is not None:
|
|
if timezone.now() > self.registration_date_limit:
|
|
return False
|
|
|
|
# Otherwise unregistration is allowed
|
|
return True
|
|
|
|
def get_waiting_list_position(self):
|
|
now_utc = timezone.now()
|
|
current_time = now_utc.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
|
|
|
|
# If no target team count exists, no one goes to waiting list
|
|
if self.team_count_limit is False:
|
|
return -1
|
|
|
|
self.reserved_spots = max(0, self.reserved_spots - 1)
|
|
self.reserved_spots += 1
|
|
self.save()
|
|
|
|
# Get count of active teams (not walked out)
|
|
current_team_count = self.team_registrations.exclude(walk_out=True).count() + self.reserved_spots
|
|
|
|
# If current count is less than target count, next team is not in waiting list
|
|
if current_team_count < self.team_count:
|
|
return -1
|
|
|
|
# If we have a waiting list limit
|
|
if self.waiting_list_limit is not None:
|
|
waiting_list_count = current_team_count - self.team_count
|
|
# If waiting list is full
|
|
if waiting_list_count >= self.waiting_list_limit:
|
|
return -1
|
|
# Return waiting list position
|
|
return waiting_list_count
|
|
|
|
# In waiting list with no limit
|
|
return current_team_count - self.team_count
|
|
|
|
def build_tournament_type_str(self):
|
|
tournament_details = []
|
|
tournament_details.append(self.level())
|
|
if self.category():
|
|
tournament_details.append(self.category())
|
|
if self.age() and self.federal_age_category != FederalAgeCategory.SENIOR:
|
|
tournament_details.append(self.age())
|
|
|
|
return " ".join(filter(None, tournament_details))
|
|
|
|
def build_tournament_details_str(self):
|
|
name_str = self.build_name_details_str()
|
|
tournament_details = []
|
|
if self.federal_level_category > 0:
|
|
tournament_details.append(self.level())
|
|
if self.category():
|
|
tournament_details.append(self.category())
|
|
if self.age() and self.federal_age_category != FederalAgeCategory.SENIOR:
|
|
tournament_details.append(self.age())
|
|
|
|
if len(name_str) > 0:
|
|
tournament_details.append(name_str)
|
|
|
|
return " ".join(filter(None, tournament_details))
|
|
|
|
def build_name_details_str(self):
|
|
name_details = []
|
|
if self.name:
|
|
name_details.append(self.name)
|
|
if self.event.name:
|
|
name_details.append(self.event.name)
|
|
name_str = " - ".join(filter(None, name_details))
|
|
if name_str:
|
|
name_str = f"{name_str}"
|
|
return name_str
|
|
|
|
def player_register_check(self, licence_id):
|
|
reasons = []
|
|
if not licence_id:
|
|
return None
|
|
|
|
data, found = get_player_name_from_csv(self.federal_category, licence_id)
|
|
if not found or not data:
|
|
print("not found or not data")
|
|
return None
|
|
|
|
birth_year = data.get('birth_year', None)
|
|
is_woman = data.get('is_woman', None)
|
|
|
|
# Check gender category restrictions
|
|
if is_woman is not None and self.federal_category == FederalCategory.WOMEN and is_woman is False:
|
|
reasons.append("Ce tournoi est réservé aux femmes")
|
|
|
|
if birth_year is None or birth_year == 'N/A':
|
|
return reasons if reasons else None
|
|
|
|
try:
|
|
tournament_start_year = self.season_year()
|
|
user_age = tournament_start_year - int(birth_year)
|
|
except (ValueError, TypeError):
|
|
return reasons if reasons else None
|
|
|
|
tournament_start_year = self.season_year()
|
|
user_age = tournament_start_year - int(birth_year)
|
|
# Check age category restrictions
|
|
|
|
if self.federal_age_category == FederalAgeCategory.A09_10 and user_age > 10:
|
|
reasons.append("Ce tournoi est réservé aux 10 ans et moins")
|
|
if self.federal_age_category == FederalAgeCategory.A11_12 and user_age > 12:
|
|
reasons.append("Ce tournoi est réservé aux 12 ans et moins")
|
|
if self.federal_age_category == FederalAgeCategory.A13_14 and user_age > 14:
|
|
reasons.append("Ce tournoi est réservé aux 14 ans et moins")
|
|
if self.federal_age_category == FederalAgeCategory.A15_16 and user_age < 11:
|
|
reasons.append("Ce tournoi est réservé aux 11 ans et plus")
|
|
if self.federal_age_category == FederalAgeCategory.A17_18 and user_age < 11:
|
|
reasons.append("Ce tournoi est réservé aux 11 ans et plus")
|
|
if self.federal_age_category == FederalAgeCategory.A15_16 and user_age > 16:
|
|
reasons.append("Ce tournoi est réservé aux 16 ans et moins")
|
|
if self.federal_age_category == FederalAgeCategory.A17_18 and user_age > 18:
|
|
reasons.append("Ce tournoi est réservé aux 18 ans et moins")
|
|
if self.federal_age_category == FederalAgeCategory.SENIOR and user_age < 11:
|
|
reasons.append("Ce tournoi est réservé aux 11 ans et plus")
|
|
if self.federal_age_category == FederalAgeCategory.A45 and user_age < 45:
|
|
reasons.append("Ce tournoi est réservé aux +45 ans")
|
|
if self.federal_age_category == FederalAgeCategory.A55 and user_age < 55:
|
|
reasons.append("Ce tournoi est réservé aux +55 ans")
|
|
|
|
addon = 0
|
|
computedRank = int(data.get("rank", 0))
|
|
if is_woman and self.federal_category == FederalCategory.MEN:
|
|
addon = FederalCategory.female_in_male_assimilation_addition(computedRank, tournament_start_year)
|
|
computedRank = computedRank + addon
|
|
|
|
if computedRank <= self.min_player_rank():
|
|
name = data['first_name'] + " " + data['last_name'].upper()
|
|
reasons.append(f"{name} ({licence_id}): trop bien classé pour ce tournoi")
|
|
|
|
return reasons if reasons else None
|
|
|
|
def min_player_rank(self):
|
|
return FederalLevelCategory.min_player_rank(self.federal_level_category, self.federal_category, self.federal_age_category, self.season_year())
|
|
|
|
def season_year(self):
|
|
local_date = self.local_start_date()
|
|
if local_date.month >= 9: # September or later
|
|
return local_date.year + 1
|
|
else:
|
|
return local_date.year
|
|
|
|
def local_registration_federal_limit(self):
|
|
timezone = self.timezone()
|
|
if self.registration_date_limit is not None:
|
|
return self.registration_date_limit.astimezone(timezone)
|
|
|
|
if self.closed_registration_date is not None:
|
|
return self.closed_registration_date.astimezone(timezone)
|
|
|
|
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):
|
|
now_utc = timezone.now()
|
|
current_time = now_utc.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 None
|
|
|
|
if len(teams)<=self.team_count:
|
|
return None
|
|
waiting_teams = [team for team in teams if team.stage == "Attente"]
|
|
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
|
|
matches, _ = self.broadcasted_matches_and_group_stages()
|
|
|
|
if not matches:
|
|
return []
|
|
|
|
# Get all unfinished matches for courts
|
|
active_matches = [
|
|
m for m in matches
|
|
if m.end_date is None # Not finished
|
|
and m.court_index is not None
|
|
]
|
|
|
|
now = timezone.now()
|
|
# Group matches by court
|
|
matches_by_court = {}
|
|
courts = set()
|
|
for match in active_matches:
|
|
if match.court_index not in matches_by_court:
|
|
matches_by_court[match.court_index] = []
|
|
courts.add(match.court_index)
|
|
matches_by_court[match.court_index].append(match)
|
|
|
|
# Sort matches within each court by start time
|
|
for court in matches_by_court:
|
|
matches_by_court[court].sort(key=lambda m: (
|
|
m.start_date is None, # None dates come last
|
|
m.start_date if m.start_date else now
|
|
))
|
|
|
|
# Sort courts and organize them into groups of 4
|
|
sorted_courts = sorted(list(courts))
|
|
court_groups = [sorted_courts[i:i+4] for i in range(0, len(sorted_courts), 4)]
|
|
|
|
ordered_matches = []
|
|
# For each group of up to 4 courts
|
|
for court_group in court_groups:
|
|
# First row: earliest match for each court
|
|
for court in court_group:
|
|
if court in matches_by_court and matches_by_court[court]:
|
|
ordered_matches.append(matches_by_court[court][0])
|
|
else:
|
|
ordered_matches.append({"empty": True})
|
|
# Pad to 4 courts if needed
|
|
while len(ordered_matches) % 4 != 0:
|
|
ordered_matches.append({"empty": True})
|
|
|
|
# Second row: next match for each court
|
|
for court in court_group:
|
|
if court in matches_by_court and len(matches_by_court[court]) > 1:
|
|
ordered_matches.append(matches_by_court[court][1])
|
|
else:
|
|
ordered_matches.append({"empty": True})
|
|
# Pad to 4 courts if needed
|
|
while len(ordered_matches) % 4 != 0:
|
|
ordered_matches.append({"empty": True})
|
|
|
|
# Add unassigned matches at the end if needed
|
|
unassigned_matches = [
|
|
m for m in matches
|
|
if m.end_date is None and m.court_index is None
|
|
]
|
|
if unassigned_matches:
|
|
ordered_matches.extend(unassigned_matches)
|
|
|
|
return ordered_matches
|
|
|
|
def get_butterfly_bracket_match_group(self, parent_round=None, double_butterfly_mode=False, display_loser_final=False):
|
|
loser_final = None
|
|
main_rounds_reversed = []
|
|
|
|
# Get main bracket rounds (excluding children/ranking matches)
|
|
if double_butterfly_mode:
|
|
main_rounds = self.rounds.filter(
|
|
parent=parent_round,
|
|
group_stage_loser_bracket=False,
|
|
index__lte=3
|
|
).order_by('-index')
|
|
else:
|
|
main_rounds = self.rounds.filter(
|
|
parent=parent_round,
|
|
group_stage_loser_bracket=False
|
|
).order_by('-index')
|
|
|
|
count = main_rounds.count()
|
|
if display_loser_final and count > 1:
|
|
semi = main_rounds[count - 2]
|
|
loser_final = self.rounds.filter(
|
|
parent=semi,
|
|
group_stage_loser_bracket=False
|
|
).order_by('index').first()
|
|
|
|
# Create serializable match groups data
|
|
serializable_match_groups = []
|
|
|
|
# Add first half of each round (from last to semi-finals)
|
|
for round in main_rounds:
|
|
next_round = main_rounds.filter(index=round.index - 1).first()
|
|
match_group = round.prepare_match_group(next_round, parent_round, loser_final, double_butterfly_mode, False)
|
|
if match_group:
|
|
serializable_match_groups.append(match_group)
|
|
|
|
if double_butterfly_mode:
|
|
main_rounds_reversed = list(main_rounds)
|
|
main_rounds_reversed.reverse()
|
|
for round in main_rounds_reversed:
|
|
if round.index > 0:
|
|
next_round = main_rounds.filter(index=round.index - 1).first()
|
|
match_group = round.prepare_match_group(next_round, parent_round, None, double_butterfly_mode, True)
|
|
if match_group:
|
|
serializable_match_groups.append(match_group)
|
|
|
|
return serializable_match_groups
|
|
|
|
def has_bracket(self):
|
|
main_rounds = self.rounds.filter(
|
|
parent=None,
|
|
group_stage_loser_bracket=False
|
|
)
|
|
|
|
count = main_rounds.count()
|
|
if count == 0:
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
def umpire_contact(self):
|
|
if self.umpire_custom_contact is not None:
|
|
return self.umpire_custom_contact
|
|
if self.event and self.event.creator:
|
|
return self.event.creator.full_name()
|
|
else:
|
|
return None
|
|
|
|
def umpire_mail(self):
|
|
if self.umpire_custom_mail is not None:
|
|
return self.umpire_custom_mail
|
|
return self.event.creator.email
|
|
|
|
def umpire_phone(self):
|
|
if self.umpire_custom_phone is not None:
|
|
return self.umpire_custom_phone
|
|
if self.event and self.event.creator:
|
|
return self.event.creator.phone
|
|
return None
|
|
|
|
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 self.automatic_waiting_list() is False:
|
|
return None
|
|
|
|
config = settings.TOURNAMENT_SETTINGS
|
|
TIME_PROXIMITY_RULES = config['TIME_PROXIMITY_RULES']
|
|
WAITING_LIST_RULES = config['WAITING_LIST_RULES']
|
|
BUSINESS_RULES = config['BUSINESS_RULES']
|
|
|
|
# 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
|
|
|
|
# Default business hours
|
|
business_start_hour = BUSINESS_RULES["hours"]["start"]
|
|
business_end_hour = BUSINESS_RULES["hours"]["end"]
|
|
# 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"] / 10 if getattr(settings, 'LIVE_TESTING', False)
|
|
# else URGENCY_OVERRIDE["minimum_response_time"])
|
|
# break
|
|
|
|
# Adjust business hours based on tournament proximity
|
|
if hours_until_tournament <= 24:
|
|
# 24 hours before tournament: 7am - 10pm
|
|
business_start_hour = 7
|
|
business_end_hour = 22
|
|
minutes_to_confirm = config['MINIMUM_RESPONSE_TIME']
|
|
|
|
if hours_until_tournament <= 12:
|
|
# 12 hours before tournament: 6am - 1am (next day)
|
|
business_start_hour = 6
|
|
business_end_hour = 25 # 1am next day (25 in 24-hour format)
|
|
minutes_to_confirm = config['MINIMUM_RESPONSE_TIME']
|
|
|
|
live_testing = getattr(settings, 'LIVE_TESTING', False)
|
|
# Divide by 10 if LIVE_TESTING is enabled
|
|
if live_testing:
|
|
minutes_to_confirm = minutes_to_confirm / 10
|
|
|
|
# 8. Calculate raw deadline
|
|
raw_deadline = current_time + timezone.timedelta(minutes=minutes_to_confirm)
|
|
|
|
# 9. Round up to next interval mark based on BACKGROUND_SCHEDULED_TASK_INTERVAL
|
|
interval = settings.BACKGROUND_SCHEDULED_TASK_INTERVAL
|
|
minute = raw_deadline.minute
|
|
if minute % interval != 0:
|
|
# Minutes to next interval mark
|
|
minutes_to_add = interval - (minute % interval)
|
|
raw_deadline += timezone.timedelta(minutes=minutes_to_add)
|
|
|
|
# 10. Apply business hours rules if needed
|
|
if apply_business_rules and live_testing is False:
|
|
# Check if deadline falls outside business hours
|
|
before_hours = raw_deadline.hour < business_start_hour
|
|
after_hours = raw_deadline.hour >= business_end_hour
|
|
|
|
if before_hours or after_hours:
|
|
# Extend to next business day
|
|
if after_hours:
|
|
# Move to next day
|
|
days_to_add = 1
|
|
raw_deadline += timezone.timedelta(days=days_to_add)
|
|
|
|
# Set to business start hour
|
|
raw_deadline = raw_deadline.replace(
|
|
hour=business_start_hour,
|
|
minute=0,
|
|
second=0,
|
|
microsecond=0
|
|
)
|
|
|
|
raw_deadline += timezone.timedelta(minutes=minutes_to_confirm)
|
|
print(f"Before hours: {before_hours}, After hours: {after_hours}")
|
|
print(f"Final deadline after adding confirmation time: {raw_deadline}")
|
|
|
|
tournament_start_date_minus_five = tournament_start_date - timedelta(minutes=5)
|
|
if raw_deadline >= tournament_start_date_minus_five:
|
|
print(f"Raw Deadline is after tournament_start_date_minus_five: {raw_deadline}, {tournament_start_date_minus_five}")
|
|
raw_deadline = tournament_start_date_minus_five
|
|
|
|
raw_deadline = raw_deadline.replace(
|
|
second=0,
|
|
microsecond=0
|
|
)
|
|
|
|
print(f"Live testing: {live_testing}")
|
|
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"Apply business rules: {apply_business_rules}")
|
|
|
|
return raw_deadline
|
|
|
|
def is_online_registration_irrevelant(self):
|
|
return self.enable_time_to_confirm is False or self.has_started() or self.has_ended() or self.is_canceled() or self.is_deleted
|
|
|
|
@property
|
|
def week_day(self):
|
|
"""Return the weekday name (e.g., 'Monday')"""
|
|
date = self.local_start_date()
|
|
return date_format(date, format='D') + '.' # 'l' gives full weekday name
|
|
|
|
@property
|
|
def day(self):
|
|
"""Return the day of the month"""
|
|
date = self.local_start_date()
|
|
return date.day
|
|
|
|
@property
|
|
def month(self):
|
|
"""
|
|
Return the month name in lowercase:
|
|
- If full month name is 4 letters or fewer, return as is
|
|
- If more than 4 letters, return first 4 letters with a dot
|
|
"""
|
|
date = self.local_start_date()
|
|
# Get full month name and convert to lowercase
|
|
full_month = date_format(date, format='F').lower()
|
|
|
|
# Check if the month name is 5 letters or fewer
|
|
if len(full_month) <= 5:
|
|
return full_month
|
|
else:
|
|
# Truncate to 5 letters and add a dot
|
|
return f"{full_month[:5]}."
|
|
|
|
@property
|
|
def year(self):
|
|
"""Return the year"""
|
|
date = self.local_start_date()
|
|
return date.year
|
|
|
|
@property
|
|
def localized_day_duration(self):
|
|
"""
|
|
Return localized day duration in French:
|
|
- If multiple days: '2 jours', '3 jours', etc.
|
|
- If 1 day and starts after 18:00: 'soirée'
|
|
- If 1 day and starts before 18:00: 'journée'
|
|
"""
|
|
# Assuming day_duration is a property or field that returns the number of days
|
|
days = self.day_duration
|
|
|
|
if days > 1:
|
|
return f"{days} jours"
|
|
else:
|
|
# For single day events, check the starting hour
|
|
start_time = self.local_start_date().time()
|
|
evening_threshold = time(18, 0) # 18:00 (6 PM)
|
|
|
|
if start_time >= evening_threshold:
|
|
return "soirée"
|
|
else:
|
|
return "journée"
|
|
|
|
def get_player_registration_status_by_licence(self, user):
|
|
user_player = self.get_user_registered(user)
|
|
if user_player:
|
|
return user_player.get_registration_status()
|
|
return None
|
|
|
|
def get_user_registered(self, user):
|
|
if not user.is_authenticated:
|
|
return None
|
|
|
|
PlayerRegistration = apps.get_model('tournaments', 'PlayerRegistration')
|
|
|
|
# First, try to find a registration directly linked to the user
|
|
direct_registration = PlayerRegistration.objects.filter(
|
|
team_registration__tournament=self,
|
|
user=user,
|
|
team_registration__walk_out=False
|
|
).first()
|
|
|
|
if direct_registration:
|
|
return direct_registration
|
|
|
|
# If no direct registration found and user has no license, return None
|
|
if not user.licence_id:
|
|
return None
|
|
|
|
# Validate the license format
|
|
validator = LicenseValidator(user.licence_id)
|
|
if not validator.validate_license():
|
|
return None
|
|
|
|
# Get the stripped license (without check letter)
|
|
stripped_license = validator.stripped_license
|
|
|
|
# Fall back to checking by license ID
|
|
return PlayerRegistration.objects.filter(
|
|
team_registration__tournament=self,
|
|
licence_id__icontains=stripped_license,
|
|
team_registration__walk_out=False
|
|
).first()
|
|
|
|
def is_user_registered(self, user):
|
|
return self.get_user_registered(user) is not None
|
|
|
|
def get_user_team_registration(self, user):
|
|
user_registered = self.get_user_registered(user)
|
|
if user_registered:
|
|
return user_registered.team_registration
|
|
else:
|
|
return None
|
|
|
|
def should_request_payment(self):
|
|
if self.enable_online_payment:
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
def is_refund_possible(self):
|
|
if self.enable_online_payment_refund:
|
|
time = timezone.now()
|
|
if self.refund_date_limit:
|
|
if time <= self.refund_date_limit:
|
|
return True
|
|
else:
|
|
return False
|
|
else:
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
def is_free(self):
|
|
if self.entry_fee is not None and self.entry_fee == 0:
|
|
return True
|
|
elif self.entry_fee is None:
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
def effective_commission_rate(self):
|
|
"""Get the commission rate for this tournament, falling back to the umpire default if not set"""
|
|
return 1.00 # Fallback default
|
|
|
|
def check_all_confirmation_deadlines(self):
|
|
"""
|
|
Check all confirmation deadlines for teams in this tournament.
|
|
Send notification emails as needed.
|
|
|
|
Returns:
|
|
int: Number of teams processed
|
|
"""
|
|
|
|
# Calculate these values once for the tournament
|
|
teams = self.teams(True)
|
|
waiting_list_teams = self.waiting_list_teams(teams)
|
|
ttc = self.calculate_time_to_confirm(len(waiting_list_teams)) if waiting_list_teams is not None else None
|
|
first_waiting_list_team = self.first_waiting_list_team(teams)
|
|
|
|
# Tournament context dict to pass to each team check
|
|
tournament_context = {
|
|
'ttc': ttc,
|
|
'first_waiting_list_team': first_waiting_list_team,
|
|
'is_online_registration_irrevelant': self.is_online_registration_irrevelant()
|
|
}
|
|
|
|
# Find players with expired confirmation deadlines in this tournament
|
|
PlayerRegistration = apps.get_model('tournaments', 'PlayerRegistration')
|
|
expired_confirmations = PlayerRegistration.objects.filter(
|
|
registration_status=RegistrationStatus.PENDING,
|
|
registered_online=True,
|
|
team_registration__tournament=self
|
|
).select_related('team_registration')
|
|
|
|
processed_teams = set() # To avoid processing the same team multiple times
|
|
teams_processed = 0
|
|
|
|
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)
|
|
teams_processed += 1
|
|
|
|
# Process in a transaction to ensure atomic operations
|
|
team_registration.check_confirmation_deadline(tournament_context)
|
|
|
|
return teams_processed
|
|
|
|
def planned_matches_by_day(self, day=None, all=False, event_mode=False, broadcast=False):
|
|
"""
|
|
Collect all matches from tournaments and group them by their planned_start_date.
|
|
|
|
Parameters:
|
|
- day: Optional date string in format 'YYYY-MM-DD'. If provided, returns matches for that day only.
|
|
|
|
Returns:
|
|
- days: List of unique days found (datetime.date objects)
|
|
- match_groups: Dictionary of match groups by date and hour or just for the selected day
|
|
"""
|
|
event = self.event
|
|
tournaments_count = 1
|
|
if event:
|
|
tournaments_count = event.tournaments.count()
|
|
|
|
if event_mode is True and tournaments_count == 1:
|
|
event_mode = False
|
|
|
|
show_teams_in_prog = False
|
|
if tournaments_count == 1:
|
|
show_teams_in_prog = self.show_teams_in_prog
|
|
elif event:
|
|
show_teams_in_prog = event.tournaments.filter(show_teams_in_prog=True).first() is not None
|
|
|
|
# Get all matches from rounds and group stages - use a set to avoid duplicates
|
|
all_matches = set()
|
|
|
|
tournaments = [self]
|
|
if event_mode is True and event:
|
|
tournaments = event.tournaments.all()
|
|
|
|
# Check if all tournaments have started - if so, always show teams
|
|
all_started = True
|
|
for t in tournaments:
|
|
if not t.has_started():
|
|
all_started = False
|
|
break
|
|
if all_started:
|
|
show_teams_in_prog = True
|
|
|
|
for tournament in tournaments:
|
|
# Get matches only from top-level rounds to avoid duplicates
|
|
for round in tournament.rounds.filter(parent=None).all():
|
|
round_matches = round.get_matches_recursive(False)
|
|
# Add to set using IDs to avoid duplicates
|
|
for match in round_matches:
|
|
all_matches.add(match)
|
|
|
|
# Get matches from group stages
|
|
for group_stage in tournament.group_stages.all():
|
|
for match in group_stage.matches.all():
|
|
all_matches.add(match)
|
|
|
|
# Filter matches with planned_start_date - convert back to list
|
|
planned_matches = [match for match in all_matches if match.planned_start_date and not match.disabled]
|
|
|
|
if not planned_matches:
|
|
return [], []
|
|
|
|
# Group matches by day
|
|
matches_by_day = {}
|
|
days = set()
|
|
|
|
for match in planned_matches:
|
|
# Convert to local time zone
|
|
local_date = match.local_planned_start_date()
|
|
day_key = local_date.date()
|
|
days.add(day_key)
|
|
|
|
if day_key not in matches_by_day:
|
|
matches_by_day[day_key] = []
|
|
|
|
matches_by_day[day_key].append(match)
|
|
|
|
# Sort days
|
|
sorted_days = sorted(list(days))
|
|
# Create match groups for the selected day
|
|
match_groups = []
|
|
hide_teams = show_teams_in_prog == False
|
|
# When broadcast=True, handle all days with matches
|
|
if broadcast:
|
|
today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0).date()
|
|
sorted_days = [day for day in sorted(list(days)) if day >= today]
|
|
|
|
# Process all days with matches
|
|
for selected_day in sorted_days:
|
|
|
|
# Group matches by hour
|
|
matches_by_hour = {}
|
|
for match in matches_by_day[selected_day]:
|
|
local_time = match.local_planned_start_date()
|
|
hour_key = local_time.strftime('%H:%M')
|
|
|
|
if hour_key not in matches_by_hour:
|
|
matches_by_hour[hour_key] = []
|
|
|
|
matches_by_hour[hour_key].append(match)
|
|
|
|
# Create match groups for each hour
|
|
for hour, matches in sorted(matches_by_hour.items()):
|
|
# Sort matches by court if available
|
|
matches.sort(key=lambda m: (m.court_index if m.court_index is not None else 999))
|
|
|
|
local_date = matches[0].local_planned_start_date()
|
|
formatted_name = formats.date_format(local_date, format='l j F à H:i').capitalize()
|
|
mg = self.create_match_group(
|
|
name=formatted_name,
|
|
matches=matches,
|
|
round_id=None,
|
|
round_index=None,
|
|
hide_teams=hide_teams,
|
|
event_mode=event_mode,
|
|
broadcast=broadcast
|
|
)
|
|
match_groups.append(mg)
|
|
return sorted_days, match_groups
|
|
|
|
if all or day is None:
|
|
today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0).date()
|
|
if today in days:
|
|
selected_day = today
|
|
else:
|
|
# Default to first day if today is not in the list
|
|
if self.has_ended():
|
|
selected_day = sorted_days[-1]
|
|
else:
|
|
selected_day = sorted_days[0]
|
|
|
|
# Group matches by hour
|
|
matches_by_hour = {}
|
|
for match in matches_by_day[selected_day]:
|
|
local_time = match.local_planned_start_date()
|
|
hour_key = local_time.strftime('%H:%M')
|
|
|
|
if hour_key not in matches_by_hour:
|
|
matches_by_hour[hour_key] = []
|
|
|
|
matches_by_hour[hour_key].append(match)
|
|
|
|
# Create match groups for each hour
|
|
for hour, matches in sorted(matches_by_hour.items()):
|
|
# Sort matches by court if available
|
|
matches.sort(key=lambda m: (m.court_index if m.court_index is not None else 999))
|
|
|
|
local_date = matches[0].local_planned_start_date()
|
|
formatted_name = formats.date_format(local_date, format='l j F à H:i').capitalize()
|
|
mg = self.create_match_group(
|
|
name=formatted_name,
|
|
matches=matches,
|
|
round_id=None,
|
|
round_index=None,
|
|
hide_teams=hide_teams,
|
|
event_mode=event_mode,
|
|
broadcast=broadcast
|
|
)
|
|
match_groups.append(mg)
|
|
return sorted_days, match_groups
|
|
|
|
# If specific day requested, filter to that day
|
|
selected_day = None
|
|
if day:
|
|
try:
|
|
# Parse the day string to a date object
|
|
selected_day = datetime.strptime(day, '%Y-%m-%d').date()
|
|
if selected_day not in days:
|
|
selected_day = sorted_days[0] if sorted_days else None
|
|
except (ValueError, TypeError):
|
|
selected_day = sorted_days[0] if sorted_days else None
|
|
else:
|
|
selected_day = sorted_days[0] if sorted_days else None
|
|
|
|
if selected_day and selected_day in matches_by_day:
|
|
# Group matches by hour
|
|
matches_by_hour = {}
|
|
for match in matches_by_day[selected_day]:
|
|
local_time = timezone.localtime(match.planned_start_date)
|
|
hour_key = local_time.strftime('%H:%M')
|
|
|
|
if hour_key not in matches_by_hour:
|
|
matches_by_hour[hour_key] = []
|
|
|
|
matches_by_hour[hour_key].append(match)
|
|
|
|
# Create match groups for each hour
|
|
for hour, matches in sorted(matches_by_hour.items()):
|
|
# Sort matches by court if available
|
|
matches.sort(key=lambda m: (m.court_index if m.court_index is not None else 999))
|
|
|
|
local_date = matches[0].local_planned_start_date()
|
|
formatted_name = formats.date_format(local_date, format='l j F à H:i').capitalize()
|
|
mg = self.create_match_group(
|
|
name=formatted_name,
|
|
matches=matches,
|
|
round_id=None,
|
|
round_index=None,
|
|
hide_teams=hide_teams,
|
|
event_mode=event_mode,
|
|
broadcast=broadcast
|
|
)
|
|
match_groups.append(mg)
|
|
|
|
return sorted_days, match_groups
|
|
|
|
def has_sponsors(self):
|
|
return self.event.images.exists()
|
|
|
|
def is_cart_player_from_club(self, player_data):
|
|
player_club_code = player_data.get('club_code', None)
|
|
if player_club_code is None or len(player_club_code) == 0:
|
|
return False
|
|
club_code = self.event.club.code
|
|
if club_code is None or len(club_code) == 0:
|
|
return False
|
|
player_club_code = player_club_code.replace(" ", "")
|
|
club_code = club_code.replace(" ", "")
|
|
return player_club_code.lower() == club_code.lower()
|
|
|
|
def localized_start_time(self):
|
|
return formats.date_format(self.local_start_date(), format='H:i').capitalize()
|
|
|
|
def court_names(self):
|
|
if self.event.club is None:
|
|
return [f"Piste {i+1}" for i in range(self.court_count)]
|
|
return [f"{self.event.club.court_name(i)}" for i in range(self.court_count)]
|
|
|
|
class MatchGroup:
|
|
def __init__(self, name, matches, formatted_schedule, round_id=None, round_index=None):
|
|
self.name = name
|
|
self.matches = matches
|
|
self.formatted_schedule = formatted_schedule
|
|
self.round_id = round_id
|
|
self.round_index = round_index
|
|
|
|
def add_match(self, match):
|
|
self.matches.append(match)
|
|
|
|
def add_matches(self, matches):
|
|
self.matches = matches
|
|
|
|
def to_dict(self):
|
|
return {
|
|
'name': self.name,
|
|
'round_id': self.round_id,
|
|
'round_index': self.round_index,
|
|
'matches': [match.to_dict() for match in self.matches]
|
|
}
|
|
|
|
class TeamSummon:
|
|
def __init__(self, id, names, date, weight, stage, court, image, day_duration):
|
|
self.id = str(id)
|
|
self.names = names
|
|
self.date = date
|
|
self.weight = weight
|
|
self.stage = stage
|
|
self.court = court
|
|
self.image = image
|
|
self.day_duration = day_duration
|
|
|
|
def formatted_date(self):
|
|
if self.date:
|
|
if self.day_duration >= 7:
|
|
return formats.date_format(self.date, format='l d M H:i')
|
|
else:
|
|
return formats.date_format(self.date, format='l H:i')
|
|
else:
|
|
return ''
|
|
|
|
def to_dict(self):
|
|
return {
|
|
"names": self.names,
|
|
"date": self.formatted_date(),
|
|
"weight": self.weight,
|
|
"stage": self.stage,
|
|
"court": self.court,
|
|
"image": self.image,
|
|
}
|
|
|
|
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()
|
|
self.registration_date = team_registration.registration_date
|
|
if team_registration.players_sorted_by_rank.count() == 0:
|
|
weight = None
|
|
else:
|
|
weight = team_registration.weight
|
|
self.weight = weight
|
|
self.initial_weight = team_registration.initial_weight()
|
|
self.image = team_registration.logo
|
|
self.stage = ""
|
|
self.team_registration = team_registration
|
|
self.wildcard_bracket = team_registration.wild_card_bracket
|
|
self.wildcard_groupstage = team_registration.wild_card_group_stage
|
|
|
|
def set_stage(self, stage):
|
|
self.stage = stage
|
|
|
|
def to_dict(self):
|
|
return {
|
|
"names": self.names,
|
|
"date": self.date,
|
|
"registration_date": self.registration_date,
|
|
"weight": self.weight,
|
|
"initial_weight": self.initial_weight,
|
|
"image": self.image,
|
|
"stage": self.stage,
|
|
"wildcard_bracket": self.wildcard_bracket,
|
|
"wildcard_groupstage": self.wildcard_groupstage,
|
|
}
|
|
|
|
class TeamRanking:
|
|
def __init__(self, id, names, ranking, points, image):
|
|
self.id = str(id)
|
|
self.names = names
|
|
self.ranking = ranking
|
|
self.formatted_ranking = self.ordinal(ranking)
|
|
self.points = self.points_earned_display(points)
|
|
self.image = image
|
|
|
|
# def ranking_display(self):
|
|
# return self.ordinal(self.ranking)
|
|
|
|
def points_earned_display(self, points):
|
|
if points is None:
|
|
return ""
|
|
return f"+{points} pt{self.plural_suffix(points)}"
|
|
|
|
def plural_suffix(self, n):
|
|
if n > 1:
|
|
return 's'
|
|
else:
|
|
return ''
|
|
|
|
def ordinal(self, n):
|
|
if n == 1:
|
|
suffix = 'er'
|
|
else:
|
|
suffix = 'ème'
|
|
return str(n) + suffix
|
|
|
|
def to_dict(self):
|
|
return {
|
|
"names": self.names,
|
|
"ranking": self.ranking,
|
|
"formatted_ranking": self.formatted_ranking,
|
|
"points": self.points,
|
|
"image": self.image,
|
|
}
|
|
|