apikeys
Laurent 4 months ago
commit 285292ac55
  1. 6
      api/urls.py
  2. 548
      api/utils.py
  3. 337
      api/views.py
  4. 9
      padelclub_backend/settings.py
  5. 1
      requirements.txt
  6. 4
      tournaments/admin.py
  7. 152
      tournaments/admin_utils.py
  8. 523
      tournaments/management/commands/analyze_rankings.py
  9. 222
      tournaments/management/commands/test_fft_all_tournaments.py
  10. 103
      tournaments/management/commands/test_fft_scraper.py
  11. 6
      tournaments/models/purchase.py
  12. 37
      tournaments/models/tournament.py
  13. 1622
      tournaments/static/rankings/CLASSEMENT-PADEL-DAMES-07-2025.csv
  14. 8368
      tournaments/static/rankings/CLASSEMENT-PADEL-MESSIEURS-07-2025.csv
  15. 1
      tournaments/static/tournaments/css/style.css
  16. 36
      tournaments/templates/admin/tournaments/dashboard.html
  17. 1
      tournaments/templates/tournaments/broadcast/broadcast.html
  18. 1
      tournaments/templates/tournaments/broadcast/broadcast_club.html
  19. 9
      tournaments/templates/tournaments/download.html
  20. 3
      tournaments/views.py

