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 if self.event and self.event.creator: return self.event.creator.email return None 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, }