@ -39,6 +39,7 @@ urlpatterns = [
path('sync-data/', SynchronizationApi.as_view(), name="data"),
path('data-access-content/', UserDataAccessApi.as_view(), name="data-access-content"),
path("is_granted_unlimited_access/", views.is_granted_unlimited_access, name="is-granted-unlimited-access"),
path('api-token-auth/', obtain_auth_token, name='api_token_auth'),
path("user-by-token/", views.user_by_token, name="user_by_token"),
@ -47,6 +48,11 @@ urlpatterns = [
path('xls-to-csv/', views.xls_to_csv, name='xls-to-csv'),
path('config/tournament/', views.get_tournament_config, name='tournament-config'),
path('config/payment/', views.get_payment_config, name='payment-config'),
path('fft/club-tournaments/', views.get_fft_club_tournaments, name='get-fft-club-tournaments'),
path('fft/club-tournaments-complete/', views.get_fft_club_tournaments_with_umpire_data, name='get-fft-club-tournaments-complete'),
path('fft/all-tournaments/', views.get_fft_all_tournaments, name='get-fft-all-tournaments'),
path('fft/umpire/<str:tournament_id>/', views.get_fft_umpire_data, name='get-fft-umpire-data'),
path('fft/federal-clubs/', views.get_fft_federal_clubs, name='get-fft-federal-clubs'),
# authentication
path("change-password/", ChangePasswordView.as_view(), name="change_password"),

@ -1,13 +1,557 @@
import time
import logging
import requests
import re
from playwright.sync_api import sync_playwright
from datetime import datetime, timedelta
import json
import traceback
logger = logging.getLogger(__name__)
def check_version_smaller_than_1_1_12(version_str):
# Remove the parentheses part if it exists, example of version: 1.1.12 (2)
version_str = version_str.split()[0]
if version_str:
# Split version into components
version_parts = [int(x) for x in version_str.split('.')]
target_parts = [1, 1, 12]
# Compare version components
return version_parts < target_parts
else:
return False
def scrape_fft_club_tournaments(club_code, club_name, start_date=None, end_date=None, page=0):
"""
Scrapes FFT tournaments using Playwright with detailed debugging
"""
logger.info(f"Starting Playwright scraping for {club_name}")
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page_obj = browser.new_page()
page_obj.set_extra_http_headers({
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15"
})
# Navigate to FFT
target_url = "https://tenup.fft.fr/recherche/tournois"
logger.info(f"Navigating to: {target_url}")
page_obj.goto(target_url)
# page_obj.wait_for_timeout(7000)
current_url = page_obj.url
logger.info(f"Current URL: {current_url}")
if "queue-it.net" in current_url.lower():
logger.warning("Still in Queue-It")
browser.close()
return None
# Extract form_build_id
form_input = page_obj.query_selector('input[name="form_build_id"]')
if not form_input:
logger.error("Could not find form_build_id")
browser.close()
return None
form_build_id = form_input.get_attribute('value')
logger.info(f"Extracted form_build_id: {form_build_id}")
# Build parameters
date_component = ""
if start_date and end_date:
date_component = f"&date[start]={start_date}&date[end]={end_date}"
elif start_date:
try:
start_dt = datetime.strptime(start_date, "%d/%m/%y")
end_dt = start_dt + timedelta(days=90)
date_component = f"&date[start]={start_date}&date[end]={end_dt.strftime('%d/%m/%y')}"
except ValueError:
logger.warning(f"Invalid date format: {start_date}")
club_name_encoded = club_name.replace(" ", "+")
club_code_clean = club_code.replace(" ", "")
params = f"recherche_type=club&club[autocomplete][value_container][value_field]={club_code_clean}&club[autocomplete][value_container][label_field]={club_name_encoded}&pratique=PADEL{date_component}&page={page}&sort=dateDebut+asc&form_build_id={form_build_id}&form_id=recherche_tournois_form&_triggering_element_name=submit_page&_triggering_element_value=Submit+page"
logger.info(f"AJAX Parameters: {params}")
# Make AJAX request and capture the full response
ajax_script = f"""
async () => {{
try {{
const response = await fetch('https://tenup.fft.fr/system/ajax', {{
method: 'POST',
headers: {{
'Accept': 'application/json, text/javascript, */*; q=0.01',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
'Origin': 'https://tenup.fft.fr',
'Referer': 'https://tenup.fft.fr/recherche/tournois'
}},
body: `{params}`
}});
const status = response.status;
const responseText = await response.text();
return {{
success: response.ok,
status: status,
responseText: responseText
}};
}} catch (error) {{
return {{
success: false,
error: error.message
}};
}}
}}
"""
logger.info("Making AJAX request...")
result = page_obj.evaluate(ajax_script)
browser.close()
# Print the full response for debugging
logger.info(f"AJAX Response Status: {result.get('status')}")
logger.info(f"AJAX Response Success: {result.get('success')}")
if result.get('success'):
response_text = result.get('responseText', '')
logger.info(f"Raw Response Length: {len(response_text)}")
# logger.info(f"Raw Response (first 500 chars): {response_text[:500]}")
try:
# Try to parse as JSON
json_data = json.loads(response_text)
logger.info(f"JSON Response Type: {type(json_data)}")
# logger.info(f"JSON Response: {json.dumps(json_data, indent=2, default=str)}")
# Now try to parse it
parsed_result = _parse_ajax_response(json_data)
# logger.info(f"Parsed Result: {parsed_result}")
return parsed_result
except json.JSONDecodeError as json_error:
logger.error(f"JSON Parse Error: {json_error}")
logger.error(f"Response text: {response_text}")
return None
else:
logger.error(f"AJAX request failed: {result.get('error')}")
return None
except Exception as e:
logger.error(f"Error in Playwright scraping: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
return None
def scrape_fft_club_tournaments_all_pages(club_code, club_name, start_date=None, end_date=None):
"""
Scrapes all pages of FFT tournaments for a specific club
"""
logger.info(f"Starting complete tournament scraping for {club_name}")
all_tournaments = []
page = 0
while True:
try:
# Call the working single-page function
result = scrape_fft_club_tournaments(
club_code=club_code,
club_name=club_name,
start_date=start_date,
end_date=end_date,
page=page
)
# Debug: Log what we got
logger.info(f"Page {page} result: {result}")
if not result:
logger.warning(f"No result for page {page}")
break
tournaments = result.get('tournaments', [])
logger.info(f"Page {page} returned {len(tournaments)} tournaments")
if not tournaments:
logger.info(f"No tournaments on page {page}, stopping")
break
all_tournaments.extend(tournaments)
# Check if we have all results
total_results = result.get('total_results', 0)
logger.info(f"Total so far: {len(all_tournaments)}, Target: {total_results}")
if len(all_tournaments) >= total_results:
logger.info("Got all tournaments, stopping")
break
page += 1
logger.info(f"Moving to page {page}")
# time.sleep(1) # Rate limiting
except Exception as e:
logger.error(f"Error on page {page}: {e}")
break
logger.info(f"Completed scraping: {len(all_tournaments)} tournaments across {page + 1} pages")
return {
'tournaments': all_tournaments,
'total_results': len(all_tournaments),
'current_count': len(all_tournaments),
'pages_scraped': page + 1
}
def get_umpire_data(tournament_id):
"""
Scrapes umpire data for a specific tournament
"""
logger.info(f"Getting umpire data for tournament {tournament_id}")
try:
url = f"https://tenup.fft.fr/tournoi/{tournament_id}"
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15'
}
response = requests.get(url, headers=headers, timeout=30)
if response.status_code != 200:
logger.error(f"Failed to fetch tournament page: {response.status_code}")
return None, None, None
html_content = response.text
# Extract name
name_pattern = r'tournoi-detail-page-inscription-responsable-title">\s*([^<]+)\s*<'
name_match = re.search(name_pattern, html_content)
name = name_match.group(1).strip() if name_match else None
# Extract email
email_pattern = r'mailto:([^"]+)"'
email_match = re.search(email_pattern, html_content)
email = email_match.group(1) if email_match else None
# Extract phone
phone_pattern = r'<div class="details-bloc">\s*(\d{2}\s+\d{2}\s+\d{2}\s+\d{2}\s+\d{2})\s*</div>'
phone_match = re.search(phone_pattern, html_content)
phone = phone_match.group(1).strip() if phone_match else None
logger.info(f"Extracted umpire data: name={name}, email={email}, phone={phone}")
return name, email, phone
except Exception as e:
logger.error(f"Error getting umpire data: {e}")
return None, None, None
def _parse_ajax_response(commands):
"""
Parse the AJAX response commands to extract tournament data
Returns data in the exact format expected by Swift FederalTournament struct
"""
tournaments = []
try:
# Check for alert commands (maintenance mode)
for command in commands:
if command.get('command') == 'alert':
logger.warning("Maintenance mode detected")
return None
# Find the command with results
result_command = None
for command in commands:
if command.get('command') == 'recherche_tournois_update':
result_command = command
logger.info("Found recherche_tournois_update command!")
break
if result_command and result_command.get('results'):
results = result_command['results']
items = results.get('items', [])
total_results = results.get('nb_results', 0)
logger.info(f"Processing {len(items)} tournaments from results")
for item in items:
# Parse dates - they're already in the correct format
date_debut = item.get('dateDebut')
date_fin = item.get('dateFin')
date_validation = item.get('dateValidation')
# Build the tournament object to match Swift FederalTournament structure
tournament = {
"id": str(item.get('id', '')),
"millesime": item.get('millesime'),
"libelle": item.get('libelle'),
"tmc": item.get('tmc'),
"tarifAdulteChampionnat": item.get('tarifAdulteChampionnat'),
"type": item.get('type'),
"ageReel": item.get('ageReel'),
"naturesTerrains": item.get('naturesTerrains', []),
"idsArbitres": item.get('idsArbitres', []),
"tarifJeuneChampionnat": item.get('tarifJeuneChampionnat'),
"international": item.get('international'),
"inscriptionEnLigne": item.get('inscriptionEnLigne'),
"categorieTournoi": item.get('categorieTournoi'),
"prixLot": item.get('prixLot'),
"paiementEnLigne": item.get('paiementEnLigne'),
"reductionAdherentJeune": item.get('reductionAdherentJeune'),
"reductionAdherentAdulte": item.get('reductionAdherentAdulte'),
"paiementEnLigneObligatoire": item.get('paiementEnLigneObligatoire'),
"villeEngagement": item.get('villeEngagement'),
"senior": item.get('senior'),
"veteran": item.get('veteran'),
"inscriptionEnLigneEnCours": item.get('inscriptionEnLigneEnCours'),
"avecResultatPublie": item.get('avecResultatPublie'),
"code": item.get('code'),
"categorieAge": item.get('categorieAge'),
"codeComite": item.get('codeComite'),
"installations": item.get('installations', []),
"reductionEpreuveSupplementaireJeune": item.get('reductionEpreuveSupplementaireJeune'),
"reductionEpreuveSupplementaireAdulte": item.get('reductionEpreuveSupplementaireAdulte'),
"nomComite": item.get('nomComite'),
"naturesEpreuves": item.get('naturesEpreuves'),
"jeune": item.get('jeune'),
"courrielEngagement": item.get('courrielEngagement'),
"nomClub": item.get('nomClub'),
"installation": item.get('installation'),
"categorieAgeMax": item.get('categorieAgeMax'),
"tournoiInterne": item.get('tournoiInterne'),
"nomLigue": item.get('nomLigue'),
"nomEngagement": item.get('nomEngagement'),
"codeLigue": item.get('codeLigue'),
"modeleDeBalle": item.get('modeleDeBalle'),
"jugeArbitre": item.get('jugeArbitre'),
"adresse2Engagement": item.get('adresse2Engagement'),
"epreuves": item.get('epreuves'),
"dateDebut": date_debut,
"serie": item.get('serie'),
"dateFin": date_fin,
"dateValidation": date_validation,
"codePostalEngagement": item.get('codePostalEngagement'),
"codeClub": item.get('codeClub'),
"prixEspece": item.get('prixEspece'),
"japPhoneNumber": None, # Will be populated by separate umpire call
# Additional fields from the response
"adresse1Engagement": item.get('adresse1Engagement'),
"originalId": item.get('originalId'),
"familleTournoi": item.get('familleTournoi', []),
"isTournoi": item.get('isTournoi'),
"natureWithCatAge": item.get('natureWithCatAge')
}
tournaments.append(tournament)
logger.info(f"Successfully parsed {len(tournaments)} tournaments from response")
return {
'tournaments': tournaments,
'total_results': total_results,
'current_count': len(tournaments)
}
else:
logger.error("No recherche_tournois_update command found in AJAX response")
return {'tournaments': [], 'total_results': 0, 'current_count': 0}
except Exception as e:
logger.error(f"Error parsing AJAX response: {e}")
return None
def scrape_fft_all_tournaments(sorting_option=None, page=0, start_date=None, end_date=None,
city='', distance=15, categories=None, levels=None,
lat=None, lng=None, ages=None, tournament_types=None,
national_cup=False):
"""
Scrapes FFT tournaments using Playwright with detailed debugging
Based exactly on the working scrape_fft_club_tournaments function
"""
logger.info(f"Starting Playwright scraping for city: {city}")
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page_obj = browser.new_page()
page_obj.set_extra_http_headers({
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15"
})
# Navigate to FFT
target_url = "https://tenup.fft.fr/recherche/tournois"
logger.info(f"Navigating to: {target_url}")
page_obj.goto(target_url)
# page_obj.wait_for_timeout(7000)
current_url = page_obj.url
logger.info(f"Current URL: {current_url}")
if "queue-it.net" in current_url.lower():
logger.warning("Still in Queue-It")
browser.close()
return None
# Extract form_build_id
form_input = page_obj.query_selector('input[name="form_build_id"]')
if not form_input:
logger.error("Could not find form_build_id")
browser.close()
return None
form_build_id = form_input.get_attribute('value')
logger.info(f"Extracted form_build_id: {form_build_id}")
# Build parameters - EXACT same pattern as club function
date_component = ""
if start_date and end_date:
date_component = f"&date[start]={start_date}&date[end]={end_date}"
elif start_date:
try:
start_dt = datetime.strptime(start_date, "%d/%m/%y")
end_dt = start_dt + timedelta(days=90)
date_component = f"&date[start]={start_date}&date[end]={end_dt.strftime('%d/%m/%y')}"
except ValueError:
logger.warning(f"Invalid date format: {start_date}")
# Build filter parameters
filter_params = ""
# Add categories filter
if categories:
logger.info(f"Adding categories filter: {categories}")
for category in categories:
filter_params += f"&epreuve[{category}]={category}"
# Add levels filter
if levels:
logger.info(f"Adding levels filter: {levels}")
for level in levels:
filter_params += f"&categorie_tournoi[{level}]={level}"
# Add ages filter
if ages:
logger.info(f"Adding ages filter: {ages}")
for age in ages:
filter_params += f"&categorie_age[{age}]={age}"
# Add types filter
if tournament_types:
logger.info(f"Adding types filter: {tournament_types}")
for t_type in tournament_types:
capitalized_type = t_type.capitalize()
filter_params += f"&type[{capitalized_type}]={capitalized_type}"
# Add national cup filter
if national_cup:
logger.info("Adding national cup filter")
filter_params += "&tournoi_npc=1"
# Fix the sorting parameter
if sorting_option:
sort_param = f"&sort={sorting_option}"
else:
sort_param = "&sort=dateDebut+asc"
# Build city parameters with distance and location
if city and city.strip():
city_name_encoded = city.strip().replace(" ", "+")
# Start with the working base parameters
base_params = f"recherche_type=ville&ville[autocomplete][value_container][value_field]={city_name_encoded}&ville[autocomplete][value_container][label_field]={city_name_encoded}"
# Add distance parameter
distance_param = f"&ville[distance][value_field]={int(distance)}"
# Add lat/lng if provided
location_params = ""
if lat and lng:
location_params = f"&ville[autocomplete][value_container][lat_field]={lat}&ville[autocomplete][value_container][lng_field]={lng}"
# Combine all parameters including filters
params = f"{base_params}{location_params}{distance_param}&pratique=PADEL{date_component}&page={page}{sort_param}&form_build_id={form_build_id}&form_id=recherche_tournois_form&_triggering_element_name=submit_page&_triggering_element_value=Submit+page"
else:
# Default to ligue search if no city provided
params = f"recherche_type=ligue&pratique=PADEL{date_component}{filter_params}&page={page}&sort={sorting_option or 'dateDebut+asc'}&form_build_id={form_build_id}&form_id=recherche_tournois_form&_triggering_element_name=submit_page&_triggering_element_value=Submit+page"
logger.info(f"AJAX Parameters: {params}")
# Make AJAX request and capture the full response - EXACT same as club function
ajax_script = f"""
async () => {{
try {{
const response = await fetch('https://tenup.fft.fr/system/ajax', {{
method: 'POST',
headers: {{
'Accept': 'application/json, text/javascript, */*; q=0.01',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
'Origin': 'https://tenup.fft.fr',
'Referer': 'https://tenup.fft.fr/recherche/tournois'
}},
body: `{params}`
}});
const status = response.status;
const responseText = await response.text();
return {{
success: response.ok,
status: status,
responseText: responseText
}};
}} catch (error) {{
return {{
success: false,
error: error.message
}};
}}
}}
"""
logger.info("Making AJAX request...")
result = page_obj.evaluate(ajax_script)
browser.close()
# Print the full response for debugging - EXACT same as club function
logger.info(f"AJAX Response Status: {result.get('status')}")
logger.info(f"AJAX Response Success: {result.get('success')}")
if result.get('success'):
response_text = result.get('responseText', '')
logger.info(f"Raw Response Length: {len(response_text)}")
try:
# Try to parse as JSON
json_data = json.loads(response_text)
logger.info(f"JSON Response Type: {type(json_data)}")
# Now try to parse it - EXACT same as club function
parsed_result = _parse_ajax_response(json_data)
return parsed_result
except json.JSONDecodeError as json_error:
logger.error(f"JSON Parse Error: {json_error}")
logger.error(f"Response text: {response_text}")
return None
else:
logger.error(f"AJAX request failed: {result.get('error')}")
return None
except Exception as e:
logger.error(f"Error in Playwright scraping: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
return None

@ -1,6 +1,21 @@
from rest_framework import viewsets
from rest_framework.response import Response
from rest_framework.decorators import api_view, permission_classes
from rest_framework import status
from rest_framework.exceptions import MethodNotAllowed
from rest_framework.permissions import IsAuthenticated
from django.conf import settings
from django.http import Http404, HttpResponse, JsonResponse
from django.db.models import Q
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
from django.shortcuts import get_object_or_404
from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, DrawLogSerializer, TournamentSerializer, UserSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, PurchaseSerializer, ShortUserSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer, CustomUserSerializer, UnregisteredTeamSerializer, UnregisteredPlayerSerializer, ImageSerializer
from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer, Image
<<<<<<< HEAD
from crm.serializers import CRMActivitySerializer, CRMProspectSerializer, CRMEntitySerializer
from crm.models import Activity, Prospect, Entity
@ -13,25 +28,21 @@ from rest_framework.exceptions import MethodNotAllowed
from django.http import Http404
from django.db.models import Q
=======
>>>>>>> f988fc1c06ccb26ea16383304e65161733a71fca
from .permissions import IsClubOwner
from .utils import check_version_smaller_than_1_1_12
from .utils import check_version_smaller_than_1_1_12, scrape_fft_club_tournaments, scrape_fft_club_tournaments_all_pages, get_umpire_data, scrape_fft_all_tournaments
from shared.discord import send_discord_log_message
from rest_framework.decorators import permission_classes
from rest_framework.permissions import IsAuthenticated
from django.shortcuts import get_object_or_404
from tournaments.services.payment_service import PaymentService
from django.conf import settings
from tournaments.utils.extensions import create_random_filename
import stripe
import json
import pandas as pd
from tournaments.utils.extensions import create_random_filename
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
import os
from django.http import HttpResponse
import logging
logger = logging.getLogger(__name__)
@ -572,6 +583,312 @@ def validate_stripe_account(request):
'needs_onboarding': True,
}, status=200)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def is_granted_unlimited_access(request):
can_create = False
if request.user and request.user.is_anonymous == False and request.user.owners:
for owner in request.user.owners.all():
purchases = Purchase.objects.filter(user=owner,product_id='app.padelclub.tournament.subscription.unlimited')
for purchase in purchases:
if purchase.is_active():
can_create = True
return JsonResponse({'can_create': can_create}, status=status.HTTP_200_OK)
@api_view(['GET', 'POST'])
@permission_classes([])
def get_fft_club_tournaments(request):
"""
API endpoint to get tournaments for a specific club
Handles pagination automatically to get all results
"""
try:
if request.method == 'POST':
data = request.data
else:
data = request.GET
club_code = data.get('club_code', '62130180')
club_name = data.get('club_name', 'TENNIS SPORTING CLUB DE CASSIS')
start_date = data.get('start_date')
end_date = data.get('end_date')
paginate = data.get('paginate', 'true').lower() == 'true'
if paginate:
# Get all pages automatically (matching Swift behavior)
result = scrape_fft_club_tournaments_all_pages(
club_code=club_code,
club_name=club_name,
start_date=start_date,
end_date=end_date
)
else:
# Get single page
page = int(data.get('page', 0))
result = scrape_fft_club_tournaments(
club_code=club_code,
club_name=club_name,
start_date=start_date,
end_date=end_date,
page=page
)
if result:
return JsonResponse({
'success': True,
'tournaments': result.get('tournaments', []),
'total_results': result.get('total_results', 0),
'current_count': result.get('current_count', 0),
'pages_scraped': result.get('pages_scraped', 1),
'message': f'Successfully scraped {len(result.get("tournaments", []))} tournaments'
}, status=status.HTTP_200_OK)
else:
return JsonResponse({
'success': False,
'tournaments': [],
'total_results': 0,
'current_count': 0,
'message': 'Failed to scrape club tournaments'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Exception as e:
logger.error(f"Error in get_fft_club_tournaments endpoint: {e}")
return JsonResponse({
'success': False,
'tournaments': [],
'message': f'Unexpected error: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET'])
@permission_classes([])
def get_fft_umpire_data(request, tournament_id):
"""
API endpoint to get umpire data for a specific tournament
Returns data that can be used to populate japPhoneNumber field
"""
try:
name, email, phone = get_umpire_data(tournament_id)
return JsonResponse({
'success': True,
'umpire': {
'name': name,
'email': email,
'phone': phone
},
'japPhoneNumber': phone, # Direct field for updating FederalTournament
'message': 'Umpire data retrieved successfully'
}, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error in get_fft_umpire_data endpoint: {e}")
return JsonResponse({
'success': False,
'umpire': None,
'japPhoneNumber': None,
'message': f'Unexpected error: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET', 'POST'])
@permission_classes([])
def get_fft_all_tournaments(request):
"""
API endpoint to get all tournaments with filters
"""
try:
if request.method == 'POST':
data = request.data
else:
data = request.GET
# Extract parameters
sorting_option = data.get('sorting_option', 'dateDebut+asc')
page = int(data.get('page', 0))
start_date = data.get('start_date')
end_date = data.get('end_date')
city = data.get('city', '')
distance = float(data.get('distance', 15))
categories = data.getlist('categories') if hasattr(data, 'getlist') else data.get('categories', [])
levels = data.getlist('levels') if hasattr(data, 'getlist') else data.get('levels', [])
lat = data.get('lat')
lng = data.get('lng')
ages = data.getlist('ages') if hasattr(data, 'getlist') else data.get('ages', [])
tournament_types = data.getlist('types') if hasattr(data, 'getlist') else data.get('types', [])
national_cup = data.get('national_cup', 'false').lower() == 'true'
result = scrape_fft_all_tournaments(
sorting_option=sorting_option,
page=page,
start_date=start_date,
end_date=end_date,
city=city,
distance=distance,
categories=categories,
levels=levels,
lat=lat,
lng=lng,
ages=ages,
tournament_types=tournament_types,
national_cup=national_cup
)
if result:
return JsonResponse({
'success': True,
'tournaments': result.get('tournaments', []),
'total_results': result.get('total_results', 0),
'current_count': result.get('current_count', 0),
'page': page,
'message': f'Successfully scraped {len(result.get("tournaments", []))} tournaments'
}, status=status.HTTP_200_OK)
else:
return JsonResponse({
'success': False,
'tournaments': [],
'total_results': 0,
'current_count': 0,
'message': 'Failed to scrape all tournaments'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Exception as e:
logger.error(f"Error in get_fft_all_tournaments endpoint: {e}")
return JsonResponse({
'success': False,
'tournaments': [],
'message': f'Unexpected error: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET', 'POST'])
@permission_classes([])
def get_fft_federal_clubs(request):
"""
API endpoint to get federal clubs
"""
try:
if request.method == 'POST':
data = request.data
else:
data = request.GET
country = data.get('country', 'fr')
city = data.get('city', '')
radius = float(data.get('radius', 15))
latitude = data.get('latitude')
longitude = data.get('longitude')
if latitude:
latitude = float(latitude)
if longitude:
longitude = float(longitude)
result = scrape_federal_clubs(
country=country,
city=city,
radius=radius,
latitude=latitude,
longitude=longitude
)
if result:
return JsonResponse({
'success': True,
'clubs': result,
'message': 'Federal clubs retrieved successfully'
}, status=status.HTTP_200_OK)
else:
return JsonResponse({
'success': False,
'clubs': [],
'message': 'Failed to retrieve federal clubs'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Exception as e:
logger.error(f"Error in get_fft_federal_clubs endpoint: {e}")
return JsonResponse({
'success': False,
'clubs': [],
'message': f'Unexpected error: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET', 'POST'])
@permission_classes([])
def get_fft_club_tournaments_with_umpire_data(request):
"""
Combined endpoint that gets club tournaments and enriches them with umpire data
This matches the complete workflow from your Swift code
"""
try:
if request.method == 'POST':
data = request.data
else:
data = request.GET
club_code = data.get('club_code', '62130180')
club_name = data.get('club_name', 'TENNIS SPORTING CLUB DE CASSIS')
start_date = data.get('start_date')
end_date = data.get('end_date')
include_umpire_data = data.get('include_umpire_data', 'false').lower() == 'true'
# Get all tournaments for the club
result = scrape_fft_club_tournaments_all_pages(
club_code=club_code,
club_name=club_name,
start_date=start_date,
end_date=end_date
)
if not result:
return JsonResponse({
'success': False,
'tournaments': [],
'message': 'Failed to scrape club tournaments'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
tournaments = result.get('tournaments', [])
# Enrich with umpire data if requested
if include_umpire_data:
logger.info(f"Enriching {len(tournaments)} tournaments with umpire data...")
for tournament in tournaments:
try:
tournament_id = tournament.get('id')
if tournament_id:
name, email, phone = get_umpire_data(tournament_id)
tournament['japPhoneNumber'] = phone
# Also update jugeArbitre if we got more data
if name and not tournament.get('jugeArbitre'):
tournament['jugeArbitre'] = {
'nom': name.split(' ')[-1] if name else None,
'prenom': ' '.join(name.split(' ')[:-1]) if name and ' ' in name else name
}
# Small delay to avoid rate limiting
time.sleep(0.5)
except Exception as e:
logger.warning(f"Failed to get umpire data for tournament {tournament_id}: {e}")
continue
return JsonResponse({
'success': True,
'tournaments': tournaments,
'total_results': result.get('total_results', 0),
'current_count': len(tournaments),
'pages_scraped': result.get('pages_scraped', 1),
'umpire_data_included': include_umpire_data,
'message': f'Successfully scraped {len(tournaments)} tournaments' +
(' with umpire data' if include_umpire_data else '')
}, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error in get_fft_club_tournaments_with_umpire_data endpoint: {e}")
return JsonResponse({
'success': False,
'tournaments': [],
'message': f'Unexpected error: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
### CRM
class CRMActivityViewSet(SoftDeleteViewSet):

@ -204,8 +204,17 @@ LOGGING = {
'backupCount': 10,
'formatter': 'verbose',
},
'mail_admins': {
'level': 'ERROR',
'class': 'django.utils.log.AdminEmailHandler'
},
},
'loggers': {
'django.request': {
'handlers': ['mail_admins'],
'level': 'ERROR',
'propagate': True,
},
'django': {
'handlers': ['console', 'file'],
'level': 'INFO',

@ -18,3 +18,4 @@ cryptography==41.0.7
stripe==11.6.0
django-background-tasks==1.2.8
Pillow==10.2.0
playwright==1.40.0

@ -90,7 +90,7 @@ class TournamentAdmin(SyncedObjectAdmin):
list_display = ['display_name', 'event', 'is_private', 'start_date', 'payment', 'creator', 'is_canceled']
list_filter = [StartDateRangeFilter, 'is_deleted', 'event__creator']
ordering = ['-start_date']
search_fields = ['id', 'display_name', 'federal_level_category']
search_fields = ['id', 'federal_level_category']
def dashboard_view(self, request):
"""Tournament dashboard view with comprehensive statistics"""
@ -322,7 +322,7 @@ class RoundAdmin(SyncedObjectAdmin):
class PlayerRegistrationAdmin(SyncedObjectAdmin):
list_display = ['first_name', 'last_name', 'licence_id', 'rank']
search_fields = ['id', 'first_name', 'last_name', 'licence_id__icontains']
list_filter = ['registered_online', TeamScoreTournamentListFilter]
list_filter = ['registered_online', 'payment_id', TeamScoreTournamentListFilter]
ordering = ['last_name', 'first_name']
raw_id_fields = ['team_registration'] # Add this line
list_per_page = 50 # Controls pagination on the list view

@ -15,9 +15,9 @@ from django.middleware.csrf import get_token
import concurrent.futures
from functools import partial
default_sexe = "H"
default_sexe = 'F'
default_id_homologation = "82546485"
default_session_id = "JSESSIONID=CFE4A886CB92764066D1EC920EF9AB1C; AWSALB=c4OHU4Lw6YW6QRsoD1ktcfSgEExZZil/dDetMp3teMKtQ7RlA3VIM8ZHnJH8K3GYMoPu0M61xLjZo64rBNzDEO5tISKEYIX79baengXCKXnaqdqNqHJ7cSPeon+g; AWSALBCORS=c4OHU4Lw6YW6QRsoD1ktcfSgEExZZil/dDetMp3teMKtQ7RlA3VIM8ZHnJH8K3GYMoPu0M61xLjZo64rBNzDEO5tISKEYIX79baengXCKXnaqdqNqHJ7cSPeon+g; tc_cj_v2=m_iZZZ%22**%22%27%20ZZZKQNRSMQNLMRQLZZZ%5D777%5Ecl_%5Dny%5B%5D%5D_mmZZZZZZKQOKMQKQONJKOZZZ%5D777_rn_lh%5BfyfcheZZZ222H%2B%7B%7E%20%27-%20%21%20-%20%29%7D%20H%7D*%28ZZZKQOKMQKRLNNPMZZZ%5D777%5Ecl_%5Dny%5B%5D%5D_mmZZZZZZKQOKNLQOPMLSMZZZ%5D; tc_cj_v2_cmp=; tc_cj_v2_med=; incap_ses_2223_2712217=/I1fA56LxWI8KbyfPa/ZHpmpZGgAAAAAoadzHEsPdo9W59AkhfXcmQ==; xtan=-; xtant=1; pa_vid=%22mckhos3iasswydjm%22; datadome=oi7wKIO2uaUDCcpICiRO1_hEYcwyQWDVbXyNCSkAmr315~8pnPcuXWKfvNEEz~jKcoORIOThSRe~AxoRRrPLUsr0miWm7AdAcy~_3hABc1ZWfRt~SKGa_uhyqiE0Hzfj; _pcid=%7B%22browserId%22%3A%22mckhos3iasswydjm%22%2C%22_t%22%3A%22ms8wm9hs%7Cmckhos5s%22%7D; _pctx=%7Bu%7DN4IgrgzgpgThIC4B2YA2qA05owMoBcBDfSREQpAeyRCwgEt8oBJAE0RXSwH18yBbCAA4A7vwCcACwgAffgGMA1pMoQArPAC%2BQA; EA_SESSION_ID=E15E1DD5A23272A1A0CC3B8CEDF56B65; refresh_token=eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICIzYjQ2ODk1ZC0zN2EzLTQzM2QtYmQ1My01N2QxZTM1YTI3NzkifQ.eyJleHAiOjE3NTY1NTM5MjgsImlhdCI6MTc1MTM3MjAwNCwianRpIjoiYzJiNzA3N2UtZmQ5MS00ZGM4LWI4ZDEtMzA2MDdkYjk5MTgxIiwiaXNzIjoiaHR0cHM6Ly9sb2dpbi5mZnQuZnIvcmVhbG1zL2Nvbm5lY3QiLCJhdWQiOiJodHRwczovL2xvZ2luLmZmdC5mci9yZWFsbXMvY29ubmVjdCIsInN1YiI6IjI3ZDQ5NzRjLTEwZWUtNDNlOC1iOTczLWUyMzc2MDM1ZTE0MSIsInR5cCI6IlJlZnJlc2giLCJhenAiOiJtZWEtc2l0ZSIsInNpZCI6IjM5NTZjMzZlLTczMWItNDJkNy1iNjI2LTE2MGViY2Y2YTY2ZiIsInNjb3BlIjoib3BlbmlkIHJvbGVzIHJlYWQ6bGljZW5jZSByZWFkOmlkZW50aXR5IGVtYWlsIHByb2ZpbGUifQ.e6v5vlen985vSFJhrgMQTTB3fzzsnwugPfXKoyib1QSIBZ9kC47h1cYwcpam0VmZ9vRD_y0hVC14jDvBR6d1dQ; user_login=10000984864; user_token=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJRaTV3bWx2bTNuX2p1YW4tSTl1dHo3UGZRLU1tVVlvektwSExhbm9lTXI4In0.eyJleHAiOjE3NTEzNzIzMDQsImlhdCI6MTc1MTM3MjAwNCwianRpIjoiMzEzMGVhODUtNjFjNC00OGRjLWFlNGMtZTIwZmZhYTU3YTlhIiwiaXNzIjoiaHR0cHM6Ly9sb2dpbi5mZnQuZnIvcmVhbG1zL2Nvbm5lY3QiLCJhdWQiOlsiZmVkLWFwaSIsImFjY291bnQiXSwic3ViIjoiMjdkNDk3NGMtMTBlZS00M2U4LWI5NzMtZTIzNzYwMzVlMTQxIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoibWVhLXNpdGUiLCJzaWQiOiIzOTU2YzM2ZS03MzFiLTQyZDctYjYyNi0xNjBlYmNmNmE2NmYiLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiZGVmYXVsdC1yb2xlcy1jb25uZWN0Il19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCJdfX0sInNjb3BlIjoib3BlbmlkIHJlYWQ6bGljZW5jZSByZWFkOmlkZW50aXR5IGVtYWlsIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaWRDcm0iOiIxMDAwMDk4NDg2NCIsIm5hbWUiOiJSYXptaWcgU0FSS0lTU0lBTiIsInByZWZlcnJlZF91c2VybmFtZSI6InJhem1vZyIsImdpdmVuX25hbWUiOiJSYXptaWciLCJzZXNzaW9uX3N0YXRlIjoiMzk1NmMzNmUtNzMxYi00MmQ3LWI2MjYtMTYwZWJjZjZhNjZmIiwibG9jYWxlIjoiZnIiLCJmYW1pbHlfbmFtZSI6IlNBUktJU1NJQU4iLCJlbWFpbCI6InJhem1pZy5zYXJraXNzaWFuQGdtYWlsLmNvbSJ9.VSjG2htaUMt_acrqL3VcAjVMhAno9q0vdb7LTzw8UVbjIiDLzhR5msRxI8h8gSJ38kFLaa7f_SFGLIsRCSdcmhYRd2zKIrcPE-QFKbsPnH69xN2i3giMMiYEy3hj__IIyijt9z3W4KXeQdwUrlXPxprlXQ2sYTlZG63HlCGq1iI3Go9eXFmNDNM6p1jBypXcHEvJr6HwNcRdn6ZGfZ9LLMZ2aMEJAhDqL2CLrFrOZkGQpFz7ITUi_DVJAqh5DmTK1JqPswcOjhuZhDT7qWNfIleV-L7XCwvofxBwkSX9ve9l_3COZJXbsMiiRdCSTZtewlFRfgo4IuAu3g06fmJw7g; TCID=; nlbi_2712217=Ok4tKplxIEN+k1gmb9lUTgAAAAA70zbGXpiElrV2qkRjBeXO; visid_incap_2712217=LW/brcN4Rwml/7waoG/rloFBYmgAAAAAQUIPAAAAAAAlHbwlYSPbNS2qq3UBZNK8; TCPID=125629554310878226394; xtvrn=$548419$"
default_session_id = "JSESSIONID=CEC70DF4428E76E1FD1BFE5C66904708; AWSALB=omN79AoahQc27iH5vvO14U7ZrjH30faWu5delXAthjiYVq4jzbeXJ0IOmVTGjG6YDoi7Do2uCswhEaO/smz1QG733RpYlsw7ShlFV/X2aLn2L7/DZ5KUBA/8LPNr; AWSALBCORS=omN79AoahQc27iH5vvO14U7ZrjH30faWu5delXAthjiYVq4jzbeXJ0IOmVTGjG6YDoi7Do2uCswhEaO/smz1QG733RpYlsw7ShlFV/X2aLn2L7/DZ5KUBA/8LPNr; incap_ses_2223_2712217=g6xvVwmOBh66wpenPa/ZHpN2ZmgAAAAAcmuXPCKJ1/mEqKuQEXJS2Q==; tc_cj_v2=m_iZZZ%22**%22%27%20ZZZKQNRSMQNLMRQLZZZ%5D777%5Ecl_%5Dny%5B%5D%5D_mmZZZZZZKQOKMQKQONJKOZZZ%5D777_rn_lh%5BfyfcheZZZ222H%2B%7B%7E%20%27-%20%21%20-%20%29%7D%20H%7D*%28ZZZKQOKMQKRLNNPMZZZ%5D777%5Ecl_%5Dny%5B%5D%5D_mmZZZZZZKQOKNRJOLLQOJZZZ%5D777_rn_lh%5BfyfcheZZZ%2F%20%290%2BH%2C0%200%20G%24%2FH%29%20%2FZZZKQOKOLRLNNQJLZZZ%5D; tc_cj_v2_cmp=; tc_cj_v2_med=; SSESS7ba44afc36c80c3faa2b8fa87e7742c5=4-IzUXNKXq_BQFMLjjivcLW14OXgk3lLPl18WYgSmU0; xtan=-; xtant=1; pa_vid=%22mckhos3iasswydjm%22; datadome=oi7wKIO2uaUDCcpICiRO1_hEYcwyQWDVbXyNCSkAmr315~8pnPcuXWKfvNEEz~jKcoORIOThSRe~AxoRRrPLUsr0miWm7AdAcy~_3hABc1ZWfRt~SKGa_uhyqiE0Hzfj; _pcid=%7B%22browserId%22%3A%22mckhos3iasswydjm%22%2C%22_t%22%3A%22ms8wm9hs%7Cmckhos5s%22%7D; _pctx=%7Bu%7DN4IgrgzgpgThIC4B2YA2qA05owMoBcBDfSREQpAeyRCwgEt8oBJAE0RXSwH18yBbCAA4A7vwCcACwgAffgGMA1pMoQArPAC%2BQA; EA_SESSION_ID=E15E1DD5A23272A1A0CC3B8CEDF56B65; refresh_token=eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICIzYjQ2ODk1ZC0zN2EzLTQzM2QtYmQ1My01N2QxZTM1YTI3NzkifQ.eyJleHAiOjE3NTY1NTM5MjgsImlhdCI6MTc1MTM3MjAwNCwianRpIjoiYzJiNzA3N2UtZmQ5MS00ZGM4LWI4ZDEtMzA2MDdkYjk5MTgxIiwiaXNzIjoiaHR0cHM6Ly9sb2dpbi5mZnQuZnIvcmVhbG1zL2Nvbm5lY3QiLCJhdWQiOiJodHRwczovL2xvZ2luLmZmdC5mci9yZWFsbXMvY29ubmVjdCIsInN1YiI6IjI3ZDQ5NzRjLTEwZWUtNDNlOC1iOTczLWUyMzc2MDM1ZTE0MSIsInR5cCI6IlJlZnJlc2giLCJhenAiOiJtZWEtc2l0ZSIsInNpZCI6IjM5NTZjMzZlLTczMWItNDJkNy1iNjI2LTE2MGViY2Y2YTY2ZiIsInNjb3BlIjoib3BlbmlkIHJvbGVzIHJlYWQ6bGljZW5jZSByZWFkOmlkZW50aXR5IGVtYWlsIHByb2ZpbGUifQ.e6v5vlen985vSFJhrgMQTTB3fzzsnwugPfXKoyib1QSIBZ9kC47h1cYwcpam0VmZ9vRD_y0hVC14jDvBR6d1dQ; user_login=10000984864; user_token=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJRaTV3bWx2bTNuX2p1YW4tSTl1dHo3UGZRLU1tVVlvektwSExhbm9lTXI4In0.eyJleHAiOjE3NTEzNzIzMDQsImlhdCI6MTc1MTM3MjAwNCwianRpIjoiMzEzMGVhODUtNjFjNC00OGRjLWFlNGMtZTIwZmZhYTU3YTlhIiwiaXNzIjoiaHR0cHM6Ly9sb2dpbi5mZnQuZnIvcmVhbG1zL2Nvbm5lY3QiLCJhdWQiOlsiZmVkLWFwaSIsImFjY291bnQiXSwic3ViIjoiMjdkNDk3NGMtMTBlZS00M2U4LWI5NzMtZTIzNzYwMzVlMTQxIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoibWVhLXNpdGUiLCJzaWQiOiIzOTU2YzM2ZS03MzFiLTQyZDctYjYyNi0xNjBlYmNmNmE2NmYiLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiZGVmYXVsdC1yb2xlcy1jb25uZWN0Il19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCJdfX0sInNjb3BlIjoib3BlbmlkIHJlYWQ6bGljZW5jZSByZWFkOmlkZW50aXR5IGVtYWlsIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaWRDcm0iOiIxMDAwMDk4NDg2NCIsIm5hbWUiOiJSYXptaWcgU0FSS0lTU0lBTiIsInByZWZlcnJlZF91c2VybmFtZSI6InJhem1vZyIsImdpdmVuX25hbWUiOiJSYXptaWciLCJzZXNzaW9uX3N0YXRlIjoiMzk1NmMzNmUtNzMxYi00MmQ3LWI2MjYtMTYwZWJjZjZhNjZmIiwibG9jYWxlIjoiZnIiLCJmYW1pbHlfbmFtZSI6IlNBUktJU1NJQU4iLCJlbWFpbCI6InJhem1pZy5zYXJraXNzaWFuQGdtYWlsLmNvbSJ9.VSjG2htaUMt_acrqL3VcAjVMhAno9q0vdb7LTzw8UVbjIiDLzhR5msRxI8h8gSJ38kFLaa7f_SFGLIsRCSdcmhYRd2zKIrcPE-QFKbsPnH69xN2i3giMMiYEy3hj__IIyijt9z3W4KXeQdwUrlXPxprlXQ2sYTlZG63HlCGq1iI3Go9eXFmNDNM6p1jBypXcHEvJr6HwNcRdn6ZGfZ9LLMZ2aMEJAhDqL2CLrFrOZkGQpFz7ITUi_DVJAqh5DmTK1JqPswcOjhuZhDT7qWNfIleV-L7XCwvofxBwkSX9ve9l_3COZJXbsMiiRdCSTZtewlFRfgo4IuAu3g06fmJw7g; TCID=; nlbi_2712217=Ok4tKplxIEN+k1gmb9lUTgAAAAA70zbGXpiElrV2qkRjBeXO; visid_incap_2712217=LW/brcN4Rwml/7waoG/rloFBYmgAAAAAQUIPAAAAAAAlHbwlYSPbNS2qq3UBZNK8; TCPID=125629554310878226394; xtvrn=$548419$"
def calculate_age_from_birth_date(birth_date_str):
"""
@ -36,9 +36,10 @@ def calculate_age_from_birth_date(birth_date_str):
except (ValueError, TypeError):
return None
def find_best_license_match(license_results, player_age_sportif):
def find_best_license_match(license_results, player):
"""
Find the best matching license from multiple results using ageSportif comparison
Also filters out players without valid classement data
Args:
license_results: List of license data from API
@ -47,21 +48,90 @@ def find_best_license_match(license_results, player_age_sportif):
Returns:
Tuple of (best_match, match_info)
"""
# Get player's age from ranking data for duplicate matching
player_age_sportif = player.get('ageSportif')
rank = player.get('classement')
lastname = player.get('nom')
firstname = player.get('prenom')
if not license_results:
return None, {"reason": "no_results"}
if len(license_results) == 1:
return license_results[0], {"reason": "single_result", "age_match": "n/a"}
# First, filter out players without valid classement data
def has_valid_classement(license_data, rank):
"""Check if a license has valid classement data"""
classement = license_data.get('classement', {})
if not classement:
return False
# Check if any of the key classement fields have meaningful data
date_fr = classement.get('dateFr', '').strip()
rang = classement.get('rang')
points = classement.get('points')
date = classement.get('date')
# Consider it valid if at least one of these conditions is met:
# - dateFr is not empty
# - rang is not None
# - points is not None (and > 0)
# - date is not None
return (
rang is not None and rang == rank
)
# First, filter out players without valid classement data
def has_valid_name(license_data, firstname, lastname):
lk_firstname = license_data.get('prenom', '')
lk_lastname = license_data.get('nom', '')
if not lk_firstname and not lk_lastname:
return False
return (
lk_firstname == firstname and lk_lastname == lastname
)
# Filter license results to only include those with valid classement
valid_license_results = [
license_data for license_data in license_results
if has_valid_name(license_data, firstname, lastname)
if has_valid_classement(license_data, rank)
]
# If no valid results after filtering, return None
if not valid_license_results:
return None, {
"reason": "no_valid_classement",
"original_count": len(license_results),
"filtered_count": 0
}
# If only one valid result, return it
if len(valid_license_results) == 1:
return valid_license_results[0], {
"reason": "single_valid_result",
"original_count": len(license_results),
"filtered_count": 1,
"age_match": "n/a"
}
# If we don't have ageSportif from ranking, take the first match
# If we don't have ageSportif from ranking, take the first valid match
if player_age_sportif is None:
return license_results[0], {"reason": "no_age_data", "used_first_result": True}
return valid_license_results[0], {
"reason": "no_age_data_used_first_valid",
"original_count": len(license_results),
"filtered_count": len(valid_license_results),
"used_first_result": True
}
best_match = None
best_age_diff = float('inf')
match_details = []
best_match_count = 0
for i, license_data in enumerate(license_results):
for i, license_data in enumerate(valid_license_results):
birth_date_fr = license_data.get('dateNaissanceFr')
calculated_age = calculate_age_from_birth_date(birth_date_fr)
@ -70,36 +140,53 @@ def find_best_license_match(license_results, player_age_sportif):
"dateNaissanceFr": birth_date_fr,
"calculated_age": calculated_age,
"player_age_sportif": player_age_sportif,
"age_difference": None
"age_difference": None,
"license": license_data.get('licence'),
"classement": license_data.get('classement', {})
}
if calculated_age is not None:
age_diff = abs(calculated_age - player_age_sportif)
match_detail["age_difference"] = age_diff
if age_diff < best_age_diff:
if age_diff < best_age_diff and best_age_diff > 1 and age_diff < 2:
best_age_diff = age_diff
best_match = license_data
best_match_count = 1
elif age_diff <= best_age_diff:
best_match_count += 1
match_details.append(match_detail)
# If no match found with valid age, use first result
# If no match found with valid age, use first valid result
if best_match is None:
best_match = license_results[0]
match_info = {
"reason": "no_valid_ages",
"reason": "no_valid_ages_used_first_valid",
"original_count": len(license_results),
"filtered_count": len(valid_license_results),
"used_first_result": True,
"match_details": match_details
}
return valid_license_results[0], match_info
else:
match_info = {
"reason": "age_matched",
"best_age_difference": best_age_diff,
"total_candidates": len(license_results),
"match_details": match_details
}
return best_match, match_info
if best_match_count == 1:
match_info = {
"reason": "age_matched",
"best_age_difference": best_age_diff,
"total_candidates": len(license_results),
"valid_candidates": len(valid_license_results),
"match_details": match_details
}
return best_match, match_info
else:
match_info = {
"reason": "multiple_matches",
"best_age_difference": best_age_diff,
"total_candidates": len(license_results),
"valid_candidates": len(valid_license_results),
"match_details": match_details
}
return None, match_info
@staff_member_required
def test_player_details_apis(request):
@ -1089,8 +1176,8 @@ def search_player_by_name(request):
if request.method == 'POST':
session_id = request.POST.get('sessionId', '').strip()
id_homologation = request.POST.get('idHomologation', '').strip()
nom = request.POST.get('nom', '').strip()
prenom = request.POST.get('prenom', '').strip()
nom = request.POST.get('nom', '')
prenom = request.POST.get('prenom', '')
if not session_id or not id_homologation:
messages.error(request, "sessionId and idHomologation are required.")
@ -1396,11 +1483,11 @@ def enrich_rankings_with_licenses(request):
return player, False, None
# Keep original case and accents, just clean up any extra whitespace
nom = raw_nom.strip()
prenom = raw_prenom.strip()
nom = raw_nom
prenom = raw_prenom
# Get player's gender
sexe = player.get('sexe', 'H') # Default to 'H' for male
sexe = player.get('sexe', default_sexe) # Default to 'H' for male
# Setup license lookup headers
license_headers = {
@ -1440,11 +1527,12 @@ def enrich_rankings_with_licenses(request):
presence_doublon = license_data['object'].get('presenceDoublon', False)
if liste_joueurs:
# Get player's age from ranking data for duplicate matching
player_age_sportif = player.get('ageSportif')
# Find the best matching license using age comparison
license_info, match_info = find_best_license_match(liste_joueurs, player_age_sportif)
license_info, match_info = find_best_license_match(liste_joueurs, player)
if license_info is None:
player['license_lookup_status'] = 'too_many_results'
player['presenceDoublon'] = presence_doublon
return player, False, f"Failed {nom} {prenom} {player['idCrm']} -> Too many results"
# Add all license data to player
player['licence'] = license_info.get('licence')
@ -1476,7 +1564,7 @@ def enrich_rankings_with_licenses(request):
else:
player['license_lookup_status'] = 'no_results'
player['presenceDoublon'] = presence_doublon
return player, False, f"Failed {nom} {prenom} {player['idCrm']} -> No results"
return player, False, f"Failed {nom} {prenom} {player['idCrm']} {presence_doublon} -> No results"
else:
player['license_lookup_status'] = 'no_data'
return player, False, f"Failed {nom} {prenom} {player['idCrm']} -> No data"
@ -1690,7 +1778,7 @@ def enrich_rankings_with_licenses(request):
<div class="form-group">
<label for="max_workers">Concurrent Workers (1-50):</label>
<input type="number" id="max_workers" name="max_workers" class="form-control" value="10" min="1" max="200">
<input type="number" id="max_workers" name="max_workers" class="form-control" value="100" min="1" max="200">
<small>Higher values = faster processing but more server load. Adjust based on server capacity.</small>
</div>

@ -20,10 +20,12 @@ class Command(BaseCommand):
parser.add_argument('--clubs', type=int, default=10, help='Number of top clubs to display')
parser.add_argument('--leagues', type=int, default=10, help='Number of top leagues to display')
parser.add_argument('--find-anonymous', action='store_true', help='Find and match anonymous players with previous month rankings')
parser.add_argument('--confidence', type=float, default=0.7, help='Confidence threshold for automatic matching (0-1)')
parser.add_argument('--confidence', type=int, default=7, help='Confidence threshold for automatic matching (0-1)')
parser.add_argument('--auto-match', action='store_true', help='Automatically match anonymous players when confidence is high')
parser.add_argument('--output', type=str, help='Save results to output file')
parser.add_argument('--verbose', action='store_true', help='Show detailed matching information')
parser.add_argument('--named-only', action='store_true', help='Process only anonymous players WITH names (missing license)')
parser.add_argument('--unnamed-only', action='store_true', help='Process only anonymous players WITHOUT names')
def handle(self, *args, **options):
# Base directory for rankings files
@ -53,7 +55,7 @@ class Command(BaseCommand):
# Generate statistics
if players:
self.generate_statistics(players, options)
# self.generate_statistics(players, options)
# Find anonymous players if requested
if options['find_anonymous']:
@ -243,10 +245,17 @@ class Command(BaseCommand):
self.stdout.write(self.style.SUCCESS(f'\nAnonymous players: {len(anonymous_players)} ({(len(anonymous_players) / total_players) * 100:.1f}%)'))
def is_anonymous_player(self, player):
"""Check if a player is anonymous (missing name data)"""
# Define criteria for anonymous players - adjust as needed
return (player['name'] == 'N/A' or player['name'] == '' or
player['first_name'] == 'N/A' or player['first_name'] == '')
"""Check if a player is anonymous (missing name data or license)"""
# Player is anonymous if they have no name data
if (player['name'] == 'N/A' or player['name'] == '' or
player['first_name'] == 'N/A' or player['first_name'] == ''):
return True
# Player is also anonymous if they have name but no license
if (player['license'] == 'N/A' or player['license'] == ''):
return True
return False
def player_exists_in_current_month(self, prev_player, current_players_indexes):
"""
@ -276,8 +285,11 @@ class Command(BaseCommand):
self.stdout.write("Building player indexes for fast lookup...")
start_time = datetime.now()
# Players to index (only non-anonymous)
players_to_index = [p for p in current_players if not self.is_anonymous_player(p)]
# Only index players that have BOTH name AND license
players_to_index = [p for p in current_players
if (p['license'] != 'N/A' and p['license'] != '') and
(p['name'] != 'N/A' and p['name'] != '') and
(p['first_name'] != 'N/A' and p['first_name'] != '')]
# Create license index
license_index = {}
@ -376,14 +388,59 @@ class Command(BaseCommand):
matched_count = 0
# Identify anonymous players
anonymous_players = [p for p in current_players if self.is_anonymous_player(p)]
if not anonymous_players:
all_anonymous_players = [p for p in current_players if self.is_anonymous_player(p)]
if not all_anonymous_players:
self.stdout.write(self.style.SUCCESS('No anonymous players found!'))
if return_count:
return 0
return
self.stdout.write(self.style.SUCCESS(f'\nFound {len(anonymous_players)} anonymous players. Looking for matches...'))
# Check for conflicting options
if options['named_only'] and options['unnamed_only']:
self.stderr.write(self.style.ERROR('Cannot use both --named-only and --unnamed-only options together'))
if return_count:
return 0
return
# Sort anonymous players by type
anonymous_players_with_names = []
anonymous_players_without_names = []
for player in all_anonymous_players:
if (player['name'] != 'N/A' and player['name'] != '' and
player['first_name'] != 'N/A' and player['first_name'] != ''):
anonymous_players_with_names.append(player)
else:
anonymous_players_without_names.append(player)
# Select which players to process based on options
if options['named_only']:
anonymous_players = anonymous_players_with_names
processing_type = "named anonymous players (with names but missing license)"
elif options['unnamed_only']:
anonymous_players = anonymous_players_without_names
processing_type = "unnamed anonymous players (missing names)"
else:
# Default behavior: process named players first, then unnamed
anonymous_players = anonymous_players_with_names + anonymous_players_without_names
processing_type = "all anonymous players (named first, then unnamed)"
if not anonymous_players:
if options['named_only']:
self.stdout.write(self.style.SUCCESS('No anonymous players with names found!'))
elif options['unnamed_only']:
self.stdout.write(self.style.SUCCESS('No anonymous players without names found!'))
if return_count:
return 0
return
# Display summary
self.stdout.write(self.style.SUCCESS(f'\nProcessing {processing_type}'))
self.stdout.write(f'Anonymous players breakdown:')
self.stdout.write(f' Total found: {len(all_anonymous_players)}')
self.stdout.write(f' With names: {len(anonymous_players_with_names)}')
self.stdout.write(f' Without names: {len(anonymous_players_without_names)}')
self.stdout.write(f' Selected for processing: {len(anonymous_players)}')
# Find previous month file
prev_month_file = self.find_previous_month_file(current_metadata, rankings_dir)
@ -422,7 +479,19 @@ class Command(BaseCommand):
# Show progress
progress_counter += 1
if progress_counter % progress_interval == 0 or progress_counter == 1:
self.stdout.write(f' Processing anonymous player {progress_counter}/{len(anonymous_players)} ({(progress_counter/len(anonymous_players))*100:.1f}%)')
# Determine which type of player we're processing
if options['named_only']:
player_type = "named"
elif options['unnamed_only']:
player_type = "unnamed"
else:
# Default behavior: check if we're still processing named players
if progress_counter <= len(anonymous_players_with_names):
player_type = "named"
else:
player_type = "unnamed"
self.stdout.write(f' Processing {player_type} anonymous player {progress_counter}/{len(anonymous_players)} ({(progress_counter/len(anonymous_players))*100:.1f}%)')
potential_matches = self.find_potential_matches(anon_player, prev_players, current_players_indexes, options)
@ -442,7 +511,12 @@ class Command(BaseCommand):
progression = f", Progression: {anon_player['progression']}" if anon_player['progression'] != 'N/A' else ""
assimilation = f", Assimilation: {anon_player['assimilation']}" if anon_player['assimilation'] != 'N/A' else ""
self.stdout.write(f"\nAnonymous player: Rank {anon_player['rank']}, League: {anon_player['league']}{progression}{assimilation}")
# Show if this is a named or unnamed anonymous player
if (anon_player['name'] != 'N/A' and anon_player['name'] != '' and
anon_player['first_name'] != 'N/A' and anon_player['first_name'] != ''):
self.stdout.write(f"\nNamed anonymous player: {anon_player['name']} {anon_player['first_name']} - Rank {anon_player['rank']}, League: {anon_player['league']}{progression}{assimilation}")
else:
self.stdout.write(f"\nUnnamed anonymous player: Rank {anon_player['rank']}, League: {anon_player['league']}{progression}{assimilation}")
for i, match in enumerate(potential_matches[:3]): # Show top 3 matches
player = match['player']
@ -456,7 +530,11 @@ class Command(BaseCommand):
high_confidence_matches += 1
else:
if options['verbose']:
self.stdout.write(f"\nNo matches found for anonymous player: Rank {anon_player['rank']}, League: {anon_player['league']}")
if (anon_player['name'] != 'N/A' and anon_player['name'] != '' and
anon_player['first_name'] != 'N/A' and anon_player['first_name'] != ''):
self.stdout.write(f"\nNo matches found for named anonymous player: {anon_player['name']} {anon_player['first_name']} - Rank {anon_player['rank']}, League: {anon_player['league']}")
else:
self.stdout.write(f"\nNo matches found for unnamed anonymous player: Rank {anon_player['rank']}, League: {anon_player['league']}")
# Batch processing status update
if progress_counter % 100 == 0 and progress_counter > 0:
@ -472,7 +550,11 @@ class Command(BaseCommand):
# Summary
self.stdout.write(self.style.SUCCESS(f'\nMatching summary:'))
self.stdout.write(f'Total anonymous players: {len(anonymous_players)}')
self.stdout.write(f'Processing mode: {processing_type}')
self.stdout.write(f'Anonymous players processed: {len(anonymous_players)}')
if not options['named_only'] and not options['unnamed_only']:
self.stdout.write(f' Named: {len(anonymous_players_with_names)}')
self.stdout.write(f' Unnamed: {len(anonymous_players_without_names)}')
self.stdout.write(f'Players with potential matches: {matches_found}')
self.stdout.write(f'High confidence matches (≥{options["confidence"]}): {high_confidence_matches}')
self.stdout.write(f'Skipped players already in current month: {skipped_existing_players}')
@ -484,6 +566,7 @@ class Command(BaseCommand):
# Auto-match players if requested
if options['auto_match'] and matches_found > 0 and file_path:
# Note: We pass the selected anonymous_players for matching
matched_count = self.update_rankings_with_matches(file_path, anonymous_players, results,
options['confidence'], options)
elif options['auto_match'] and file_path is None:
@ -546,32 +629,40 @@ class Command(BaseCommand):
if options['verbose']:
self.stdout.write(f" Target previous rank: {prev_rank_from_progression} (current rank {anon_rank} + progression {prog_value})")
# Show anonymous player details
self.stdout.write("\n" + "="*80)
self.stdout.write(f"Looking for matches for anonymous player at rank {anon_player['rank']}:")
self.stdout.write(f" Points: {anon_player['points']}")
self.stdout.write(f" Assimilation: {anon_player['assimilation']}")
self.stdout.write(f" Tournaments: {anon_player['tournaments_played']}")
self.stdout.write(f" League: {anon_player['league']}")
if anon_player['name'] != 'N/A' and anon_player['first_name'] != 'N/A':
self.stdout.write(f" Name: {anon_player['name']} {anon_player['first_name']}")
self.stdout.write("-"*80)
for prev_player in prev_players:
# Skip anonymous players in previous month
if self.is_anonymous_player(prev_player):
continue
# Check if this player exists in current month with the same license
exists, existing_player = self.player_exists_in_current_month(prev_player, current_players_indexes)
if exists:
# If we found the exact same player (same license), skip them
if existing_player['license'] == prev_player['license']:
skipped_players += 1
continue
# If we found someone with the same name but different license, we can still consider this player
# Initialize match data
match_data = {
'player': prev_player,
'rank_match_type': None,
'rank_diff': None,
'has_league_match': False,
'has_assimilation_match': False,
'points_similarity': 0.0,
'match_reasons': [],
'confidence': 0.0
'confidence': 0
}
# Print candidate details
self.stdout.write(f"\nChecking candidate: {prev_player['name']} {prev_player['first_name']}")
self.stdout.write(f" Rank: {prev_player['rank']}")
self.stdout.write(f" Points: {prev_player['points']}")
self.stdout.write(f" Assimilation: {prev_player['assimilation']}")
self.stdout.write(f" Tournaments: {prev_player['tournaments_played']}")
self.stdout.write(f" League: {prev_player['league']}")
# Start building confidence score
confidence_details = []
# 1. PRIMARY MATCHER: Previous rank match
if prev_rank_from_progression is not None:
try:
@ -583,68 +674,142 @@ class Command(BaseCommand):
if rank_diff == 0:
match_data['rank_match_type'] = 'exact'
match_data['match_reasons'].append(f"exact previous rank match ({prev_rank_value})")
match_data['confidence'] = 0.7
match_data['confidence'] = 7
# Assimilation match
if anon_player['assimilation'] == prev_player['assimilation']:
match_data['confidence'] += 3
confidence_details.append(f"Assimilation match (+0.3)")
match_data['match_reasons'].append(f"same assimilation ({anon_player['assimilation']})")
# League match
if (anon_player['league'] == prev_player['league'] and
anon_player['league'] != 'N/A' and anon_player['league'] != ''):
match_data['confidence'] += 7
confidence_details.append(f"League match (+0.5)")
match_data['match_reasons'].append(f"same league ({anon_player['league']})")
# Tournament count comparison
try:
anon_tournaments = int(anon_player['tournaments_played'])
prev_tournaments = int(prev_player['tournaments_played'])
tournaments_diff = abs(anon_tournaments - prev_tournaments)
if tournaments_diff == 0:
match_data['confidence'] += 4
confidence_details.append(f"Tournaments unchanged (+0.2)")
match_data['match_reasons'].append(f"same tournaments played ({anon_tournaments})")
else:
# Calculate percentage difference
max_tournaments = max(anon_tournaments, prev_tournaments)
if max_tournaments > 0:
percentage_diff = (tournaments_diff / max_tournaments) * 100
if percentage_diff <= 10:
match_data['confidence'] += 3
confidence_details.append(f"Tournaments within 10% range (+0.15, diff: {percentage_diff:.1f}%)")
match_data['match_reasons'].append(f"tournaments played: prev={prev_tournaments}, current={anon_tournaments}")
elif percentage_diff <= 20:
match_data['confidence'] += 2
confidence_details.append(f"Tournaments within 20% range (+0.1, diff: {percentage_diff:.1f}%)")
match_data['match_reasons'].append(f"tournaments played: prev={prev_tournaments}, current={anon_tournaments}")
else:
confidence_details.append(f"Tournaments too different (diff: {percentage_diff:.1f}%)")
match_data['match_reasons'].append(f"tournaments played: prev={prev_tournaments}, current={anon_tournaments}")
else:
# Handle edge case where both values are 0
match_data['confidence'] += 4
confidence_details.append(f"Both have 0 tournaments (+0.2)")
match_data['match_reasons'].append(f"both have 0 tournaments played")
except ValueError:
confidence_details.append("Could not compare tournaments played")
# Points comparison
try:
anon_points = float(anon_player['points'])
prev_points = float(prev_player['points'])
points_diff = abs(anon_points - prev_points)
match_data['match_reasons'].append(f"points: prev={prev_points}, current={anon_points}, diff={points_diff}")
if points_diff == 0:
match_data['confidence'] += 4
confidence_details.append(f"Points unchanged (+0.3)")
else:
# Calculate percentage difference
max_points = max(anon_points, prev_points)
if max_points > 0:
percentage_diff = (points_diff / max_points) * 100
if percentage_diff <= 10:
match_data['confidence'] += 3
confidence_details.append(f"Points within 10% range (+0.25, diff: {percentage_diff:.1f}%)")
elif percentage_diff <= 20:
match_data['confidence'] += 2
confidence_details.append(f"Points within 20% range (+0.15, diff: {percentage_diff:.1f}%)")
elif percentage_diff <= 30:
match_data['confidence'] += 1
confidence_details.append(f"Points within 30% range (+0.1, diff: {percentage_diff:.1f}%)")
else:
confidence_details.append(f"Points too different (diff: {percentage_diff:.1f}%)")
except ValueError:
confidence_details.append("Could not compare points")
elif rank_diff <= 3:
match_data['rank_match_type'] = 'close'
match_data['match_reasons'].append(f"close previous rank match ({prev_rank_value} vs {prev_rank_from_progression})")
match_data['confidence'] = 0.4
match_data['confidence'] = 4
elif rank_diff <= 10:
match_data['rank_match_type'] = 'approximate'
match_data['match_reasons'].append(f"approximate previous rank match ({prev_rank_value} vs {prev_rank_from_progression})")
match_data['confidence'] = 0.2
match_data['confidence'] = 2
except ValueError:
pass
# 2. Points similarity (new check)
try:
if anon_player['points'] != 'N/A' and prev_player['points'] != 'N/A':
anon_points = float(anon_player['points'])
prev_points = float(prev_player['points'])
points_diff = abs(anon_points - prev_points)
points_similarity = max(0, 1 - (points_diff / max(anon_points, prev_points)))
if points_similarity > 0.9:
match_data['points_similarity'] = points_similarity
match_data['match_reasons'].append(f"similar points ({prev_points} vs {anon_points})")
match_data['confidence'] += 0.2
except ValueError:
pass
# 3. League match
if anon_player['league'] != 'N/A' and prev_player['league'] != 'N/A':
if anon_player['league'] == prev_player['league']:
match_data['has_league_match'] = True
match_data['match_reasons'].append("league match")
match_data['confidence'] += 0.25
# 4. Assimilation match
if anon_player['assimilation'] != 'N/A' and prev_player['assimilation'] != 'N/A':
if anon_player['assimilation'] == prev_player['assimilation']:
match_data['has_assimilation_match'] = True
match_data['match_reasons'].append("assimilation match")
match_data['confidence'] += 0.1
# Only consider matches with minimum confidence
if match_data['confidence'] >= 0.1:
# Name match check
if (anon_player['name'] != 'N/A' and anon_player['name'] != '' and
anon_player['first_name'] != 'N/A' and anon_player['first_name'] != ''):
if (anon_player['name'].lower() == prev_player['name'].lower() and
anon_player['first_name'].lower() == prev_player['first_name'].lower()):
match_data['confidence'] += 25
confidence_details.append(f"Exact name match (+0.4)")
match_data['match_reasons'].append("exact name match")
# Birth year match
if (anon_player['birth_year'] != 'N/A' and anon_player['birth_year'] != '' and
prev_player['birth_year'] != 'N/A' and prev_player['birth_year'] != '' and
anon_player['birth_year'] == prev_player['birth_year']):
match_data['confidence'] += 1
confidence_details.append(f"Birth year match (+0.2)")
match_data['match_reasons'].append(f"same birth year ({anon_player['birth_year']})")
# Only consider matches with reasonable confidence
if match_data['confidence'] >= 10:
# Print confidence calculation details
self.stdout.write("\n Confidence calculation:")
for detail in confidence_details:
self.stdout.write(f" {detail}")
self.stdout.write(f" Total confidence: {match_data['confidence']:.2f}")
match_data['match_reasons'] = ", ".join(match_data['match_reasons'])
potential_matches.append(match_data)
self.stdout.write(" → Considered as potential match")
# else:
# self.stdout.write(" → Rejected (confidence too low)")
# self.stdout.write("-"*40)
# Sort matches by confidence
potential_matches.sort(key=lambda x: x['confidence'], reverse=True)
# Summary of best matches
if potential_matches:
self.stdout.write("\nTop matches found:")
for i, match in enumerate(potential_matches[:3]): # Show top 3
self.stdout.write(f"\n{i+1}. {match['player']['name']} {match['player']['first_name']}")
self.stdout.write(f" Confidence: {match['confidence']:.2f}")
self.stdout.write(f" Reasons: {match['match_reasons']}")
else:
self.stdout.write("\nNo matches found with sufficient confidence.")
# Sort matches with updated criteria
def match_sort_key(match):
rank_score = {
'exact': 1000,
'close': 100,
'approximate': 10,
None: 1
}.get(match['rank_match_type'], 0)
points_score = int(match.get('points_similarity', 0) * 100)
league_value = 2 if match['has_league_match'] else 1
assimilation_value = 2 if match['has_assimilation_match'] else 1
return (rank_score, points_score, league_value, assimilation_value, match['confidence'])
potential_matches.sort(key=match_sort_key, reverse=True)
return potential_matches
def save_results(self, results, output_path):
@ -679,7 +844,7 @@ class Command(BaseCommand):
Args:
file_path: Path to the current month's rankings file
anonymous_players: List of anonymous players
anonymous_players: List of anonymous players (filtered based on command options)
matches: List of match info dictionaries
confidence_threshold: Minimum confidence to apply auto-matching
options: Command options
@ -698,39 +863,42 @@ class Command(BaseCommand):
with open(file_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
# Create a map of anonymous players by rank for faster lookup
anon_by_rank = {}
for player in anonymous_players:
if player['rank'] != 'N/A':
anon_by_rank[player['rank']] = player
# Create a set of players that should be updated
# Only include players that were in our filtered anonymous_players list AND have high confidence matches
players_to_update = set()
update_info = {}
# Track which players will be updated (use a dictionary to ensure only one update per anonymous player)
players_to_update = {}
for match_info in matches:
anon_player = match_info['anonymous_player']
best_match = match_info['best_match']
rank = anon_player['rank']
if best_match['confidence'] >= confidence_threshold and rank not in players_to_update:
# This match has high enough confidence to auto-apply
# Only add if we haven't already found a match for this rank
players_to_update[rank] = {
# Only update if this player was in our filtered list AND meets confidence threshold
if anon_player in anonymous_players and best_match['confidence'] >= confidence_threshold:
# Create a unique identifier for this player
player_id = f"{anon_player['rank']}_{anon_player['points']}_{anon_player['assimilation']}_{anon_player['tournaments_played']}_{anon_player['league']}"
# Add additional uniqueness based on name status
if (anon_player['name'] != 'N/A' and anon_player['name'] != '' and
anon_player['first_name'] != 'N/A' and anon_player['first_name'] != ''):
player_id += f"_{anon_player['name']}_{anon_player['first_name']}"
players_to_update.add(player_id)
update_info[player_id] = {
'anonymous_player': anon_player,
'match': best_match
}
if not players_to_update:
self.stdout.write("No players met the confidence threshold for auto-matching.")
return 0 # Return 0 because no players were updated
return 0
self.stdout.write(f"Found {len(players_to_update)} players to update.")
# Process the file line by line
updated_count = 0
updated_lines = []
already_updated_ranks = set() # Track which ranks we've already updated
# First, we need to find the data start line
# First, find the data start line
data_start_line = 0
for i, line in enumerate(lines):
if ';RANG;NOM;PRENOM;' in line:
@ -752,84 +920,55 @@ class Command(BaseCommand):
updated_lines.append(line)
continue
# Check if this is an anonymous player line
# Extract player data from the line
rank = values[1].strip() if len(values) > 1 else ''
name = values[2].strip() if len(values) > 2 else ''
first_name = values[3].strip() if len(values) > 3 else ''
# Skip if we've already updated this rank (prevent duplicates)
if rank in already_updated_ranks:
updated_lines.append(line)
continue
# CRITICAL CHECK: Only update if this is actually an anonymous player
# Check if player is anonymous (empty or missing name fields)
is_anonymous = not name or not first_name
if rank in players_to_update and is_anonymous:
# This is an anonymous player line with a match to apply
update_info = players_to_update[rank]
matched_player = update_info['match']['player']
# Log the current values for debugging
self.stdout.write(f"Updating anonymous player at rank {rank}. Current values: Name='{name}', First name='{first_name}'")
# Update this line with matched player info
# Basic information: name and first name
values[2] = matched_player['name'] # Last name
values[3] = matched_player['first_name'] # First name
# Update nationality if available
if matched_player['nationality'] != 'N/A' and len(values) > 4:
values[4] = matched_player['nationality']
# Update license if available
if matched_player['license'] != 'N/A' and len(values) > 5:
values[5] = matched_player['license']
# Additional fields:
# Club code (position 10)
if matched_player['club_code'] != 'N/A' and len(values) > 10:
values[10] = matched_player['club_code']
# Club name (position 11)
if matched_player['club'] != 'N/A' and len(values) > 11:
values[11] = matched_player['club']
# Birth year (position 14)
if matched_player['birth_year'] != 'N/A' and len(values) > 14:
values[14] = matched_player['birth_year']
# Reconstruct the line
updated_line = ';'.join(values) + '\n'
updated_lines.append(updated_line)
license_num = values[5].strip() if len(values) > 5 else ''
points = values[6].strip() if len(values) > 6 else ''
assimilation = values[7].strip() if len(values) > 7 else ''
tournaments = values[8].strip() if len(values) > 8 else ''
league = values[9].strip() if len(values) > 9 else ''
# Create player identifier for this line
line_player_id = f"{rank}_{points}_{assimilation}_{tournaments}_{league}"
# Add name info if present
if name and first_name and name != 'N/A' and first_name != 'N/A':
line_player_id += f"_{name}_{first_name}"
# Check if this player should be updated
if line_player_id in players_to_update:
# This player should be updated
match_info = update_info[line_player_id]
matched_player = match_info['match']['player']
# Update the line with matched player information
# Keep the existing rank and points, but update name and license
new_values = values.copy()
new_values[2] = matched_player['name'] # Name
new_values[3] = matched_player['first_name'] # First name
new_values[4] = matched_player['nationality']
new_values[5] = matched_player['license']
new_values[10] = matched_player['club_code']
new_values[11] = matched_player['club']
new_values[14] = matched_player['birth_year']
new_line = ';'.join(new_values) + '\n'
updated_lines.append(new_line)
updated_count += 1
# Mark this rank as updated to prevent duplicates
already_updated_ranks.add(rank)
self.stdout.write(f"Updated player rank {rank}: {matched_player['name']} {matched_player['first_name']}")
self.stdout.write(f"Updated player: {matched_player['name']} {matched_player['first_name']} (Rank: {rank})")
else:
# Not an anonymous player or no match to apply - keep the line unchanged
# This player should NOT be updated - keep the line exactly as is
updated_lines.append(line)
# If this is a non-anonymous player with a rank that was in our update list,
# log a warning that we skipped it
if rank in players_to_update and not is_anonymous:
self.stdout.write(self.style.WARNING(
f"WARNING: Skipped rank {rank} because it already contains a non-anonymous player: {name} {first_name}"
))
# Write the updated file
with open(file_path, 'w', encoding='utf-8') as f:
f.writelines(updated_lines)
self.stdout.write(self.style.SUCCESS(f"\nUpdated {updated_count} players in the rankings file."))
self.stdout.write(f"Original file backed up to: {backup_path}")
return updated_count # Return the count of updated players
self.stdout.write(self.style.SUCCESS(f"Successfully updated {updated_count} players in {file_path}"))
return updated_count
def iterative_match_anonymous_players(self, file_path, rankings_dir, options):
"""
@ -875,10 +1014,13 @@ class Command(BaseCommand):
anon_file = os.path.join(temp_dir, "anonymous_players.json")
prev_players_file = os.path.join(temp_dir, "prev_month_players.json")
matches_file = os.path.join(temp_dir, "matches.json")
print(os.path.join(temp_dir))
# Extract anonymous players and filter previous month players
self.stdout.write("Creating initial working files...")
filtered_data = self.create_filtered_working_files(current_players, prev_players, anon_file, prev_players_file)
filtered_data = self.create_filtered_working_files(
current_players, prev_players, anon_file, prev_players_file, options
)
anon_count = filtered_data['anon_count']
prev_count = filtered_data['prev_count']
@ -907,13 +1049,25 @@ class Command(BaseCommand):
# Update current players from the main file
current_players, _ = self.parse_rankings_file(file_path)
# Remove matched players from prev_players for next iteration
# Load the matches from the temp file to identify which prev players were used
if os.path.exists(matches_file):
with open(matches_file, 'r', encoding='utf-8') as f:
matches = json.load(f)
# Create a set of licenses that were matched
matched_licenses = set()
for match in matches:
matched_player = match['best_match']['player']
if matched_player['license'] != 'N/A' and matched_player['license']:
matched_licenses.add(matched_player['license'])
# Remove matched players from prev_players
prev_players = [p for p in prev_players if p['license'] not in matched_licenses]
# Update temp files for next iteration
filtered_data = self.create_filtered_working_files(current_players, prev_players, anon_file, prev_players_file)
anon_count = filtered_data['anon_count']
prev_count = filtered_data['prev_count']
filtered_data = self.create_filtered_working_files(current_players, prev_players, anon_file, prev_players_file, options)
self.stdout.write(f"Updated working files: {anon_count} anonymous players and {prev_count} eligible previous month players")
else:
self.stdout.write(self.style.SUCCESS(f"Iteration {iteration} complete: No new matches found"))
changes_made = False
@ -921,7 +1075,7 @@ class Command(BaseCommand):
iteration += 1
# Prevent infinite loops (optional safety check)
if iteration > 10: # Cap at 10 iterations maximum
if iteration > 1: # Cap at 10 iterations maximum
self.stdout.write(self.style.WARNING("Maximum iterations reached (10). Stopping process."))
break
@ -940,7 +1094,7 @@ class Command(BaseCommand):
improvement = ((initial_anonymous_count - final_anonymous_count) / initial_anonymous_count) * 100
self.stdout.write(f"Data completeness improved by {improvement:.1f}%")
def create_filtered_working_files(self, current_players, prev_players, anon_file, prev_players_file):
def create_filtered_working_files(self, current_players, prev_players, anon_file, prev_players_file, options):
"""
Create filtered working files:
1. anonymous_players.json - Contains only anonymous players from current month
@ -950,7 +1104,23 @@ class Command(BaseCommand):
"""
# Extract anonymous players from current month
anonymous_players = [p for p in current_players if self.is_anonymous_player(p)]
all_anonymous_players = [p for p in current_players if self.is_anonymous_player(p)]
# Filter based on named/unnamed options
if options['named_only']:
anonymous_players = [p for p in all_anonymous_players if (
p['name'] != 'N/A' and p['name'] != '' and
p['first_name'] != 'N/A' and p['first_name'] != ''
)]
self.stdout.write(self.style.SUCCESS(f"Filtering to only process named anonymous players ({len(anonymous_players)}/{len(all_anonymous_players)})"))
elif options['unnamed_only']:
anonymous_players = [p for p in all_anonymous_players if (
p['name'] == 'N/A' or p['name'] == '' or
p['first_name'] == 'N/A' or p['first_name'] == ''
)]
self.stdout.write(self.style.SUCCESS(f"Filtering to only process unnamed anonymous players ({len(anonymous_players)}/{len(all_anonymous_players)})"))
else:
anonymous_players = all_anonymous_players
# Create lookup for current non-anonymous players
current_players_lookup = {}
@ -1022,8 +1192,15 @@ class Command(BaseCommand):
for anon_player in anonymous_players:
potential_matches = self.find_potential_matches(anon_player, prev_players, current_players_indexes, options)
if potential_matches:
best_match = potential_matches[0] # Highest confidence match
if len(potential_matches) == 1:
best_match = potential_matches[0] # Highest confidence match
elif len(potential_matches) > 1 and potential_matches[0]['confidence'] - potential_matches[1]['confidence'] > 2:
# print(potential_matches[0]['confidence'], potential_matches[1]['match_reasons'])
best_match = potential_matches[0] # Highest confidence match
else:
# for match in potential_matches:
# print(match['player']['name'], match['confidence'], match['match_reasons'])
continue
# Record the match info
match_info = {
'anonymous_player': anon_player,

@ -0,0 +1,222 @@
from django.core.management.base import BaseCommand
from datetime import datetime, timedelta
import logging
class Command(BaseCommand):
help = 'Test FFT all tournaments scraping with various filters'
def add_arguments(self, parser):
parser.add_argument(
'--sorting',
type=str,
default='dateDebut+asc',
choices=['dateDebut+asc', 'dateDebut+desc', '_DISTANCE_'],
help='Sorting option (default: dateDebut+asc)'
)
parser.add_argument(
'--page',
type=int,
default=0,
help='Page number to scrape (default: 0)'
)
parser.add_argument(
'--city',
type=str,
default='',
help='City to search around'
)
parser.add_argument(
'--distance',
type=float,
default=15.0,
help='Distance in km (default: 15)'
)
parser.add_argument(
'--categories',
nargs='*',
default=[],
help='Tournament categories to filter by'
)
parser.add_argument(
'--levels',
nargs='*',
default=[],
help='Tournament levels to filter by'
)
parser.add_argument(
'--ages',
nargs='*',
default=[],
help='Age categories to filter by'
)
parser.add_argument(
'--types',
nargs='*',
default=[],
help='Tournament types to filter by'
)
parser.add_argument(
'--national-cup',
action='store_true',
help='Filter for national cup tournaments only'
)
parser.add_argument(
'--lat',
type=float,
help='Latitude for location-based search'
)
parser.add_argument(
'--lng',
type=float,
help='Longitude for location-based search'
)
parser.add_argument(
'--days-ahead',
type=int,
default=90,
help='How many days ahead to search (default: 90)'
)
parser.add_argument(
'--start-date',
type=str,
help='Start date in DD/MM/YY format (overrides --days-ahead)'
)
parser.add_argument(
'--end-date',
type=str,
help='End date in DD/MM/YY format (overrides --days-ahead)'
)
parser.add_argument(
'--verbose',
action='store_true',
help='Enable verbose logging'
)
def handle(self, *args, **options):
if options['verbose']:
logging.basicConfig(level=logging.INFO)
# Extract options
sorting_option = options['sorting']
page = options['page']
city = options['city']
distance = options['distance']
categories = options['categories']
levels = options['levels']
ages = options['ages']
tournament_types = options['types']
national_cup = options['national_cup']
lat = options['lat']
lng = options['lng']
verbose = options['verbose']
# Calculate date range
if options['start_date'] and options['end_date']:
start_date_str = options['start_date']
end_date_str = options['end_date']
else:
start_date = datetime.now()
end_date = start_date + timedelta(days=options['days_ahead'])
start_date_str = start_date.strftime('%d/%m/%y')
end_date_str = end_date.strftime('%d/%m/%y')
self.stdout.write(self.style.SUCCESS("=== FFT All Tournaments Scraper ==="))
self.stdout.write(f"Sorting: {sorting_option}")
self.stdout.write(f"Page: {page}")
self.stdout.write(f"Date range: {start_date_str} to {end_date_str}")
self.stdout.write(f"City: {city if city else 'Not specified'}")
self.stdout.write(f"Distance: {distance} km")
self.stdout.write(f"Categories: {categories if categories else 'All'}")
self.stdout.write(f"Levels: {levels if levels else 'All'}")
self.stdout.write(f"Ages: {ages if ages else 'All'}")
self.stdout.write(f"Types: {tournament_types if tournament_types else 'All'}")
self.stdout.write(f"National Cup: {'Yes' if national_cup else 'No'}")
if lat and lng:
self.stdout.write(f"Location: {lat}, {lng}")
self.stdout.write(f"Method: Playwright (Chrome-free)")
self.stdout.write("")
try:
from api.utils import scrape_fft_all_tournaments
self.stdout.write("🚀 Testing general tournament scraping...")
result = scrape_fft_all_tournaments(
sorting_option=sorting_option,
page=page,
start_date=start_date_str,
end_date=end_date_str,
city=city,
distance=distance,
categories=categories,
levels=levels,
lat=lat,
lng=lng,
ages=ages,
tournament_types=tournament_types,
national_cup=national_cup
)
# Debug: Show what we got (only in verbose mode)
if verbose:
self.stdout.write(f"🔍 Raw result: {result}")
if result:
tournaments = result.get('tournaments', [])
self.stdout.write(self.style.SUCCESS(f"✅ SUCCESS: {len(tournaments)} tournaments found"))
if tournaments:
self.stdout.write("\n📝 Sample tournaments:")
# Show first 3 tournaments
for i, tournament in enumerate(tournaments[:3]):
self.stdout.write(f"\n Tournament {i+1}:")
self.stdout.write(f" ID: {tournament.get('id')}")
self.stdout.write(f" Name: {tournament.get('libelle')}")
self.stdout.write(f" Date: {tournament.get('dateDebut', {}).get('date', 'N/A')}")
self.stdout.write(f" Club: {tournament.get('nomClub', 'N/A')}")
self.stdout.write(f" City: {tournament.get('villeEngagement', 'N/A')}")
self.stdout.write(f" Category: {tournament.get('categorieTournoi', 'N/A')}")
self.stdout.write(f" Type: {tournament.get('type', 'N/A')}")
if tournament.get('jugeArbitre'):
self.stdout.write(f" Judge: {tournament.get('jugeArbitre', {}).get('nom', 'N/A')}")
self.stdout.write(f"\n📊 Summary:")
self.stdout.write(f" Total tournaments: {len(tournaments)}")
self.stdout.write(f" Current page: {page}")
self.stdout.write(f" Total results available: {result.get('total_results', 'Unknown')}")
# Analysis of results
if tournaments:
cities = set()
clubs = set()
categories = set()
types = set()
for tournament in tournaments:
if tournament.get('villeEngagement'):
cities.add(tournament['villeEngagement'])
if tournament.get('nomClub'):
clubs.add(tournament['nomClub'])
if tournament.get('categorieTournoi'):
categories.add(tournament['categorieTournoi'])
if tournament.get('type'):
types.add(tournament['type'])
self.stdout.write(f"\n🔍 Analysis:")
self.stdout.write(f" Unique cities: {len(cities)}")
self.stdout.write(f" Unique clubs: {len(clubs)}")
self.stdout.write(f" Unique categories: {len(categories)}")
self.stdout.write(f" Unique types: {len(types)}")
if verbose:
self.stdout.write(f"\n Cities: {sorted(list(cities))[:10]}") # Show first 10
self.stdout.write(f" Categories: {sorted(list(categories))}")
self.stdout.write(f" Types: {sorted(list(types))}")
else:
self.stdout.write(self.style.ERROR("❌ FAILED: No tournaments found"))
except Exception as e:
self.stdout.write(self.style.ERROR(f"❌ ERROR: {e}"))
import traceback
if verbose:
self.stdout.write(traceback.format_exc())

@ -0,0 +1,103 @@
from django.core.management.base import BaseCommand
from datetime import datetime, timedelta
import logging
class Command(BaseCommand):
help = 'Test FFT tournament scraping with Playwright'
def add_arguments(self, parser):
parser.add_argument(
'--club-code',
type=str,
default='62130180',
help='Club code for testing (default: 62130180)'
)
parser.add_argument(
'--club-name',
type=str,
default='TENNIS SPORTING CLUB DE CASSIS',
help='Club name for testing'
)
parser.add_argument(
'--all-pages',
action='store_true',
help='Test all pages scraping'
)
parser.add_argument(
'--verbose',
action='store_true',
help='Enable verbose logging'
)
def handle(self, *args, **options):
if options['verbose']:
logging.basicConfig(level=logging.INFO)
club_code = options['club_code']
club_name = options['club_name']
all_pages = options['all_pages']
verbose = options['verbose']
# Calculate date range
start_date = datetime.now()
end_date = start_date + timedelta(days=90)
start_date_str = start_date.strftime('%d/%m/%y')
end_date_str = end_date.strftime('%d/%m/%y')
self.stdout.write(self.style.SUCCESS("=== FFT Tournament Scraper ==="))
self.stdout.write(f"Club: {club_name} ({club_code})")
self.stdout.write(f"Date range: {start_date_str} to {end_date_str}")
self.stdout.write(f"Method: Playwright (Chrome-free)")
self.stdout.write("")
try:
if all_pages:
from api.utils import scrape_fft_club_tournaments_all_pages
self.stdout.write("🚀 Testing complete tournament scraping...")
result = scrape_fft_club_tournaments_all_pages(
club_code=club_code,
club_name=club_name,
start_date=start_date_str,
end_date=end_date_str
)
else:
from api.utils import scrape_fft_club_tournaments
self.stdout.write("🚀 Testing single page scraping...")
result = scrape_fft_club_tournaments(
club_code=club_code,
club_name=club_name,
start_date=start_date_str,
end_date=end_date_str,
page=0
)
# Debug: Show what we got (only in verbose mode)
if verbose:
self.stdout.write(f"🔍 Raw result: {result}")
if result:
tournaments = result.get('tournaments', [])
self.stdout.write(self.style.SUCCESS(f"✅ SUCCESS: {len(tournaments)} tournaments found"))
if tournaments:
self.stdout.write("\n📝 Sample tournament:")
sample = tournaments[0]
self.stdout.write(f" ID: {sample.get('id')}")
self.stdout.write(f" Name: {sample.get('libelle')}")
self.stdout.write(f" Date: {sample.get('dateDebut', {}).get('date', 'N/A')}")
self.stdout.write(f" Judge: {sample.get('jugeArbitre', {}).get('nom', 'N/A')}")
self.stdout.write(f"\n📊 Summary:")
self.stdout.write(f" Total tournaments: {len(tournaments)}")
self.stdout.write(f" Pages scraped: {result.get('pages_scraped', 1)}")
else:
self.stdout.write(self.style.ERROR("❌ FAILED: No tournaments found"))
except Exception as e:
self.stdout.write(self.style.ERROR(f"❌ ERROR: {e}"))
import traceback
if verbose:
self.stdout.write(traceback.format_exc())

@ -1,6 +1,7 @@
from django.db import models
from . import BaseModel, CustomUser
from django.utils import timezone
from . import BaseModel, CustomUser
class Purchase(BaseModel):
id = models.BigIntegerField(primary_key=True, unique=True, editable=True)
@ -16,3 +17,6 @@ class Purchase(BaseModel):
def __str__(self):
return f"{self.id} > {self.product_id} - {self.purchase_date} - {self.user.username}"
def is_active(self):
return self.expiration_date > timezone.now()

@ -1014,30 +1014,35 @@ class Tournament(BaseModel):
return self.has_started(hour_delta=hour_delta)
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)
# 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}")
# # 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
# 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())
return start >= now
# 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

@ -302,6 +302,7 @@ tr {
.logo {
height: 80px;
margin-bottom: 15px;
}
.padding-bottom-small {

@ -15,25 +15,33 @@
<div class="tournament-dashboard">
<!-- Quick Actions - Déplacé en haut -->
<div class="quick-actions" style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 12px; padding: 25px; margin-bottom: 20px;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 15px;">
<a href="{% url 'admin:tournaments_customuser_changelist' %}"
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
Users
</a>
<a href="{% url 'admin:tournaments_purchase_changelist' %}"
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
Purchases
</a>
<a href="{% url 'admin:tournaments_tournament_changelist' %}"
style="display: block; padding: 12px 15px; background: #007bff; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
style="display: block; padding: 12px 15px; background: #ffc107; color: #212529; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
Tournaments
</a>
<a href="{% url 'admin:tournaments_teamregistration_changelist' %}"
style="display: block; padding: 12px 15px; background: #28a745; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
style="display: block; padding: 12px 15px; background: #ffc107; color: #212529; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
Teams
</a>
<a href="{% url 'admin:tournaments_playerregistration_changelist' %}"
style="display: block; padding: 12px 15px; background: #6c757d; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
style="display: block; padding: 12px 15px; background: #ffc107; color: #212529; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
Players
</a>
<a href="{% url 'admin:tournaments_match_changelist' %}"
style="display: block; padding: 12px 15px; background: #fd7e14; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
style="display: block; padding: 12px 15px; background: #ffc107; color: #212529; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
Matches
</a>
<a href="{% url 'admin:tournaments_event_changelist' %}"
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
style="display: block; padding: 12px 15px; background: #ffc107; color: #212529; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
Events
</a>
<a href="{% url 'admin:tournaments_club_changelist' %}"
@ -47,8 +55,8 @@
<div class="dashboard-stats" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin: 20px 0;">
<!-- Running Tournaments Card -->
<div class="stat-card" style="background: linear-gradient(135deg, #28a745, #20c997); color: white; border-radius: 12px; padding: 25px; box-shadow: 0 4px 15px rgba(0,0,0,0.1);">
<h3 style="margin: 0 0 20px 0; display: flex; align-items: center; gap: 10px;">
<div class="stat-card" style="background: #17a2b8; color: white; border-radius: 12px; padding: 25px; box-shadow: 0 4px 15px rgba(0,0,0,0.1);">
<h3 style="margin: 0 0 20px 0; display: flex; align-items: center; gap: 10px; color: white;">
🎾 Starting Tournaments
</h3>
<div class="tournament-stats">
@ -86,8 +94,8 @@
</div>
<!-- Ended Tournaments Card -->
<div class="stat-card" style="background: linear-gradient(135deg, #dc3545, #e74c3c); color: white; border-radius: 12px; padding: 25px; box-shadow: 0 4px 15px rgba(0,0,0,0.1);">
<h3 style="margin: 0 0 20px 0; display: flex; align-items: center; gap: 10px;">
<div class="stat-card" style="background: #17a2b8; color: white; border-radius: 12px; padding: 25px; box-shadow: 0 4px 15px rgba(0,0,0,0.1);">
<h3 style="margin: 0 0 20px 0; display: flex; align-items: center; gap: 10px; color: white;">
🏁 Ended Tournaments
</h3>
<div class="tournament-stats">
@ -111,8 +119,8 @@
</div>
<!-- Participants Card -->
<div class="stat-card" style="background: linear-gradient(135deg, #007bff, #0056b3); color: white; border-radius: 12px; padding: 25px; box-shadow: 0 4px 15px rgba(0,0,0,0.1);">
<h3 style="margin: 0 0 20px 0; display: flex; align-items: center; gap: 10px;">
<div class="stat-card" style="background: #17a2b8; color: white; border-radius: 12px; padding: 25px; box-shadow: 0 4px 15px rgba(0,0,0,0.1);">
<h3 style="margin: 0 0 20px 0; display: flex; align-items: center; gap: 10px; color: white;">
👥 Participants
</h3>
<div class="participant-stats">
@ -132,8 +140,8 @@
</div>
<!-- Matches Card -->
<div class="stat-card" style="background: linear-gradient(135deg, #fd7e14, #e55e2b); color: white; border-radius: 12px; padding: 25px; box-shadow: 0 4px 15px rgba(0,0,0,0.1);">
<h3 style="margin: 0 0 20px 0; display: flex; align-items: center; gap: 10px;">
<div class="stat-card" style="background: #17a2b8; color: white; border-radius: 12px; padding: 25px; box-shadow: 0 4px 15px rgba(0,0,0,0.1);">
<h3 style="margin: 0 0 20px 0; display: flex; align-items: center; gap: 10px; color: white;">
🏓 Matches
</h3>
<div class="match-stats">

@ -29,7 +29,6 @@
<div><a href="{% url 'broadcasted-group-stages' tournament.id %}">Poules</a></div>
<div><a href="{% url 'broadcasted-summons' tournament.id %}">Convocations</a></div>
<div><a href="{% url 'broadcasted-rankings' tournament.id %}">Classement</a></div>
<div><a href="{% url 'broadcasted-prog' tournament.id %}">(beta) Programmation</a></div>
<div><a href="{% url 'broadcasted-planning' tournament.id %}">(beta) Planning</a></div>
<div><a href="{% url 'broadcasted-bracket' tournament.id %}">(beta) Tableau</a></div>
</div>

@ -33,7 +33,6 @@
<span><a href="{% url 'automatic-broadcast' tournament.id %}">Automatic</a></span> |
<span><a href="{% url 'broadcasted-bracket' tournament.id %}">(beta) Tableau</a></span> |
<span><a href="{% url 'broadcasted-planning' tournament.id %}">(beta) Planning</a></span> |
<span><a href="{% url 'broadcasted-prog' tournament.id %}">(beta) Programmation</a></span> |
<span><a href="{% url 'broadcasted-matches' tournament.id %}">Matchs</a></span> |
<span><a href="{% url 'broadcasted-group-stages' tournament.id %}">Poules</a></span> |
<span><a href="{% url 'broadcasted-summons' tournament.id %}">Convocations</a></span> |

@ -115,15 +115,16 @@
<div class="bubble">
<h4 class="orange">Avant le tournoi</h4>
<ul>
<li>Inscriptions <span class="bold">en ligne</span></li>
<li><span class="bold">Paiement en ligne</span> via Stripe</li>
<li>Gestion <span class="bold">automatique des listes d'attente</span></li>
<li><span class="bold">Recherche et importation</span> facile des joueurs licenciés</li>
<li>Gestion de la <span class="bold">liste d'attente</span></li>
<li>Interface avec <span class="bold">Beach-Padel</span></li>
<li>Simulation des <span class="bold">structures de tournoi</span></li>
<li>Simulation des <span class="bold">Structures de tournoi</span></li>
<li><span class="bold">Planification instantanée</span></li>
<li><span class="bold">Convocations groupées</span> des joueurs</li>
<li><span class="bold">Création de vos paires</span> rapide</li>
<li><span class="bold">Impression papier</span> des tableaux et poules</li>
<li><span class="bold">Calcul du poids des équipes</span> automatique</li>
<li><span class="bold">Convocation de tous les joueurs</span> en un clic</li>
<li><span class="bold">Traitement des forfaits</span> de dernière minute</li>
</ul>
</div>

@ -21,7 +21,7 @@ from django.conf import settings
from django.contrib.admin.views.decorators import staff_member_required
from django.views.generic import View
from django.db.models import Q
from .models import Club, Tournament, CustomUser, Event, Round, Match, TeamScore, TeamRegistration, PlayerRegistration, UserOrigin
from .models import Club, Tournament, CustomUser, Event, Round, Match, TeamScore, TeamRegistration, PlayerRegistration, UserOrigin, Purchase
from datetime import datetime, timedelta
import time
import json
@ -1678,7 +1678,6 @@ def get_user_tournaments(user, tournaments):
def stripe_onboarding_complete(request):
return render(request, 'stripe/onboarding_complete.html')
def stripe_refresh_account_link(request):
return render(request, 'stripe/refresh_account_link.html')

Loading…
Cancel
Save