Compare commits

..

No commits in common. 'main' and 'apikeys' have entirely different histories.

  1. 1
      .gitignore
  2. 1
      api/admin.py
  3. 99
      api/serializers.py
  4. 5
      api/urls.py
  5. 874
      api/utils.py
  6. 94
      api/views.py
  7. 313
      biz/admin.py
  8. 37
      biz/filters.py
  9. 38
      biz/migrations/0005_alter_activity_status_campaign.py
  10. 19
      biz/migrations/0006_alter_campaign_id.py
  11. 37
      biz/migrations/0007_prospectgroup_delete_campaign.py
  12. 23
      biz/migrations/0008_alter_activity_declination_reason_and_more.py
  13. 29
      biz/models.py
  14. 448
      biz/templates/admin/biz/dashboard.html
  15. 3
      biz/templates/admin/biz/prospect/change_list.html
  16. 3
      padelclub_backend/settings.py
  17. 61
      padelclub_backend/settings_app.py
  18. 1
      padelclub_backend/settings_local.py.dist
  19. 7
      padelclub_backend/urls.py
  20. 21
      sync/README.md
  21. 11
      sync/admin.py
  22. 2
      sync/model_manager.py
  23. 27
      sync/models/base.py
  24. 2
      sync/models/data_access.py
  25. 19
      sync/registry.py
  26. 26
      sync/signals.py
  27. 5
      sync/utils.py
  28. 14
      sync/views.py
  29. 14
      sync/ws_sender.py
  30. 163
      tournaments/admin.py
  31. 1251
      tournaments/admin_utils.py
  32. 7
      tournaments/custom_views.py
  33. 83
      tournaments/filters.py
  34. 2
      tournaments/forms.py
  35. 53
      tournaments/middleware.py
  36. 28
      tournaments/migrations/0138_remove_customuser_agents_customuser_supervisors_and_more.py
  37. 19
      tournaments/migrations/0139_customuser_organizers.py
  38. 18
      tournaments/migrations/0140_tournament_custom_club_name.py
  39. 2
      tournaments/models/club.py
  40. 5
      tournaments/models/custom_user.py
  41. 26
      tournaments/models/player_registration.py
  42. 111
      tournaments/models/round.py
  43. 20
      tournaments/models/team_registration.py
  44. 49
      tournaments/models/tournament.py
  45. 74
      tournaments/services/email_service.py
  46. 247
      tournaments/services/payment_service.py
  47. 70
      tournaments/services/tournament_registration.py
  48. 10
      tournaments/services/tournament_unregistration.py
  49. 13
      tournaments/signals.py
  50. 16151
      tournaments/static/rankings/CLASSEMENT-PADEL-DAMES-10-2025.csv
  51. 16913
      tournaments/static/rankings/CLASSEMENT-PADEL-DAMES-11-2025.csv
  52. 110963
      tournaments/static/rankings/CLASSEMENT-PADEL-MESSIEURS-10-2025.csv
  53. 117908
      tournaments/static/rankings/CLASSEMENT-PADEL-MESSIEURS-11-2025.csv
  54. 17
      tournaments/static/tournaments/css/tournament_bracket.css
  55. 225
      tournaments/static/tournaments/js/tournament_bracket.js
  56. 6
      tournaments/templates/admin/tournaments/dashboard.html
  57. 81
      tournaments/templates/admin/tournaments/set_club_action.html
  58. 18
      tournaments/templates/register_tournament.html
  59. 30
      tournaments/templates/stripe/payment_complete.html
  60. 7
      tournaments/templates/tournaments/broadcast/broadcasted_bracket.html
  61. 6
      tournaments/templates/tournaments/download.html
  62. 10
      tournaments/templates/tournaments/navigation_tournament.html
  63. 11
      tournaments/templates/tournaments/tournament_info.html
  64. 12
      tournaments/templates/tournaments/tournament_row.html
  65. 5
      tournaments/templatetags/tournament_tags.py
  66. 1
      tournaments/urls.py
  67. 8
      tournaments/utils/player_search.py
  68. 148
      tournaments/views.py

1
.gitignore vendored

@ -7,7 +7,6 @@ padelclub_backend/settings_local.py
myenv/
shared/config_local.py
logs/
# Byte-compiled / optimized / DLL files
__pycache__/

@ -12,7 +12,6 @@ class APIKeyAdmin(APIKeyModelAdmin):
list_display = [*APIKeyModelAdmin.list_display, "user"]
list_filter = [*APIKeyModelAdmin.list_filter, "user"]
search_fields = [*APIKeyModelAdmin.search_fields, "user__username", "user__email"]
raw_id_fields = ['user']
def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)

@ -49,7 +49,7 @@ class UserSerializer(serializers.ModelSerializer):
username_lower = validated_data['username'].lower()
if CustomUser.objects.filter(username__iexact=username_lower) | CustomUser.objects.filter(email__iexact=username_lower):
raise serializers.ValidationError("Cet identifiant est déjà utilisé. Veuillez en choisir un autre :)")
raise IntegrityError("Cet identifiant est déjà utilisé. Veuillez en choisir un autre :)")
user = CustomUser.objects.create_user(
username=validated_data['username'],
@ -135,100 +135,15 @@ class TournamentSerializer(serializers.ModelSerializer):
fields = '__all__'
class TournamentSummarySerializer(serializers.ModelSerializer):
# English field names for all the information described in the comment
tournament_name = serializers.SerializerMethodField()
tournament_information = serializers.CharField(source='information', read_only=True)
start_date = serializers.SerializerMethodField()
end_date = serializers.SerializerMethodField()
tournament_category = serializers.SerializerMethodField() # P25/P100/P250 as string
tournament_type = serializers.SerializerMethodField() # homme/femme/mixte as string
tournament_age_category = serializers.SerializerMethodField() # U10, U12, Senior, +45, etc. as string
max_teams = serializers.IntegerField(source='team_count', read_only=True)
registered_teams_count = serializers.SerializerMethodField()
tournament_status = serializers.SerializerMethodField() # status as string
registration_link = serializers.SerializerMethodField()
umpire_name = serializers.SerializerMethodField()
umpire_phone = serializers.SerializerMethodField()
umpire_email = serializers.SerializerMethodField()
registration_count = serializers.SerializerMethodField()
class Meta:
model = Tournament
fields = [
'id',
'tournament_name',
'tournament_information',
'start_date',
'end_date',
'tournament_category',
'tournament_type',
'tournament_age_category',
'max_teams',
'registered_teams_count',
'tournament_status',
'registration_link',
'umpire_name',
'umpire_phone',
'umpire_email'
]
def get_start_date(self, obj):
"""Get formatted start date"""
return obj.local_start_date()
def get_end_date(self, obj):
"""Get formatted end date"""
return obj.local_end_date()
def get_tournament_name(self, obj):
"""Get the tournament name"""
return obj.name or obj.name_and_event()
def get_tournament_category(self, obj):
"""Get tournament category as string label (P25, P100, P250, etc.)"""
return obj.level()
def get_tournament_type(self, obj):
"""Get tournament type as string label (homme, femme, mixte)"""
return obj.category()
def get_tournament_age_category(self, obj):
"""Get tournament age category as string label (U10, U12, Senior, +45, etc.)"""
return obj.age()
def get_registered_teams_count(self, obj):
"""Get number of registered teams"""
return len(obj.teams(False))
def get_tournament_status(self, obj):
"""Get tournament status as string"""
return obj.get_tournament_status()
def get_registration_link(self, obj):
"""Get appropriate link based on tournament status"""
# This will need to be adapted based on your URL structure
# For now, returning a placeholder that you can customize
status = obj.get_online_registration_status()
base_url = "https://padelclub.app/"
if status.value in [1, 3, 5]: # OPEN, NOT_STARTED, WAITING_LIST_POSSIBLE
return f"{base_url}tournament/{obj.id}/info/"
elif status.value == 7: # IN_PROGRESS
return f"{base_url}tournament/{obj.id}/live/"
elif status.value == 8: # ENDED_WITH_RESULTS
return f"{base_url}tournament/{obj.id}/rankings/"
else:
return f"{base_url}tournament/{obj.id}/info/"
def get_umpire_name(self, obj):
"""Get umpire/referee name"""
return obj.umpire_contact()
def get_umpire_phone(self, obj):
"""Get umpire phone number"""
return obj.umpire_phone()
def get_umpire_email(self, obj):
"""Get umpire email address"""
return obj.umpire_mail()
fields = ['id', 'name', 'start_date', 'day_duration', 'team_count', 'federal_category', 'federal_level_category', 'federal_age_category', 'registration_count']
def get_registration_count(self, obj):
return len(obj.teams(True))
class EventSerializer(serializers.ModelSerializer):
class Meta:

@ -8,7 +8,7 @@ from authentication.views import CustomAuthToken, Logout, ChangePasswordView
router = routers.DefaultRouter()
router.register(r'users', views.UserViewSet)
router.register(r'user-supervisors', views.SupervisorViewSet)
router.register(r'user-agents', views.ShortUserViewSet)
router.register(r'clubs', views.ClubViewSet)
router.register(r'tournaments', views.TournamentViewSet)
router.register(r'tournament-summaries', views.TournamentSummaryViewSet)
@ -63,6 +63,5 @@ urlpatterns = [
path('dj-rest-auth/', include('dj_rest_auth.urls')),
path('stripe/create-account/', views.create_stripe_connect_account, name='create_stripe_account'),
path('stripe/create-account-link/', views.create_stripe_account_link, name='create_account_link'),
path('resend-payment-email/<str:team_registration_id>/', views.resend_payment_email, name='resend-payment-email'),
path('payment-link/<str:team_registration_id>/', views.get_payment_link, name='get-payment-link'),
]

File diff suppressed because it is too large Load Diff

@ -1,4 +1,3 @@
from pandas.core.groupby import base
from rest_framework import viewsets
from rest_framework.response import Response
from rest_framework.decorators import api_view, permission_classes
@ -16,7 +15,6 @@ 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, ActivitySerializer, ProspectSerializer, EntitySerializer, TournamentSummarySerializer
from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer, Image
from tournaments.services.email_service import TournamentEmailService
from biz.models import Activity, Prospect, Entity
@ -42,7 +40,6 @@ import pandas as pd
import os
import logging
from datetime import datetime
logger = logging.getLogger(__name__)
@ -86,22 +83,10 @@ class TournamentSummaryViewSet(SoftDeleteViewSet):
if self.request.user.is_anonymous:
return Tournament.objects.none()
queryset = self.queryset.filter(
return self.queryset.filter(
Q(event__creator=self.request.user) | Q(related_user=self.request.user)
).distinct()
# Add min_start_date filtering
min_start_date = self.request.query_params.get('min_start_date')
if min_start_date:
try:
# Parse the date string (assumes ISO format: YYYY-MM-DD)
min_date = datetime.fromisoformat(min_start_date).date()
queryset = queryset.filter(start_date__gte=min_date)
except (ValueError, TypeError):
# If date parsing fails, ignore the filter
pass
return queryset
class TournamentViewSet(SoftDeleteViewSet):
queryset = Tournament.objects.all()
@ -343,13 +328,13 @@ class UnregisteredPlayerViewSet(SoftDeleteViewSet):
return self.queryset.filter(unregistered_team__tournament__event__creator=self.request.user)
return []
class SupervisorViewSet(viewsets.ModelViewSet):
class ShortUserViewSet(viewsets.ModelViewSet):
queryset = CustomUser.objects.all()
serializer_class = ShortUserSerializer
permission_classes = [] # Users are public whereas the other requests are only for logged users
def get_queryset(self):
return self.request.user.supervisors
return self.request.user.agents
class ImageViewSet(viewsets.ModelViewSet):
"""
@ -520,13 +505,8 @@ def create_stripe_account_link(request):
}, status=400)
try:
# Force HTTPS for production Stripe calls
if hasattr(settings, 'STRIPE_MODE') and settings.STRIPE_MODE == 'live':
base_path = f"https://{request.get_host()}"
else:
base_path = f"{request.scheme}://{request.get_host()}"
# print("create_stripe_account_link", base_path)
refresh_url = f"{base_path}/stripe-refresh-account-link/"
return_url = f"{base_path}/stripe-onboarding-complete/"
@ -619,76 +599,12 @@ def validate_stripe_account(request):
'needs_onboarding': True,
}, status=200)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def resend_payment_email(request, team_registration_id):
"""
Resend the registration confirmation email (which includes payment info/link)
"""
try:
team_registration = TeamRegistration.objects.get(id=team_registration_id)
tournament = team_registration.tournament
TournamentEmailService.send_registration_confirmation(
request,
tournament,
team_registration,
waiting_list_position=-1,
force_send=True
)
return Response({
'success': True,
'message': 'Email de paiement renvoyé'
})
except TeamRegistration.DoesNotExist:
return Response({'error': 'Team not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_payment_link(request, team_registration_id):
"""
Get payment link for a team registration.
Only accessible by the umpire (tournament creator).
"""
try:
team_registration = get_object_or_404(TeamRegistration, id=team_registration_id)
# Check if the user is the umpire (creator) of the tournament
if request.user != team_registration.tournament.event.creator:
return Response({
'success': False,
'message': "Vous n'êtes pas autorisé à accéder à ce lien de paiement"
}, status=403)
# Create payment link
payment_link = PaymentService.create_payment_link(team_registration.id)
if payment_link:
return Response({
'success': True,
'payment_link': payment_link
})
else:
return Response({
'success': False,
'message': 'Impossible de créer le lien de paiement'
}, status=500)
except TeamRegistration.DoesNotExist:
return Response({'error': 'Team not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@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.organising_for:
for owner in request.user.organising_for.all():
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():

@ -6,16 +6,15 @@ from django.shortcuts import render, redirect
from django.contrib.auth import get_user_model
from django.utils.html import format_html
from django.core.mail import send_mail
from django.db.models import Q, Max, Subquery, OuterRef
import csv
import io
import time
import logging
from .models import Entity, Prospect, Activity, Status, ActivityType, EmailTemplate, DeclinationReason, ProspectGroup
from .models import Entity, Prospect, Activity, Status, ActivityType, EmailTemplate, DeclinationReason
from .forms import FileImportForm, EmailTemplateSelectionForm
from .filters import ContactAgainFilter, ProspectStatusFilter, StaffUserFilter, ProspectProfileFilter, ProspectDeclineReasonFilter, ProspectGroupFilter, PhoneFilter
from .filters import ContactAgainFilter, ProspectStatusFilter, StaffUserFilter, ProspectProfileFilter, ProspectDeclineReasonFilter
from tournaments.models import CustomUser
from tournaments.models.enums import UserOrigin
@ -43,7 +42,6 @@ class EntityAdmin(SyncedObjectAdmin):
class EmailTemplateAdmin(SyncedObjectAdmin):
list_display = ('name', 'subject', 'body')
search_fields = ('name', 'subject')
exclude = ('data_access_ids', 'activities',)
def contacted_by_sms(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, ActivityType.SMS, Status.CONTACTED, None)
@ -77,14 +75,6 @@ def declined_android_user(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.DECLINED, DeclinationReason.USE_ANDROID)
declined_android_user.short_description = "Declined use Android"
def mark_as_have_account(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.HAVE_CREATED_ACCOUNT, None)
mark_as_have_account.short_description = "Mark as having an account"
def mark_as_not_concerned(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.NOT_CONCERNED, None)
mark_as_not_concerned.short_description = "Mark as not concerned"
def create_default_activity_for_prospect(modeladmin, request, queryset, type, status, reason):
for prospect in queryset:
activity = Activity.objects.create(
@ -122,21 +112,16 @@ class ProspectAdmin(SyncedObjectAdmin):
'fields': ['related_activities', 'id', 'entity_names', 'first_name', 'last_name', 'email', 'phone', 'contact_again', 'official_user', 'name_unsure', 'entities', 'related_user']
}),
]
list_display = ('first_name', 'last_name', 'entity_names', 'phone', 'last_update_date', 'current_status', 'contact_again')
list_display = ('first_name', 'last_name', 'entity_names', 'last_update_date', 'current_status', 'current_text', 'contact_again')
list_filter = (ContactAgainFilter, ProspectStatusFilter, ProspectDeclineReasonFilter, ProspectGroupFilter, PhoneFilter, 'creation_date', StaffUserFilter, 'source', ProspectProfileFilter)
search_fields = ('first_name', 'last_name', 'email', 'phone')
list_filter = (ContactAgainFilter, ProspectStatusFilter, ProspectDeclineReasonFilter, 'creation_date', StaffUserFilter, 'source', ProspectProfileFilter)
search_fields = ('first_name', 'last_name', 'email', 'entities__name')
date_hierarchy = 'creation_date'
change_list_template = "admin/biz/prospect/change_list.html"
ordering = ['-last_update']
filter_horizontal = ['entities']
actions = ['send_email', create_activity_for_prospect, mark_as_inbound, contacted_by_sms, mark_as_should_test, mark_as_testing, mark_as_customer, mark_as_have_account, declined_too_expensive, declined_use_something_else, declined_android_user, mark_as_not_concerned]
autocomplete_fields = ['official_user', 'related_user']
def save_model(self, request, obj, form, change):
if obj.related_user is None:
obj.related_user = request.user
super().save_model(request, obj, form, change)
actions = ['send_email', create_activity_for_prospect, mark_as_inbound, contacted_by_sms, mark_as_should_test, mark_as_testing, mark_as_customer, declined_too_expensive, declined_use_something_else, declined_android_user]
raw_id_fields = ['official_user']
def last_update_date(self, obj):
return obj.last_update.date() if obj.last_update else None
@ -157,68 +142,12 @@ class ProspectAdmin(SyncedObjectAdmin):
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('dashboard/', self.admin_site.admin_view(self.dashboard), name='biz_dashboard'),
path('import_file/', self.admin_site.admin_view(self.import_file), name='import_file'),
path('import_app_users/', self.admin_site.admin_view(self.import_app_users), name='import_app_users'),
path('cleanup/', self.admin_site.admin_view(self.cleanup), name='cleanup'),
]
return custom_urls + urls
def dashboard(self, request):
"""
Dashboard view showing prospects organized by status columns
"""
# Get filter parameter - if 'my' is true, filter by current user
filter_my = request.GET.get('my', 'false') == 'true'
# Base queryset
base_queryset = Prospect.objects.select_related().prefetch_related('entities', 'activities')
# Apply user filter if requested
if filter_my:
base_queryset = base_queryset.filter(related_user=request.user)
# Helper function to get prospects by status
def get_prospects_by_status(statuses):
# Get the latest activity status for each prospect
latest_activity = Activity.objects.filter(
prospects=OuterRef('pk'),
status__isnull=False
).order_by('-creation_date')
prospects = base_queryset.annotate(
latest_status=Subquery(latest_activity.values('status')[:1])
).filter(
latest_status__in=statuses
).order_by('last_update')
return prospects
# Get prospects for each column
should_test_prospects = get_prospects_by_status([Status.SHOULD_TEST])
testing_prospects = get_prospects_by_status([Status.TESTING])
responded_prospects = get_prospects_by_status([Status.RESPONDED])
others_prospects = get_prospects_by_status([Status.INBOUND, Status.SHOULD_BUY])
# Get prospects with contact_again date set, sorted by oldest first
contact_again_prospects = base_queryset.filter(
contact_again__isnull=False
).order_by('contact_again')
context = {
'title': 'CRM Dashboard',
'should_test_prospects': should_test_prospects,
'testing_prospects': testing_prospects,
'responded_prospects': responded_prospects,
'others_prospects': others_prospects,
'contact_again_prospects': contact_again_prospects,
'filter_my': filter_my,
'opts': self.model._meta,
'has_view_permission': self.has_view_permission(request),
}
return render(request, 'admin/biz/dashboard.html', context)
def cleanup(self, request):
Entity.objects.all().delete()
Prospect.objects.all().delete()
@ -300,17 +229,15 @@ class ProspectAdmin(SyncedObjectAdmin):
try:
# Read the file content
file_content = file.read().decode('utf-8')
csv_reader = csv.reader(io.StringIO(file_content), delimiter=';')
csv_reader = csv.reader(io.StringIO(file_content))
created_prospects = 0
updated_prospects = 0
created_entities = 0
created_events = 0
for row in csv_reader:
print(f'>>> row size is {len(row)}')
if len(row) < 5:
print(f'>>> WARNING: row size is {len(row)}: {row}')
if len(row) < 8:
continue # Skip rows that don't have enough columns
entity_name = row[0].strip()
@ -320,9 +247,9 @@ class ProspectAdmin(SyncedObjectAdmin):
phone = row[4].strip() if row[4].strip() else None
if phone and not phone.startswith('0'):
phone = '0' + phone
# attachment_text = row[5].strip() if row[5].strip() else None
# status_text = row[6].strip() if row[6].strip() else None
# related_user_name = row[7].strip() if row[7].strip() else None
attachment_text = row[5].strip() if row[5].strip() else None
status_text = row[6].strip() if row[6].strip() else None
related_user_name = row[7].strip() if row[7].strip() else None
# Create or get Entity
entity = None
@ -335,13 +262,13 @@ class ProspectAdmin(SyncedObjectAdmin):
created_entities += 1
# Get related user if provided
# related_user = None
# if related_user_name:
# try:
# related_user = User.objects.get(username=related_user_name)
# except User.DoesNotExist:
# # Try to find by first name if username doesn't exist
# related_user = User.objects.filter(first_name__icontains=related_user_name).first()
related_user = None
if related_user_name:
try:
related_user = User.objects.get(username=related_user_name)
except User.DoesNotExist:
# Try to find by first name if username doesn't exist
related_user = User.objects.filter(first_name__icontains=related_user_name).first()
# Create or update Prospect
prospect, prospect_created = Prospect.objects.get_or_create(
@ -351,68 +278,69 @@ class ProspectAdmin(SyncedObjectAdmin):
'last_name': last_name,
'phone': phone,
'name_unsure': False,
'related_user': related_user,
'source': source,
}
)
if prospect_created:
created_prospects += 1
# else:
# # Check if names are different and mark as name_unsure
# if (prospect.first_name != first_name or prospect.last_name != last_name):
# prospect.name_unsure = True
# # Update related_user if provided
# if related_user:
# prospect.related_user = related_user
# prospect.save()
# updated_prospects += 1
else:
# Check if names are different and mark as name_unsure
if (prospect.first_name != first_name or prospect.last_name != last_name):
prospect.name_unsure = True
# Update related_user if provided
if related_user:
prospect.related_user = related_user
prospect.save()
updated_prospects += 1
# Associate entity with prospect
if entity:
prospect.entities.add(entity)
# Create Event if attachment_text or status is provided
# if attachment_text or status_text:
# # Map status text to Status enum
# status_value = None
# declination_reason = None
# if status_text:
# if 'CONTACTED' in status_text:
# status_value = Status.CONTACTED
# elif 'RESPONDED' in status_text:
# status_value = Status.RESPONDED
# elif 'SHOULD_TEST' in status_text:
# status_value = Status.SHOULD_TEST
# elif 'CUSTOMER' in status_text:
# status_value = Status.CUSTOMER
# elif 'TESTING' in status_text:
# status_value = Status.TESTING
# elif 'LOST' in status_text:
# status_value = Status.LOST
# elif 'DECLINED_TOO_EXPENSIVE' in status_text:
# status_value = Status.DECLINED
# declination_reason = DeclinationReason.TOO_EXPENSIVE
# elif 'USE_OTHER_PRODUCT' in status_text:
# status_value = Status.DECLINED
# declination_reason = DeclinationReason.USE_OTHER_PRODUCT
# elif 'USE_ANDROID' in status_text:
# status_value = Status.DECLINED
# declination_reason = DeclinationReason.USE_ANDROID
# elif 'NOK' in status_text:
# status_value = Status.DECLINED
# declination_reason = DeclinationReason.UNKNOWN
# elif 'DECLINED_UNRELATED' in status_text:
# status_value = Status.DECLINED_UNRELATED
# activity = Activity.objects.create(
# type=ActivityType.SMS,
# attachment_text=attachment_text,
# status=status_value,
# declination_reason=declination_reason,
# description=f"Imported from CSV - Status: {status_text}" if status_text else "Imported from CSV"
# )
# activity.prospects.add(prospect)
# created_events += 1
if attachment_text or status_text:
# Map status text to Status enum
status_value = None
declination_reason = None
if status_text:
if 'CONTACTED' in status_text:
status_value = Status.CONTACTED
elif 'RESPONDED' in status_text:
status_value = Status.RESPONDED
elif 'SHOULD_TEST' in status_text:
status_value = Status.SHOULD_TEST
elif 'CUSTOMER' in status_text:
status_value = Status.CUSTOMER
elif 'TESTING' in status_text:
status_value = Status.TESTING
elif 'LOST' in status_text:
status_value = Status.LOST
elif 'DECLINED_TOO_EXPENSIVE' in status_text:
status_value = Status.DECLINED
declination_reason = DeclinationReason.TOO_EXPENSIVE
elif 'USE_OTHER_PRODUCT' in status_text:
status_value = Status.DECLINED
declination_reason = DeclinationReason.USE_OTHER_PRODUCT
elif 'USE_ANDROID' in status_text:
status_value = Status.DECLINED
declination_reason = DeclinationReason.USE_ANDROID
elif 'NOK' in status_text:
status_value = Status.DECLINED
declination_reason = DeclinationReason.UNKNOWN
elif 'DECLINED_UNRELATED' in status_text:
status_value = Status.DECLINED_UNRELATED
activity = Activity.objects.create(
type=ActivityType.SMS,
attachment_text=attachment_text,
status=status_value,
declination_reason=declination_reason,
description=f"Imported from CSV - Status: {status_text}" if status_text else "Imported from CSV"
)
activity.prospects.add(prospect)
created_events += 1
result = f"Successfully imported: {created_prospects} new prospects, {updated_prospects} updated prospects, {created_entities} new entities, {created_events} new events"
return result
@ -429,12 +357,9 @@ class ProspectAdmin(SyncedObjectAdmin):
if form.is_valid():
email_template = form.cleaned_data['email_template']
sent_count, failed_count = self.process_selected_items_with_template(request, queryset, email_template)
self.process_selected_items_with_template(request, queryset, email_template)
if failed_count > 0:
self.message_user(request, f"Email sent to {sent_count} prospects, {failed_count} failed using the '{email_template.name}' template.", messages.WARNING)
else:
self.message_user(request, f"Email sent to {sent_count} prospects using the '{email_template.name}' template.", messages.SUCCESS)
self.message_user(request, f"Email sent to {queryset.count()} prospects using the '{email_template.name}' template.", messages.SUCCESS)
return HttpResponseRedirect(request.get_full_path())
else:
form = EmailTemplateSelectionForm()
@ -455,11 +380,7 @@ class ProspectAdmin(SyncedObjectAdmin):
logger.info(f'Sending email to {queryset.count()} users...')
for prospect in queryset:
mail_body = email_template.body.replace(
'{{name}}',
f' {prospect.first_name}' if prospect.first_name and len(prospect.first_name) > 0 else ''
)
# mail_body = email_template.body.replace('{{name}}', prospect.first_name)
mail_body = email_template.body.replace('{{name}}', prospect.first_name)
all_emails.append(prospect.email)
try:
@ -471,38 +392,19 @@ class ProspectAdmin(SyncedObjectAdmin):
fail_silently=False,
)
sent_count += 1
activity = Activity.objects.create(
type=ActivityType.MAIL,
status=Status.CONTACTED,
description=f"Email sent: {email_template.subject}"
)
activity.prospects.add(prospect)
except Exception as e:
error_emails.append(prospect.email)
logger.error(f'Failed to send email to {prospect.email}: {str(e)}')
time.sleep(1)
if error_emails:
logger.error(f'Failed to send emails to: {error_emails}')
return sent_count, len(error_emails)
@admin.register(ProspectGroup)
class ProspectGroupAdmin(SyncedObjectAdmin):
list_display = ('name', 'user_count')
date_hierarchy = 'creation_date'
raw_id_fields = ['related_user']
@admin.register(Activity)
class ActivityAdmin(SyncedObjectAdmin):
# raw_id_fields = ['prospects']
list_display = ('prospect_names', 'last_update', 'status', 'type', 'description', 'attachment_text', )
list_display = ('prospect_names', 'creation_date', 'status', 'type', 'description', 'attachment_text', )
list_filter = ('status', 'type')
search_fields = ('attachment_text',)
date_hierarchy = 'last_update'
autocomplete_fields = ['prospects', 'related_user']
search_fields = ('description',)
filter_horizontal = ('prospects',)
date_hierarchy = 'creation_date'
def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
@ -524,11 +426,60 @@ class ActivityAdmin(SyncedObjectAdmin):
return form
def save_model(self, request, obj, form, change):
if obj.related_user is None:
obj.related_user = request.user
super().save_model(request, obj, form, change)
def get_event_display(self, obj):
return str(obj)
get_event_display.short_description = 'Activity'
# @admin.register(EmailCampaign)
# class EmailCampaignAdmin(admin.ModelAdmin):
# list_display = ('subject', 'event', 'sent_at')
# list_filter = ('sent_at',)
# search_fields = ('subject', 'content')
# date_hierarchy = 'sent_at'
# readonly_fields = ('sent_at',)
# @admin.register(EmailTracker)
# class EmailTrackerAdmin(admin.ModelAdmin):
# list_display = (
# 'campaign',
# 'prospect',
# 'tracking_id',
# 'sent_status',
# 'opened_status',
# 'clicked_status'
# )
# list_filter = ('sent', 'opened', 'clicked')
# search_fields = (
# 'prospect__name',
# 'prospect__email',
# 'campaign__subject'
# )
# readonly_fields = (
# 'tracking_id', 'sent', 'sent_at',
# 'opened', 'opened_at',
# 'clicked', 'clicked_at'
# )
# date_hierarchy = 'sent_at'
# def sent_status(self, obj):
# return self._get_status_html(obj.sent, obj.sent_at)
# sent_status.short_description = 'Sent'
# sent_status.allow_tags = True
# def opened_status(self, obj):
# return self._get_status_html(obj.opened, obj.opened_at)
# opened_status.short_description = 'Opened'
# opened_status.allow_tags = True
# def clicked_status(self, obj):
# return self._get_status_html(obj.clicked, obj.clicked_at)
# clicked_status.short_description = 'Clicked'
# clicked_status.allow_tags = True
# def _get_status_html(self, status, date):
# if status:
# return format_html(
# '<span style="color: green;">✓</span> {}',
# date.strftime('%Y-%m-%d %H:%M') if date else ''
# )
# return format_html('<span style="color: red;">✗</span>')

@ -7,7 +7,7 @@ from django.utils import timezone
from dateutil.relativedelta import relativedelta
from .models import Activity, Prospect, Status, DeclinationReason, ProspectGroup
from .models import Activity, Prospect, Status, DeclinationReason
User = get_user_model()
@ -110,19 +110,6 @@ class ProspectDeclineReasonFilter(admin.SimpleListFilter):
else:
return queryset
class ProspectGroupFilter(admin.SimpleListFilter):
title = 'ProspectGroup'
parameter_name = 'prospect_group'
def lookups(self, request, model_admin):
prospect_groups = ProspectGroup.objects.all().order_by('-creation_date')
return [(group.id, group.name) for group in prospect_groups]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(prospect_groups__id=self.value())
return queryset
class ContactAgainFilter(admin.SimpleListFilter):
title = 'Contact again' # or whatever you want
parameter_name = 'contact_again'
@ -139,25 +126,3 @@ class ContactAgainFilter(admin.SimpleListFilter):
# if self.value() == '0':
# return queryset.filter(my_field__isnull=True)
return queryset
class PhoneFilter(admin.SimpleListFilter):
title = 'Phone number'
parameter_name = 'phone_filter'
def lookups(self, request, model_admin):
return (
('exclude_mobile', 'Exclude mobile (06/07)'),
('mobile_only', 'Mobile only (06/07)'),
)
def queryset(self, request, queryset):
if self.value() == 'exclude_mobile':
return queryset.exclude(
Q(phone__startswith='06') | Q(phone__startswith='07')
)
elif self.value() == 'mobile_only':
return queryset.filter(
Q(phone__startswith='06') | Q(phone__startswith='07')
)
return queryset

@ -1,38 +0,0 @@
# Generated by Django 5.1 on 2025-09-22 12:34
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('biz', '0004_prospect_contact_again'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='activity',
name='status',
field=models.CharField(blank=True, choices=[('NONE', 'None'), ('INBOUND', 'Inbound'), ('CONTACTED', 'Contacted'), ('RESPONDED', 'Responded'), ('SHOULD_TEST', 'Should test'), ('TESTING', 'Testing'), ('CUSTOMER', 'Customer'), ('LOST', 'Lost customer'), ('DECLINED', 'Declined'), ('NOT_CONCERNED', 'Not concerned'), ('SHOULD_BUY', 'Should buy'), ('HAVE_CREATED_ACCOUNT', 'Have created account')], max_length=50, null=True),
),
migrations.CreateModel(
name='Campaign',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('last_update', models.DateTimeField(default=django.utils.timezone.now)),
('data_access_ids', models.JSONField(default=list)),
('name', models.CharField(blank=True, max_length=200, null=True)),
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('prospects', models.ManyToManyField(blank=True, related_name='campaigns', to='biz.prospect')),
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

@ -1,19 +0,0 @@
# Generated by Django 5.1 on 2025-09-22 13:10
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('biz', '0005_alter_activity_status_campaign'),
]
operations = [
# migrations.AlterField(
# model_name='campaign',
# name='id',
# field=models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
# ),
]

@ -1,37 +0,0 @@
# Generated by Django 5.1 on 2025-09-22 14:08
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('biz', '0006_alter_campaign_id'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ProspectGroup',
fields=[
('creation_date', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
('last_update', models.DateTimeField(default=django.utils.timezone.now)),
('data_access_ids', models.JSONField(default=list)),
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('name', models.CharField(blank=True, max_length=200, null=True)),
('last_updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('prospects', models.ManyToManyField(blank=True, related_name='prospect_groups', to='biz.prospect')),
('related_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.DeleteModel(
name='Campaign',
),
]

@ -1,23 +0,0 @@
# Generated by Django 5.1 on 2025-10-15 07:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('biz', '0007_prospectgroup_delete_campaign'),
]
operations = [
migrations.AlterField(
model_name='activity',
name='declination_reason',
field=models.CharField(blank=True, choices=[('TOO_EXPENSIVE', 'Too expensive'), ('USE_OTHER_PRODUCT', 'Use other product'), ('USE_ANDROID', 'Use Android'), ('TOO_FEW_TOURNAMENTS', 'Too few tournaments'), ('NOT_INTERESTED', 'Not interested'), ('UNKNOWN', 'Unknown')], max_length=50, null=True),
),
migrations.AlterField(
model_name='activity',
name='type',
field=models.CharField(blank=True, choices=[('MAIL', 'Mail'), ('SMS', 'SMS'), ('CALL', 'Call'), ('PRESS', 'Press Release'), ('WORD_OF_MOUTH', 'Word of mouth'), ('WHATS_APP', 'WhatsApp')], max_length=20, null=True),
),
]

@ -25,14 +25,11 @@ class Status(models.TextChoices):
# DECLINED_UNRELATED = 'DECLINED_UNRELATED', 'Declined without significance'
NOT_CONCERNED = 'NOT_CONCERNED', 'Not concerned'
SHOULD_BUY = 'SHOULD_BUY', 'Should buy'
HAVE_CREATED_ACCOUNT = 'HAVE_CREATED_ACCOUNT', 'Have created account'
class DeclinationReason(models.TextChoices):
TOO_EXPENSIVE = 'TOO_EXPENSIVE', 'Too expensive'
USE_OTHER_PRODUCT = 'USE_OTHER_PRODUCT', 'Use other product'
USE_ANDROID = 'USE_ANDROID', 'Use Android'
TOO_FEW_TOURNAMENTS = 'TOO_FEW_TOURNAMENTS', 'Too few tournaments'
NOT_INTERESTED = 'NOT_INTERESTED', 'Not interested'
UNKNOWN = 'UNKNOWN', 'Unknown'
class ActivityType(models.TextChoices):
@ -41,7 +38,6 @@ class ActivityType(models.TextChoices):
CALL = 'CALL', 'Call'
PRESS = 'PRESS', 'Press Release'
WORD_OF_MOUTH = 'WORD_OF_MOUTH', 'Word of mouth'
WHATS_APP = 'WHATS_APP', 'WhatsApp'
class Entity(BaseModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
@ -89,12 +85,6 @@ class Prospect(BaseModel):
return last_activity.status
return Status.NONE
def current_activity_type(self):
last_activity = self.activities.exclude(type=None).order_by('-creation_date').first()
if last_activity:
return last_activity.type
return None
def current_text(self):
last_activity = self.activities.exclude(status=None).order_by('-creation_date').first()
if last_activity:
@ -144,11 +134,6 @@ class Activity(BaseModel):
def delete_dependencies(self):
pass
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# Update last_update for all related prospects when activity is saved
self.prospects.update(last_update=timezone.now())
class Meta:
verbose_name_plural = "Activities"
ordering = ['-creation_date']
@ -185,20 +170,6 @@ class EmailTemplate(BaseModel):
def delete_dependencies(self):
pass
class ProspectGroup(BaseModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
name = models.CharField(max_length=200, null=True, blank=True)
prospects = models.ManyToManyField(Prospect, blank=True, related_name='prospect_groups')
def user_count(self):
return self.prospects.count()
def __str__(self):
return self.name
def delete_dependencies(self):
pass
# class EmailCampaign(models.Model):
# event = models.OneToOneField(Event, on_delete=models.CASCADE)
# subject = models.CharField(max_length=200)

@ -1,448 +0,0 @@
{% extends "admin/base_site.html" %}
{% load static %}
{% block extrahead %}
{{ block.super }}
<style>
.dashboard-container {
padding: 20px;
}
.filter-switch {
margin-bottom: 20px;
padding: 15px;
background: #f8f8f8;
border: 1px solid #ddd;
border-radius: 4px;
}
.filter-switch label {
font-weight: bold;
margin-right: 10px;
cursor: pointer;
}
.filter-switch input[type="checkbox"] {
cursor: pointer;
width: 18px;
height: 18px;
vertical-align: middle;
}
.status-section {
margin-bottom: 30px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
overflow: hidden;
}
.status-header {
background: #417690;
color: white;
padding: 12px 15px;
font-weight: bold;
font-size: 14px;
}
.status-header .status-name {
font-size: 16px;
margin-right: 10px;
}
.status-header .count {
font-size: 13px;
opacity: 0.9;
}
.prospect-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.prospect-table thead {
background: #f9f9f9;
border-bottom: 2px solid #ddd;
}
.prospect-table thead th {
padding: 10px 12px;
text-align: left;
font-weight: 600;
font-size: 13px;
color: #666;
border-bottom: 1px solid #ddd;
}
.prospect-table th:nth-child(1),
.prospect-table td:nth-child(1) {
width: 225px;
}
.prospect-table th:nth-child(2),
.prospect-table td:nth-child(2) {
width: auto;
}
.prospect-table th:nth-child(3),
.prospect-table td:nth-child(3) {
width: 120px;
}
.prospect-table th:nth-child(4),
.prospect-table td:nth-child(4) {
width: 140px;
}
.prospect-table th:nth-child(5),
.prospect-table td:nth-child(5) {
width: 130px;
}
.prospect-table th:nth-child(6),
.prospect-table td:nth-child(6) {
width: 130px;
}
.prospect-table th.actions-col,
.prospect-table td.actions-col {
width: 80px;
text-align: center;
}
.add-activity-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: #70bf2b;
color: white !important;
text-decoration: none !important;
border-radius: 50%;
font-size: 18px;
font-weight: bold;
transition: background-color 0.2s;
}
.add-activity-btn:hover {
background: #5fa624;
color: white !important;
text-decoration: none !important;
}
.prospect-table tbody tr {
border-bottom: 1px solid #eee;
transition: background-color 0.2s;
}
.prospect-table tbody tr:hover {
background: #f5f5f5;
}
.prospect-table tbody td {
padding: 10px 12px;
font-size: 13px;
}
.prospect-table tbody td a {
color: #417690;
text-decoration: none;
}
.prospect-table tbody td a:hover {
text-decoration: underline;
}
.prospect-name {
font-weight: 500;
}
.prospect-entity {
color: #666;
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
}
.prospect-date {
color: #666;
white-space: nowrap;
}
.prospect-status {
display: inline-block;
padding: 3px 8px;
background: #e8f4f8;
border-radius: 3px;
font-size: 11px;
color: #417690;
font-weight: 500;
}
.empty-state {
padding: 40px 15px;
text-align: center;
color: #999;
font-style: italic;
}
</style>
{% endblock %}
{% block content %}
<div class="dashboard-container">
<!-- Quick Actions -->
<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(100px, 1fr)); gap: 15px;">
<a href="{% url 'admin:biz_prospect_changelist' %}"
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
Prospects
</a>
<a href="{% url 'admin:biz_activity_changelist' %}"
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
Activities
</a>
<a href="{% url 'admin:biz_entity_changelist' %}"
style="display: block; padding: 12px 15px; background: #17a2b8; color: white; text-decoration: none; border-radius: 8px; text-align: center; font-weight: 500;">
Entities
</a>
</div>
</div>
<div class="filter-switch">
<label for="my-prospects-toggle">
<input type="checkbox" id="my-prospects-toggle" {% if filter_my %}checked{% endif %}>
Show only my prospects
</label>
</div>
<!-- CONTACT AGAIN Section -->
<div class="status-section">
<div class="status-header">
<span class="status-name">CONTACT AGAIN</span>
<span class="count">({{ contact_again_prospects.count }})</span>
</div>
{% if contact_again_prospects %}
<table class="prospect-table">
<thead>
<tr>
<th>Name</th>
<th>Entity</th>
<th>Phone</th>
<th>Status</th>
<th>Contact Again</th>
<th>Last Update</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{% for prospect in contact_again_prospects %}
<tr>
<td>
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name">
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }}
</a>
</td>
<td class="prospect-entity">{{ prospect.entity_names }}</td>
<td>{{ prospect.phone|default:"-" }}</td>
<td><span class="prospect-status">{{ prospect.current_status }}</span></td>
<td class="prospect-date">{{ prospect.contact_again|date:"d/m/Y" }}</td>
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td>
<td class="actions-col">
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">No prospects</div>
{% endif %}
</div>
<!-- SHOULD_TEST Section -->
<div class="status-section">
<div class="status-header">
<span class="status-name">SHOULD TEST</span>
<span class="count">({{ should_test_prospects.count }})</span>
</div>
{% if should_test_prospects %}
<table class="prospect-table">
<thead>
<tr>
<th>Name</th>
<th>Entity</th>
<th>Phone</th>
<th>Activity Type</th>
<th>Last Update</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{% for prospect in should_test_prospects %}
<tr>
<td>
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name">
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }}
</a>
</td>
<td class="prospect-entity">{{ prospect.entity_names }}</td>
<td>{{ prospect.phone|default:"-" }}</td>
<td>{{ prospect.current_activity_type|default:"-" }}</td>
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td>
<td class="actions-col">
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">No prospects</div>
{% endif %}
</div>
<!-- TESTING Section -->
<div class="status-section">
<div class="status-header">
<span class="status-name">TESTING</span>
<span class="count">({{ testing_prospects.count }})</span>
</div>
{% if testing_prospects %}
<table class="prospect-table">
<thead>
<tr>
<th>Name</th>
<th>Entity</th>
<th>Phone</th>
<th>Activity Type</th>
<th>Last Update</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{% for prospect in testing_prospects %}
<tr>
<td>
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name">
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }}
</a>
</td>
<td class="prospect-entity">{{ prospect.entity_names }}</td>
<td>{{ prospect.phone|default:"-" }}</td>
<td>{{ prospect.current_activity_type|default:"-" }}</td>
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td>
<td class="actions-col">
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">No prospects</div>
{% endif %}
</div>
<!-- OTHERS Section -->
<div class="status-section">
<div class="status-header">
<span class="status-name">OTHERS</span>
<span class="count">({{ others_prospects.count }})</span>
</div>
{% if others_prospects %}
<table class="prospect-table">
<thead>
<tr>
<th>Name</th>
<th>Entity</th>
<th>Phone</th>
<th>Status</th>
<th>Activity Type</th>
<th>Last Update</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{% for prospect in others_prospects %}
<tr>
<td>
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name">
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }}
</a>
</td>
<td class="prospect-entity">{{ prospect.entity_names }}</td>
<td>{{ prospect.phone|default:"-" }}</td>
<td><span class="prospect-status">{{ prospect.current_status }}</span></td>
<td>{{ prospect.current_activity_type|default:"-" }}</td>
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td>
<td class="actions-col">
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">No prospects</div>
{% endif %}
</div>
<!-- RESPONDED Section -->
<div class="status-section">
<div class="status-header">
<span class="status-name">RESPONDED</span>
<span class="count">({{ responded_prospects.count }})</span>
</div>
{% if responded_prospects %}
<table class="prospect-table">
<thead>
<tr>
<th>Name</th>
<th>Entity</th>
<th>Phone</th>
<th>Activity Type</th>
<th>Last Update</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{% for prospect in responded_prospects %}
<tr>
<td>
<a href="{% url 'admin:biz_prospect_change' prospect.id %}" class="prospect-name">
{{ prospect.first_name|default:"" }} {{ prospect.last_name|default:"" }}
</a>
</td>
<td class="prospect-entity">{{ prospect.entity_names }}</td>
<td>{{ prospect.phone|default:"-" }}</td>
<td>{{ prospect.current_activity_type|default:"-" }}</td>
<td class="prospect-date">{{ prospect.last_update|date:"d/m/Y H:i" }}</td>
<td class="actions-col">
<a href="{% url 'admin:biz_activity_add' %}?prospect={{ prospect.id }}" class="add-activity-btn" title="Add Activity">+</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">No prospects</div>
{% endif %}
</div>
</div>
<script>
document.getElementById('my-prospects-toggle').addEventListener('change', function(e) {
const url = new URL(window.location);
if (e.target.checked) {
url.searchParams.set('my', 'true');
} else {
url.searchParams.delete('my');
}
window.location.href = url.toString();
});
</script>
{% endblock %}

@ -3,9 +3,8 @@
{% block object-tools-items %}
{{ block.super }}
<li>
<a href="{% url 'admin:biz_dashboard' %}" class="viewlink" style="margin-right: 5px;">Dashboard</a>
<a href="{% url 'admin:import_file' %}" class="addlink" style="margin-right: 5px;">Import</a>
<a href="{% url 'admin:import_app_users' %}" class="addlink" style="margin-right: 5px;">Import App Users</a>
<!--<a href="{% url 'admin:cleanup' %}" class="deletelink" style="margin-right: 5px;">Reset</a>-->
<a href="{% url 'admin:cleanup' %}" class="deletelink" style="margin-right: 5px;">Reset</a>
</li>
{% endblock %}

@ -65,6 +65,9 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'tournaments.middleware.ReferrerMiddleware', # Add this line
'tournaments.middleware.RegistrationCartCleanupMiddleware',
]
ROOT_URLCONF = 'padelclub_backend.urls'

@ -1,54 +1,57 @@
# Rest Framework configuration
REST_FRAMEWORK = {
"DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%S.%f%z",
'DATETIME_FORMAT': "%Y-%m-%dT%H:%M:%S.%f%z",
# Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users.
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.BasicAuthentication",
"rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
]
}
EMAIL_HOST_USER = "automatic@padelclub.app"
EMAIL_HOST_PASSWORD = "XLR@Sport@2024"
DEFAULT_FROM_EMAIL = "Padel Club <automatic@padelclub.app>"
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp-xlr.alwaysdata.net"
EMAIL_HOST_USER = 'automatic@padelclub.app'
EMAIL_HOST_PASSWORD = 'XLR@Sport@2024'
DEFAULT_FROM_EMAIL = 'Padel Club <automatic@padelclub.app>'
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'smtp-xlr.alwaysdata.net'
EMAIL_PORT = 587
EMAIL_USE_TLS = True
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
},
"qr-code": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "qr-code-cache",
"TIMEOUT": 3600,
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
},
'qr-code': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'qr-code-cache',
'TIMEOUT': 3600
}
}
QR_CODE_CACHE_ALIAS = "qr-code"
QR_CODE_CACHE_ALIAS = 'qr-code'
SYNC_APPS = {
"sync": {},
"tournaments": {"exclude": ["Log", "FailedApiCall", "DeviceToken", "Image"]},
# 'biz': {},
'sync': {},
'tournaments': { 'exclude': ['Log', 'FailedApiCall', 'DeviceToken', 'Image'] },
'biz': {},
}
SYNC_MODEL_CHILDREN_SHARING = {
"Match": ["team_scores", "team_registration", "player_registrations"]
'Match': ['team_scores', 'team_registration', 'player_registrations']
}
STRIPE_CURRENCY = "eur"
STRIPE_CURRENCY = 'eur'
# Add managers who should receive internal emails
SHOP_MANAGERS = [
("Shop Admin", "shop-admin@padelclub.app"),
('Shop Admin', 'shop-admin@padelclub.app'),
# ('Laurent Morvillier', 'laurent@padelclub.app'),
# ('Xavier Rousset', 'xavier@padelclub.app'),
]
SHOP_SITE_ROOT_URL = "https://padelclub.app"
SHOP_SUPPORT_EMAIL = "shop@padelclub.app"
SHOP_SITE_ROOT_URL = 'https://padelclub.app'
SHOP_SUPPORT_EMAIL = 'shop@padelclub.app'

@ -42,7 +42,6 @@ STRIPE_PUBLISHABLE_KEY = ''
STRIPE_SECRET_KEY = ''
SHOP_STRIPE_WEBHOOK_SECRET = 'whsec_...' # Your existing webhook secret
TOURNAMENT_STRIPE_WEBHOOK_SECRET = 'whsec_...' # New webhook secret for tournaments
XLR_STRIPE_WEBHOOK_SECRET = 'whsec_...' # New webhook secret for padel club
STRIPE_FEE = 0.0075
TOURNAMENT_SETTINGS = {
'TIME_PROXIMITY_RULES': {

@ -18,7 +18,7 @@ from django.urls import include, path
from django.conf import settings
from django.conf.urls.static import static
from tournaments.admin_utils import download_french_padel_rankings, debug_tools_page, test_player_details_apis, explore_fft_api_endpoints, get_player_license_info, bulk_license_lookup, search_player_by_name, enrich_rankings_with_licenses, gather_monthly_tournaments_and_umpires
from tournaments.admin_utils import download_french_padel_rankings, debug_tools_page, test_player_details_apis, explore_fft_api_endpoints, get_player_license_info, bulk_license_lookup, search_player_by_name, enrich_rankings_with_licenses
urlpatterns = [
@ -38,11 +38,6 @@ urlpatterns = [
path('kingdom/', admin.site.urls),
path('api-auth/', include('rest_framework.urls')),
path('dj-auth/', include('django.contrib.auth.urls')),
path(
"kingdom/debug/gather-monthly-umpires/",
gather_monthly_tournaments_and_umpires,
name="gather_monthly_umpires",
),
]

@ -1,21 +0,0 @@
### Synchronization quick ReadMe
- Data class must extend BaseModel
- Admin classes must extend SyncedObjectAdmin to have updates saved in the BaseModel properties
- The SynchronizationApi defines a get and a post service to POST new data, and GET updates. When performing an operation on a data, a ModelLog instance is created with the related information. When performing a GET, we retrieve the list of ModelLogs to sent the new data to the user.
- routing.py defines the URL of the websocket where messages are sent when updates are made. URL is by user.
### Sharing
- Data can be shared between users. To do that, a new DataAccess object can be created to define the owner, the authorized user, and the object id.
- By default, the whole hierarchy of objects are shared, from the data parents to all its children.
- Special data path can be specified for a class by defining a setting
example:
SYNC_MODEL_CHILDREN_SHARING = {
'Match': ['team_scores', 'team_registration', 'player_registrations']
}
Here when sharing a Match, we also share objects accessed through the names of the properties to get TeamScore, TeamRegistration and PlayerRegistration.
- It's also possible to exclude a class from being sharable by setting sharable = False in its definition. In PadelClub, Club is the top entity that links all data together, so we don't want the automatic data scanning to share clubs.

@ -6,12 +6,12 @@ from .models import BaseModel, ModelLog, DataAccess
class SyncedObjectAdmin(admin.ModelAdmin):
exclude = ('data_access_ids',)
raw_id_fields = ['related_user', 'last_updated_by']
raw_id_fields = ['related_user']
def save_model(self, request, obj, form, change):
if isinstance(obj, BaseModel):
obj.last_updated_by = request.user
# obj.last_update = timezone.now()
obj.last_update = timezone.now()
super().save_model(request, obj, form, change)
def delete_model(self, request, obj):
@ -24,14 +24,15 @@ class SyncedObjectAdmin(admin.ModelAdmin):
queryset.delete()
class ModelLogAdmin(admin.ModelAdmin):
list_display = ['user', 'formatted_time', 'model_name', 'operation', 'model_id', 'device_id']
list_filter = ['operation', 'model_name', 'user']
list_display = ['user', 'formatted_time', 'operation', 'model_id', 'model_name', 'count']
list_filter = ['user', 'operation', 'model_name']
ordering = ['-date']
search_fields = ['model_id']
readonly_fields = ['date']
class DataAccessAdmin(SyncedObjectAdmin):
list_display = ['id', 'related_user', 'get_shared_users', 'model_name', 'model_id']
list_display = ['related_user', 'get_shared_users', 'model_name', 'model_id']
list_filter = ['related_user', 'shared_with']
ordering = ['-granted_at']

@ -116,7 +116,7 @@ class SyncModelChildrenManager:
str or None: The reverse relationship name if found
"""
# print(f'reverse of {original_relationship_name} in = {in_model} for {for_model} ')
print(f'reverse of {original_relationship_name} in = {in_model} for {for_model} ')
try:
for field in for_model._meta.get_fields():
# Check ForeignKey, OneToOneField fields

@ -16,13 +16,10 @@ class BaseModel(models.Model):
last_updated_by = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL, related_name='+')
data_access_ids = models.JSONField(default=list)
sharable = True
class Meta:
abstract = True
def save(self, *args, **kwargs):
self.last_update = now()
if self.related_user is None:
self.related_user = self.find_related_user()
if self._state.adding:
@ -42,8 +39,6 @@ class BaseModel(models.Model):
}
def update_data_access_list(self):
if self.sharable == False:
return
related_instances = self.sharing_related_instances()
data_access_ids = set()
for instance in related_instances:
@ -51,18 +46,21 @@ class BaseModel(models.Model):
data_access_ids.update(instance.data_access_ids)
# print(f'related_instances = {related_instances}')
# data_access_ids = [instance.data_access_ids for instance in related_instances if isinstance(instance, BaseModel)]
# data_access_ids.extend(self.data_access_ids)
self.data_access_ids = list(data_access_ids)
# DataAccess = apps.get_model('sync', 'DataAccess')
# data_accesses = DataAccess.objects.filter(model_id__in=related_ids)
# for data_access in data_accesses:
# self.add_data_access_relation(data_access)
def add_data_access_relation(self, data_access):
if self.sharable == False:
return
str_id = str(data_access.id)
if str_id not in self.data_access_ids:
self.data_access_ids.append(str_id)
def remove_data_access_relation(self, data_access):
if self.sharable == False:
return
try:
self.data_access_ids.remove(str(data_access.id))
except ValueError:
@ -133,8 +131,9 @@ class BaseModel(models.Model):
children_by_model = self.get_children_by_model()
for queryset in children_by_model.values():
for child in queryset:
if isinstance(child, BaseModel):
children.append(child)
# Recursively get children of children
if isinstance(child, BaseModel):
children.extend(child.get_recursive_children(processed_objects))
return children
@ -191,7 +190,7 @@ class BaseModel(models.Model):
for parent in parents_by_model.values():
if isinstance(parent, BaseModel):
if parent.related_user:
print(f'*** related_user found in {parent}')
print(f'related_user found in {parent}')
return parent.related_user
else:
return parent.find_related_user(processed_objects)
@ -220,14 +219,13 @@ class BaseModel(models.Model):
children = self.get_shared_children_from_relationships(relationships, processed_objects)
instances.extend(children)
else:
children = self.get_recursive_children(processed_objects)
instances.extend(children)
instances.extend(self.get_recursive_children(processed_objects))
return instances
def get_shared_children_from_relationships(self, relationships, processed_objects):
# print(f'>>> {self.__class__.__name__} : relationships = {relationships}')
print(f'>>> {self.__class__.__name__} : relationships = {relationships}')
current = [self]
for relationship in relationships:
# print(f'> relationship = {relationship}')
@ -242,6 +240,7 @@ class BaseModel(models.Model):
values.extend(value.all())
else:
processed_objects.add(value)
values.append(value)
current = values

@ -77,7 +77,7 @@ class DataAccess(BaseModel):
with transaction.atomic():
for instance in related_instance:
# logger.info(f'adds DataAccess to {instance.__class__.__name__}')
logger.info(f'adds DataAccess to {instance.__class__.__name__}')
if isinstance(instance, BaseModel):
instance.add_data_access_relation(self)
instance.save()

@ -91,35 +91,24 @@ class RelatedUsersRegistry:
def register(self, instance_id, users):
"""Register a device_id for a model instance ID."""
# logger.info(f'USER REGISTRY register {instance_id} : {users}')
with self._lock:
instance_id_str = str(instance_id)
if instance_id_str in self._registry:
existing_users = self._registry[instance_id_str]
self._registry[instance_id_str] = existing_users.union(users)
else:
self._registry[instance_id_str] = set(users) # we convert to set because transmitted query_set are emptying themselves
self._registry[instance_id_str] = users
def get_users(self, instance_id):
"""Get the device_id for a model instance ID."""
with self._lock:
instance_id_str = str(instance_id)
if instance_id_str in self._registry:
# logger.info(f'###### get_users exists ! {instance_id}')
return self._registry[instance_id_str]
else:
# logger.info(f'####### get_users {instance_id} not referenced !')
return {}
# return self._registry.get(instance_id_str)
return self._registry.get(str(instance_id))
def unregister(self, instance_id):
"""Remove an instance from the registry."""
# logger.info(f'USER REGISTRY unregister {instance_id}')
with self._lock:
instance_id_str = str(instance_id)
if instance_id_str in self._registry:
del self._registry[instance_id_str]
if instance_id in self._registry:
del self._registry[instance_id]
# Global instance
related_users_registry = RelatedUsersRegistry()

@ -8,7 +8,7 @@ from authentication.models import Device
from django.contrib.auth import get_user_model
from .ws_sender import websocket_sender
from .registry import device_registry, related_users_registry, model_registry
from .registry import device_registry, related_users_registry
import logging
import traceback
@ -24,7 +24,7 @@ def presave_handler(sender, instance, **kwargs):
try:
# some other classes are excluded in settings_app.py: SYNC_APPS
if not isinstance(instance, (BaseModel, User)):
if not isinstance(instance, (BaseModel, User)) or isinstance(instance, DataAccess):
return
signal = kwargs.get('signal')
@ -54,17 +54,11 @@ def synchronization_notifications(sender, instance, created=False, **kwargs):
if not isinstance(instance, BaseModel) and not isinstance(instance, User):
return
model_name = instance.__class__.__name__
if model_registry.get_model(model_name) is None:
return
try:
process_foreign_key_changes(sender, instance, **kwargs)
signal = kwargs.get('signal')
save_model_log_if_possible(instance, signal, created)
notify_impacted_users(instance)
# print(f'!!!!! related_users_registry.unregister for {instance.__class__.__name__} / {signal}')
related_users_registry.unregister(instance.id)
except Exception as e:
logger.info(f'*** ERROR2: {e}')
@ -74,7 +68,6 @@ def synchronization_notifications(sender, instance, created=False, **kwargs):
def notify_impacted_users(instance):
device_id = device_registry.get_device_id(instance.id)
users = related_users_registry.get_users(instance.id)
logger.info(f'>>> notify_impacted_users: {users} for {instance.id}')
if users:
user_ids = [user.id for user in users]
@ -102,6 +95,7 @@ def save_model_log_if_possible(instance, signal, created):
else:
operation = ModelOperation.DELETE
model_name = instance.__class__.__name__
store_id = None
if isinstance(instance, SideStoreModel):
store_id = instance.store_id
@ -112,7 +106,6 @@ def save_model_log_if_possible(instance, signal, created):
# user_ids = [user.id for user in users]
# # print(f'users to notify: {user_ids}')
# instance._users_to_notify = user_ids # save this for the post_save signal
model_name = instance.__class__.__name__
save_model_log(users, operation, model_name, instance.id, store_id)
else:
@ -126,6 +119,7 @@ def save_model_log(users, model_operation, model_name, model_id, store_id):
with transaction.atomic():
created_logs = []
for user in users:
if user.can_synchronize:
# logger.info(f'Creating ModelLog for user {user.id} - user exists: {User.objects.filter(id=user.id).exists()}')
model_log = ModelLog(
user=user,
@ -274,8 +268,6 @@ def handle_shared_with_changes(sender, instance, action, pk_set, **kwargs):
# print(f'm2m changed = {pk_set}')
users = User.objects.filter(id__in=pk_set)
save_model_log(users, ModelOperation.PUT, DataAccess.__name__, instance.id, None)
with transaction.atomic():
if action == "post_add":
instance.create_access_log(users, 'SHARED_ACCESS')
@ -283,7 +275,6 @@ def handle_shared_with_changes(sender, instance, action, pk_set, **kwargs):
instance.create_access_log(users, 'REVOKED_ACCESS')
device_id = device_registry.get_device_id(instance.id)
# logger.info(f'*** DataAccess m2m_changed > send message to : {pk_set}')
websocket_sender.send_message(pk_set, device_id)
# for user_id in pk_set:
@ -307,12 +298,9 @@ def data_access_post_save(sender, instance, **kwargs):
@receiver(pre_delete, sender=DataAccess)
def revoke_access_after_delete(sender, instance, **kwargs):
# logger.info(f'.........PRE_DELETE DATAACCESS = {instance.id}..........')
try:
instance.cleanup_references()
instance.create_revoke_access_log()
# logger.info(f'*** users to notify data access delete: {instance.shared_with.all()}')
related_users_registry.register(instance.id, instance.shared_with.all())
instance._user = instance.related_user
@ -333,8 +321,6 @@ def data_access_post_delete(sender, instance, **kwargs):
logger.info(f'*** ERROR5: {e}')
logger.info(traceback.format_exc())
raise
# logger.info(f'.........POST_DELETE END DATAACCESS = {instance.id}..........')
def related_users(instance):
users = set()
@ -347,8 +333,8 @@ def related_users(instance):
for data_access in data_access_list:
users.add(data_access.related_user)
users.update(data_access.shared_with.all())
# print(f'find users for {instance.__class__.__name__}, count = {len(users)}')
if isinstance(instance, DataAccess):
users.update(instance.shared_with.all())
return {user for user in users if user is not None}

@ -42,7 +42,7 @@ def get_data(model_name, model_id):
def get_serialized_data_by_id(model_name, model_id):
# print(f'model_name = {model_name}')
model = model_registry.get_model(model_name)
# logger.info(f'model for {model_name} = {model}')
logger.info(f'model for {model_name} = {model}')
instance = model.objects.get(id=model_id)
serializer = get_serializer(instance, model_name)
return serializer.data
@ -101,11 +101,10 @@ class HierarchyOrganizer:
self.add_related_children(instance)
def add_related_children(self, instance):
self.children = instance.get_shared_children(set())
instance.get_shared_children(self.children)
def grouped_children(self):
grouped = defaultdict(list)
for instance in self.children:
class_name = instance.__class__.__name__
grouped[class_name].append(instance.data_identifier_dict())

@ -251,7 +251,7 @@ class SynchronizationApi(APIView):
return Response({"error": f"Invalid date format for last_update: {decoded_last_update}"},
status=status.HTTP_400_BAD_REQUEST)
print(f'>>> {request.user.username} : GET last modifications since: {last_update_str} / converted = {last_update}')
print(f'>>> GET last modifications since: {last_update_str} / converted = {last_update}')
device_id = request.query_params.get('device_id')
logs = self.query_model_logs(last_update, request.user, device_id)
@ -287,6 +287,7 @@ class LogProcessingResult:
def process_logs(self, logs):
"""Process logs to collect basic operations and handle grant/revoke efficiently."""
for log in logs:
self.last_log_date = log.date
try:
if log.operation in ['POST', 'PUT', 'RELATIONSHIP_SET']:
data = get_serialized_data_by_id(log.model_name, log.model_id)
@ -323,10 +324,7 @@ class LogProcessingResult:
self.shared_relationship_sets[log.model_name][log.model_id] = data
elif log.operation == 'SHARED_RELATIONSHIP_REMOVED':
self.shared_relationship_removals[log.model_name].append(log.data_identifier_dict())
self.last_log_date = log.date # set dates after having retrieved informations
except ObjectDoesNotExist:
logger.warning(f'log processing failed, unable to find {log.model_name} : {log.model_id}')
pass
# Convert updates dict to list for each model
@ -389,17 +387,17 @@ class LogProcessingResult:
# First, collect all revocations
for model_name, items in self.revoke_info.items():
revocations[model_name].extend(items)
# logger.info(f'$$$ process_revocations for {model_name}, items = {len(items)}')
logger.info(f'$$$ process_revocations for {model_name}, items = {len(items)}')
# Process parent hierarchies for each revoked item
model = model_registry.get_model(model_name)
for item in items:
# logger.info(f'$$$ item revoked = {item}')
logger.info(f'$$$ item revoked = {item}')
try:
instance = model.objects.get(id=item['model_id'])
# logger.info(f'$$$ process revoked item parents of {model_name} : {item['model_id']}')
logger.info(f'$$$ process revoked item parents of {model_name} : {item['model_id']}')
revocated_relations_organizer.add_relations(instance)
except model.DoesNotExist:
@ -415,8 +413,6 @@ class LogProcessingResult:
shared, grants = self.process_shared()
revocations, revocated_relations_organizer = self.process_revocations()
# print(f'self.revocations = {dict(revocations)}')
# print(f'self.revocated_relations_organizer = {revocated_relations_organizer.get_organized_data()}')
# print(f'self.deletions = {dict(self.deletions)}')
# print(f'self.shared_relationship_sets = {self.shared_relationship_sets}')
# print(f'self.shared_relationship_removals = {self.shared_relationship_removals}')

@ -1,10 +1,6 @@
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
from threading import Timer, Lock
import logging
logger = logging.getLogger(__name__)
class WebSocketSender:
"""
@ -59,23 +55,19 @@ class WebSocketSender:
user_ids_key = frozenset(user_ids)
if not user_ids_key: # No users to send to
logger.info(f'WARNING: no user ids : {user_ids}')
return
with self._debounce_lock:
timer_device_id = device_id
if user_ids_key in self._debounce_registry:
old_timer, old_device_id = self._debounce_registry[user_ids_key]
old_timer, _ = self._debounce_registry[user_ids_key]
old_timer.cancel()
if old_device_id != device_id: # we want to notify all devices if there all multiple ones
timer_device_id = None
new_timer = Timer(
self._buffer_timeout,
self._handle_debounced_action,
args=[user_ids_key, timer_device_id]
args=[user_ids_key, device_id]
)
self._debounce_registry[user_ids_key] = (new_timer, device_id)
self._debounce_registry[user_ids_key] = (new_timer, device_id) # Store new timer and latest device_id
new_timer.start()
def _handle_debounced_action(self, user_ids_key, device_id):

@ -1,24 +1,19 @@
from django.contrib import admin, messages
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.utils import timezone
from django.contrib.admin.models import LogEntry, ADDITION, CHANGE, DELETION
from django.utils.html import escape
from django.urls import reverse, path
from django.urls import reverse, path # Add path import
from django.utils.safestring import mark_safe
from django.shortcuts import render
from django.db.models import Avg, Count
from datetime import timedelta, datetime
from biz.models import Prospect, ProspectGroup
from django.shortcuts import render # Add this import
from django.db.models import Sum, Count, Avg, Q # Add these imports
from datetime import datetime, timedelta # Add this import
from .models import Club, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, Court, DateInterval, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer, Image
from .forms import CustomUserCreationForm, CustomUserChangeForm
from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter, StartDateRangeFilter, UserWithEventsFilter, UserWithPurchasesFilter, UserWithProspectFilter, TeamScoreRoundIndexFilter
from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter, StartDateRangeFilter
from sync.admin import SyncedObjectAdmin
import logging
logger = logging.getLogger(__name__)
class CustomUserAdmin(UserAdmin):
form = CustomUserChangeForm
@ -26,21 +21,20 @@ class CustomUserAdmin(UserAdmin):
model = CustomUser
search_fields = ['username', 'email', 'phone', 'first_name', 'last_name', 'licence_id']
filter_horizontal = ('clubs',)
actions = ['convert_to_prospect', 'create_group']
list_display = ['email', 'first_name', 'last_name', 'username', 'date_joined', 'latest_event_club_name', 'is_active', 'event_count', 'origin', 'registration_payment_mode', 'licence_id']
list_filter = ['is_active', 'origin', UserWithEventsFilter, UserWithPurchasesFilter, UserWithProspectFilter]
list_filter = ['is_active', 'origin']
ordering = ['-date_joined']
autocomplete_fields = ['supervisors', 'organizers']
raw_id_fields = ['agents']
fieldsets = [
(None, {'fields': ['id', 'username', 'email', 'password', 'first_name', 'last_name', 'is_active', 'date_joined']}),
(None, {'fields': ['id', 'username', 'email', 'password', 'first_name', 'last_name', 'is_active']}),
('Permissions', {'fields': ['is_staff', 'is_superuser', 'groups', 'user_permissions']}),
('Personal Info', {'fields': ['registration_payment_mode', 'clubs', 'country', 'phone', 'licence_id', 'umpire_code']}),
('Tournament Settings', {'fields': [
'summons_message_body', 'summons_message_signature', 'summons_available_payment_methods',
'summons_display_format', 'summons_display_entry_fee', 'summons_use_full_custom_message',
'match_formats_default_duration', 'bracket_match_format_preference', 'group_stage_match_format_preference',
'loser_bracket_match_format_preference', 'device_id', 'loser_bracket_mode', 'origin', 'supervisors', 'organizers', 'should_synchronize', 'can_synchronize'
'loser_bracket_match_format_preference', 'device_id', 'loser_bracket_mode', 'origin', 'agents', 'should_synchronize', 'can_synchronize'
]}),
]
@ -58,66 +52,13 @@ class CustomUserAdmin(UserAdmin):
obj.last_update = timezone.now()
super().save_model(request, obj, form, change)
def create_group(self, request, queryset):
prospects = []
source_value = f"auto_created_{datetime.now().strftime('%Y-%m-%d_%H:%M')}"
for user in queryset:
prospect = Prospect.objects.filter(email=user.email).first()
if prospect:
prospects.append(prospect)
else:
prospect = Prospect.objects.create(
first_name=user.first_name,
last_name=user.last_name,
email=user.email,
phone=user.phone,
official_user=user,
source=source_value
)
prospects.append(prospect)
prospect_group = ProspectGroup.objects.create(
name=f"{datetime.now().strftime('%Y-%m-%d_%H:%M')}",
)
prospect_group.prospects.add(*prospects)
messages.success(request, f'Created prospect group {prospect_group.name} with {queryset.count()} prospects')
create_group.short_description = "Create group with selection"
def convert_to_prospect(self, request, queryset):
created_count = 0
skipped_count = 0
source_value = f"user_conversion_{datetime.now().strftime('%Y-%m-%d_%H:%M')}"
for user in queryset:
if user.email and Prospect.objects.filter(email=user.email).exists():
skipped_count += 1
continue
prospect = Prospect.objects.create(
first_name=user.first_name,
last_name=user.last_name,
email=user.email,
phone=user.phone,
official_user=user,
source=source_value
)
created_count += 1
if created_count > 0:
messages.success(request, f'{created_count} prospect(s) successfully created.')
if skipped_count > 0:
messages.warning(request, f'{skipped_count} user(s) skipped (prospect with same email already exists).')
convert_to_prospect.short_description = "Convert selected users to prospects"
class EventAdmin(SyncedObjectAdmin):
list_display = ['creation_date', 'name', 'club', 'creator', 'tenup_id', 'display_images']
list_display = ['creation_date', 'name', 'club', 'creator', 'creator_full_name', 'tenup_id', 'display_images']
list_filter = ['creator', 'club', 'tenup_id']
search_fields = ['name', 'club__name', 'creator__email']
raw_id_fields = ['related_user', 'creator', 'club']
raw_id_fields = ['creator', 'club']
ordering = ['-creation_date']
readonly_fields = ['display_images_preview']
actions = ['set_club_action']
fieldsets = [
(None, {'fields': ['last_update', 'related_user', 'name', 'club', 'creator', 'creation_date', 'tenup_id']}),
@ -147,80 +88,11 @@ class EventAdmin(SyncedObjectAdmin):
return mark_safe(html)
display_images_preview.short_description = 'Images Preview'
def set_club_action(self, request, queryset):
"""Action to set club for selected events"""
from django import forms
from django.contrib.admin.widgets import ForeignKeyRawIdWidget
from django.contrib.admin import helpers
from django.core.exceptions import ValidationError
class ClubSelectionForm(forms.Form):
_selected_action = forms.CharField(widget=forms.MultipleHiddenInput)
action = forms.CharField(widget=forms.HiddenInput)
club_id = forms.CharField(
label='Club',
required=True,
help_text='Enter Club ID or use the search icon to find a club',
widget=ForeignKeyRawIdWidget(
Event._meta.get_field('club').remote_field,
self.admin_site
)
)
def clean_club_id(self):
club_id = self.cleaned_data['club_id']
try:
club = Club.objects.get(pk=club_id)
return club
except Club.DoesNotExist:
raise ValidationError(f'Club with ID {club_id} does not exist.')
except (ValueError, TypeError) as e:
raise ValidationError(f'Invalid Club ID format: {club_id}')
if 'apply' in request.POST:
form = ClubSelectionForm(request.POST)
if form.is_valid():
club = form.cleaned_data['club_id'] # This is now a Club instance
updated_count = queryset.update(club=club)
self.message_user(
request,
f'Successfully updated {updated_count} event(s) with club: {club.name}',
messages.SUCCESS
)
return None
else:
# Show form errors
self.message_user(
request,
f'Form validation failed. Errors: {form.errors}',
messages.ERROR
)
# Initial form display
form = ClubSelectionForm(initial={
'_selected_action': request.POST.getlist(helpers.ACTION_CHECKBOX_NAME),
'action': 'set_club_action',
})
context = {
'form': form,
'events': queryset,
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
'action_name': 'set_club_action',
'title': 'Set Club for Events',
'media': form.media,
'has_change_permission': True,
}
return render(request, 'admin/tournaments/set_club_action.html', context)
set_club_action.short_description = "Set club for selected events"
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', 'federal_level_category']
raw_id_fields = ['last_updated_by', 'event']
def dashboard_view(self, request):
"""Tournament dashboard view with comprehensive statistics"""
@ -309,10 +181,6 @@ class TournamentAdmin(SyncedObjectAdmin):
avg_teams=Avg('tournament__team_count')
)['avg_teams'] or 0
email_count = PlayerRegistration.objects.aggregate(
total=Count('email', distinct=True)
)['total']
avg_entry_fee = Tournament.objects.exclude(is_deleted=True).aggregate(
avg_fee=Avg('entry_fee')
)['avg_fee'] or 0
@ -397,7 +265,6 @@ class TournamentAdmin(SyncedObjectAdmin):
'tournaments_with_payment': tournaments_with_payment,
'avg_teams_per_tournament': round(avg_teams_per_tournament, 1),
'avg_entry_fee': round(avg_entry_fee, 2),
'email_count': email_count,
# User statistics
'total_users': total_users,
@ -430,13 +297,12 @@ class TeamRegistrationAdmin(SyncedObjectAdmin):
list_display = ['player_names', 'group_stage', 'name', 'tournament', 'registration_date']
list_filter = [SimpleTournamentListFilter]
search_fields = ['id']
raw_id_fields = ['related_user', 'tournament']
class TeamScoreAdmin(SyncedObjectAdmin):
list_display = ['team_registration', 'score', 'walk_out', 'match']
list_filter = [TeamScoreRoundIndexFilter, TeamScoreTournamentListFilter]
list_filter = [TeamScoreTournamentListFilter]
search_fields = ['id', 'team_registration__player_registrations__first_name', 'team_registration__player_registrations__last_name']
raw_id_fields = ['team_registration', 'match']
raw_id_fields = ['team_registration', 'match'] # Add this line
list_per_page = 50 # Controls pagination on the list view
def get_queryset(self, request):
@ -493,7 +359,6 @@ class PurchaseAdmin(SyncedObjectAdmin):
list_display = ['id', 'user', 'product_id', 'quantity', 'purchase_date', 'revocation_date', 'expiration_date']
list_filter = ['user']
ordering = ['-purchase_date']
raw_id_fields = ['user']
class CourtAdmin(SyncedObjectAdmin):
list_display = ['index', 'name', 'club']

File diff suppressed because it is too large Load Diff

@ -43,13 +43,6 @@ class CustomLoginView(auth_views.LoginView):
return context
def get(self, request, *args, **kwargs):
# Capture referrer for anonymous users (replaces middleware functionality)
if not request.user.is_authenticated:
referrer = request.META.get('HTTP_REFERER')
# Only store referrer if it exists and is not the login page itself
if referrer and 'login' not in referrer:
request.session['login_referrer'] = referrer
# Clear any potential password reset session data
keys_to_clear = [key for key in request.session.keys()
if 'reset' in key or 'password' in key]

@ -1,6 +1,6 @@
from django.contrib import admin
from .models import Tournament, Match, Round
from django.db.models import Q, Count
from .models import Tournament, Match
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from datetime import timedelta
@ -135,82 +135,3 @@ class StartDateRangeFilter(admin.SimpleListFilter):
start_date__gte=today - timedelta(days=3),
start_date__lte=today + timedelta(days=3)
)
class UserWithEventsFilter(admin.SimpleListFilter):
title = _('has events')
parameter_name = 'has_events'
def lookups(self, request, model_admin):
return (
('yes', _('Has events')),
('no', _('No events')),
)
def queryset(self, request, queryset):
if self.value() == 'yes':
return queryset.annotate(events_count=Count('events')).filter(events_count__gt=0)
elif self.value() == 'no':
return queryset.annotate(events_count=Count('events')).filter(events_count=0)
return queryset
class UserWithPurchasesFilter(admin.SimpleListFilter):
title = _('has purchases')
parameter_name = 'has_purchases'
def lookups(self, request, model_admin):
return (
('yes', _('Has purchases')),
('no', _('No purchases')),
)
def queryset(self, request, queryset):
if self.value() == 'yes':
return queryset.annotate(purchases_count=Count('purchases')).filter(purchases_count__gt=0)
elif self.value() == 'no':
return queryset.annotate(purchases_count=Count('purchases')).filter(purchases_count=0)
return queryset
class UserWithProspectFilter(admin.SimpleListFilter):
title = _('has prospect')
parameter_name = 'has_prospect'
def lookups(self, request, model_admin):
return (
('yes', _('Has prospect')),
('no', _('No prospect')),
)
def queryset(self, request, queryset):
from biz.models import Prospect
if self.value() == 'yes':
prospect_emails = Prospect.objects.values_list('email', flat=True).filter(email__isnull=False)
return queryset.filter(email__in=prospect_emails)
elif self.value() == 'no':
prospect_emails = Prospect.objects.values_list('email', flat=True).filter(email__isnull=False)
return queryset.exclude(email__in=prospect_emails)
return queryset
class TeamScoreRoundIndexFilter(admin.SimpleListFilter):
title = _("Round Index")
parameter_name = "round_index"
def lookups(self, request, model_admin):
# Get distinct round indexes from matches that have team scores
round_indexes = Round.objects.filter(
matches__team_scores__isnull=False
).values_list('index', flat=True).distinct().order_by('index')
# Create lookup tuples with round names
lookups = []
for index in round_indexes:
round_obj = Round.objects.filter(index=index, parent__isnull=True).first()
if round_obj:
lookups.append((index, round_obj.name()))
else:
lookups.append((index, f"Index {index}"))
return lookups
def queryset(self, request, queryset):
if self.value():
return queryset.filter(match__round__index=self.value())
return queryset

@ -171,7 +171,7 @@ class SimpleForm(forms.Form):
class TournamentRegistrationForm(forms.Form):
#first_name = forms.CharField(label='Prénom', max_length=50)
#last_name = forms.CharField(label='Nom', max_length=50)
email = forms.EmailField(label='E-mail')
email = forms.EmailField(label='E-mail', widget=forms.EmailInput(attrs={'readonly': 'readonly'}))
mobile_number = forms.CharField(
label='Téléphone',
max_length=15,

@ -0,0 +1,53 @@
from django.urls import reverse
from django.utils import timezone
import datetime
class ReferrerMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Check if the user is anonymous and going to the login page
if not request.user.is_authenticated and request.path == reverse('login'):
# Get the referring URL from the HTTP_REFERER header
referrer = request.META.get('HTTP_REFERER')
# Only store referrer if it exists and is not the login page itself
if referrer and 'login' not in referrer:
request.session['login_referrer'] = referrer
response = self.get_response(request)
return response
class RegistrationCartCleanupMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
self._check_and_clean_expired_cart(request)
response = self.get_response(request)
return response
def _check_and_clean_expired_cart(self, request):
if 'registration_cart_expiry' in request.session:
try:
expiry_str = request.session['registration_cart_expiry']
expiry = datetime.datetime.fromisoformat(expiry_str)
if timezone.now() > expiry:
# Clear expired cart
keys_to_delete = [
'registration_cart_id',
'registration_tournament_id',
'registration_cart_players',
'registration_cart_expiry',
'registration_mobile_number'
]
for key in keys_to_delete:
if key in request.session:
del request.session[key]
request.session.modified = True
except (ValueError, TypeError):
# Invalid expiry format, clear it
if 'registration_cart_expiry' in request.session:
del request.session['registration_cart_expiry']
request.session.modified = True

@ -1,28 +0,0 @@
# Generated by Django 5.1 on 2025-09-24 14:19
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0137_playerregistration_is_anonymous_and_more'),
]
operations = [
migrations.RemoveField(
model_name='customuser',
name='agents',
),
migrations.AddField(
model_name='customuser',
name='supervisors',
field=models.ManyToManyField(blank=True, related_name='supervising_for', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='tournament',
name='animation_type',
field=models.IntegerField(choices=[(0, 'Tournoi'), (1, 'Mêlée'), (2, 'Classement'), (3, 'Consolation'), (4, 'Custom')], default=0),
),
]

@ -1,19 +0,0 @@
# Generated by Django 5.1 on 2025-09-24 14:20
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0138_remove_customuser_agents_customuser_supervisors_and_more'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='organizers',
field=models.ManyToManyField(blank=True, related_name='organising_for', to=settings.AUTH_USER_MODEL),
),
]

@ -1,18 +0,0 @@
# Generated by Django 5.1 on 2025-10-15 08:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0139_customuser_organizers'),
]
operations = [
migrations.AddField(
model_name='tournament',
name='custom_club_name',
field=models.CharField(blank=True, max_length=100, null=True),
),
]

@ -27,8 +27,6 @@ class Club(BaseModel):
broadcast_code = models.CharField(max_length=10, null=True, blank=True, unique=True)
admin_visible = models.BooleanField(default=False)
sharable = False
def delete_dependencies(self):
for court in self.courts.all():
# court.delete_dependencies()

@ -36,8 +36,7 @@ class CustomUser(AbstractUser):
loser_bracket_match_format_preference = models.IntegerField(default=enums.FederalMatchCategory.NINE_GAMES, choices=enums.FederalMatchCategory.choices, null=True, blank=True)
device_id = models.CharField(max_length=50, null=True, blank=True)
supervisors = models.ManyToManyField('CustomUser', blank=True, related_name='supervising_for')
organizers = models.ManyToManyField('CustomUser', blank=True, related_name='organising_for')
agents = models.ManyToManyField('CustomUser', blank=True, related_name='owners')
loser_bracket_mode = models.IntegerField(default=0)
origin = models.IntegerField(default=enums.UserOrigin.ADMIN, choices=enums.UserOrigin.choices, null=True, blank=True)
@ -65,7 +64,7 @@ class CustomUser(AbstractUser):
'summons_display_format', 'summons_display_entry_fee',
'summons_use_full_custom_message', 'match_formats_default_duration', 'bracket_match_format_preference',
'group_stage_match_format_preference', 'loser_bracket_match_format_preference', 'device_id', 'loser_bracket_mode',
'origin', 'supervisors', 'organizers', 'should_synchronize', 'user_role', 'registration_payment_mode',
'origin', 'agents', 'should_synchronize', 'user_role', 'registration_payment_mode',
'umpire_custom_mail', 'umpire_custom_contact', 'umpire_custom_phone', 'hide_umpire_mail', 'hide_umpire_phone',
'disable_ranking_federal_ruling']

@ -85,13 +85,9 @@ class PlayerRegistration(TournamentSubModel):
return "Anonyme"
name = self.name()
if (len(name) > 20 or forced) and self.first_name:
if self.last_name is None:
name = self.first_name
elif self.first_name and len(self.first_name) > 0:
name = f"{self.first_name[0]}. {self.last_name}"
if len(name) > 20:
if len(name) > 20 or forced:
name_parts = self.last_name.split(" ")
if len(name_parts) > 0 and self.first_name and len(self.first_name) > 0:
name = f"{self.first_name[0]}. {name_parts[0]}"
return name
@ -138,18 +134,17 @@ class PlayerRegistration(TournamentSubModel):
return None
tournament = self.team_registration.tournament
tournament_status_team_count = tournament.get_tournament_status_registration_count()
tournament_status_team_count = tournament.get_tournament_status_team_count()
# If custom animation type, replace header by "Inscriptions"
if tournament.is_team_tournament():
header = "Équipes"
else:
if tournament.is_custom_animation():
header = "Inscriptions"
else:
header = "Équipes"
if tournament.is_canceled():
return {
'header': header,
'position': tournament_status_team_count,
'is_team_tournament': tournament.is_team_tournament(),
'display_box': True,
'box_class': 'light-red',
'short_label': 'annulé'
@ -158,7 +153,6 @@ class PlayerRegistration(TournamentSubModel):
status = {
'header': header,
'position': tournament_status_team_count,
'is_team_tournament': True,
'display_box': True,
'box_class': 'gray',
'short_label': 'inscrit'
@ -230,13 +224,3 @@ class PlayerRegistration(TournamentSubModel):
return self.team_registration.tournament.entry_fee
else:
return 0
def player_contact(self):
if self.contact_email:
return self.contact_email
return self.email
def get_last_name(self):
if self.is_anonymous:
return "Anonyme"
return self.last_name

@ -13,15 +13,6 @@ class Round(TournamentSubModel):
group_stage_loser_bracket = models.BooleanField(default=False)
loser_bracket_mode = models.IntegerField(default=0)
# Debug flag - set to False to disable all debug prints
DEBUG_PREPARE_MATCH_GROUP = False
@staticmethod
def debug_print(*args, **kwargs):
"""Print debug messages only if DEBUG_PREPARE_MATCH_GROUP is True"""
if Round.DEBUG_PREPARE_MATCH_GROUP:
print(*args, **kwargs)
def delete_dependencies(self):
for round in self.children.all():
round.delete_dependencies()
@ -121,35 +112,16 @@ class Round(TournamentSubModel):
return True
def prepare_match_group(self, next_round, parent_round, loser_final, double_butterfly_mode, secondHalf):
Round.debug_print(f"\n[{self.name()}] === START prepare_match_group ===")
Round.debug_print(f"[{self.name()}] index={self.index}, nextRound={next_round.name() if next_round else None}, parentRound={parent_round.name() if parent_round else None}")
Round.debug_print(f"[{self.name()}] loserFinal={loser_final.name() if loser_final else None}, doubleButterfly={double_butterfly_mode}, secondHalf={secondHalf}")
short_names = double_butterfly_mode
if double_butterfly_mode and self.tournament.rounds.filter(parent=None).count() < 3:
short_names = False
Round.debug_print(f"[{self.name()}] Short names disabled (rounds < 3)")
matches = self.matches.filter(disabled=False).order_by('index')
Round.debug_print(f"[{self.name()}] Initial enabled matches: {len(matches)} - indices: {[m.index for m in matches]}")
if len(matches) == 0:
Round.debug_print(f"[{self.name()}] No matches, returning None")
return None
if next_round:
next_round_matches = next_round.matches.filter(disabled=False).order_by('index')
Round.debug_print(f"[{self.name()}] Next round matches: {len(next_round_matches)} - indices: {[m.index for m in next_round_matches]}")
else:
next_round_matches = []
Round.debug_print(f"[{self.name()}] No next round")
if len(matches) < len(next_round_matches):
Round.debug_print(f"[{self.name()}] FILTERING: matches({len(matches)}) < nextRoundMatches({len(next_round_matches)})")
all_matches = self.matches.order_by('index')
Round.debug_print(f"[{self.name()}] All matches (including disabled): {len(all_matches)} - indices: {[(m.index, m.disabled) for m in all_matches]}")
filtered_matches = []
# Process matches in pairs
@ -159,127 +131,52 @@ class Round(TournamentSubModel):
current_match = all_matches[i]
pair_match = all_matches[i+1] if i+1 < len(all_matches) else None
Round.debug_print(f"[{self.name()}] Pair {i//2}: current={current_match.index}(disabled={current_match.disabled}), pair={pair_match.index if pair_match else None}(disabled={pair_match.disabled if pair_match else None})")
# Only filter out the pair if both matches are disabled
if current_match.disabled and pair_match and pair_match.disabled:
Round.debug_print(f"[{self.name()}] Both disabled, checking next_round for index {current_match.index // 2}")
# Skip one of the matches in the pair
if next_round_matches.filter(index=current_match.index // 2).exists():
filtered_matches.append(current_match)
# filtered_matches.append(pair_match)
# Keeping two was bugging the bracket
Round.debug_print(f"[{self.name()}] Next round match exists, keeping one")
else:
Round.debug_print(f"[{self.name()}] No next round match, skipping both")
filtered_matches.append(pair_match)
pass
else:
# Keep the current match
if current_match.disabled == False:
filtered_matches.append(current_match)
Round.debug_print(f"[{self.name()}] Keeping current match {current_match.index}")
# If there's a pair match, keep it too
if pair_match and pair_match.disabled == False:
filtered_matches.append(pair_match)
Round.debug_print(f"[{self.name()}] Keeping pair match {pair_match.index}")
# Move to the next pair
i += 2
# Replace the matches list with our filtered list
matches = filtered_matches
Round.debug_print(f"[{self.name()}] After filtering: {len(matches)} matches - indices: {[m.index for m in matches]}")
if matches:
if len(matches) > 1 and double_butterfly_mode:
Round.debug_print(f"[{self.name()}] SPLITTING: doubleButterfly with {len(matches)} matches")
if len(matches) % 2 == 1:
Round.debug_print(f"[{self.name()}] ODD number of matches - using smart split logic")
# Calculate expected index range for this round
if self.index == 0:
# Final: only index 0
expected_indices = [0]
else:
# For round n: 2^n matches, starting at index (2^n - 1)
expected_count = 2 ** self.index
start_index = (2 ** self.index) - 1
expected_indices = list(range(start_index, start_index + expected_count))
Round.debug_print(f"[{self.name()}] Expected indices: {expected_indices}")
# Get actual match indices
actual_indices = [match.index for match in matches]
missing_indices = [idx for idx in expected_indices if idx not in actual_indices]
Round.debug_print(f"[{self.name()}] Actual indices: {actual_indices}")
Round.debug_print(f"[{self.name()}] Missing indices: {missing_indices}")
if missing_indices and len(expected_indices) > 1:
# Split the expected range in half
midpoint_index = len(expected_indices) // 2
first_half_expected = expected_indices[:midpoint_index]
second_half_expected = expected_indices[midpoint_index:]
Round.debug_print(f"[{self.name()}] Expected halves: first={first_half_expected}, second={second_half_expected}")
# Count actual matches in each theoretical half
first_half_actual = sum(1 for idx in actual_indices if idx in first_half_expected)
second_half_actual = sum(1 for idx in actual_indices if idx in second_half_expected)
Round.debug_print(f"[{self.name()}] Actual counts: first={first_half_actual}, second={second_half_actual}")
# Give more display space to the half with more actual matches
if first_half_actual > second_half_actual:
midpoint = (len(matches) + 1) // 2 # More to first half
Round.debug_print(f"[{self.name()}] First half has more: midpoint={midpoint}")
else:
midpoint = len(matches) // 2 # More to second half
Round.debug_print(f"[{self.name()}] Second half has more: midpoint={midpoint}")
else:
# No missing indices or only one expected match, split normally
midpoint = len(matches) // 2
Round.debug_print(f"[{self.name()}] No missing indices: midpoint={midpoint}")
else:
# Even number of matches: split evenly
midpoint = len(matches) // 2
Round.debug_print(f"[{self.name()}] EVEN number of matches: midpoint={midpoint}")
midpoint = int(len(matches) / 2)
first_half_matches = matches[:midpoint]
if secondHalf:
first_half_matches = matches[midpoint:]
Round.debug_print(f"[{self.name()}] Using SECOND half: {len(first_half_matches)} matches - indices: {[m.index for m in first_half_matches]}")
else:
Round.debug_print(f"[{self.name()}] Using FIRST half: {len(first_half_matches)} matches - indices: {[m.index for m in first_half_matches]}")
else:
Round.debug_print(f"[{self.name()}] NO SPLITTING: singleButterfly or single match")
first_half_matches = list(matches) # Convert QuerySet to a list
Round.debug_print(f"[{self.name()}] Using all {len(first_half_matches)} matches - indices: {[m.index for m in first_half_matches]}")
if self.index == 0 and loser_final:
loser_match = loser_final.matches.first()
if loser_match:
first_half_matches.append(loser_match)
Round.debug_print(f"[{self.name()}] Added loser final match: {loser_match.index}")
if first_half_matches:
name = self.plural_name()
if parent_round and first_half_matches[0].name is not None:
name = first_half_matches[0].name
Round.debug_print(f"[{self.name()}] Using custom name from first match: '{name}'")
else:
Round.debug_print(f"[{self.name()}] Using round name: '{name}'")
Round.debug_print(f"[{self.name()}] Creating match_group: name='{name}', roundId={self.id}, roundIndex={self.index}, shortNames={short_names}")
Round.debug_print(f"[{self.name()}] Final matches in group: {[m.index for m in first_half_matches]}")
match_group = self.tournament.create_match_group(
name=name,
matches=first_half_matches,
round_id=self.id,
round_index=self.index,
short_names=short_names
short_names=double_butterfly_mode
)
Round.debug_print(f"[{self.name()}] === END prepare_match_group - SUCCESS ===\n")
return match_group
Round.debug_print(f"[{self.name()}] === END prepare_match_group - NO MATCHES ===\n")
return None

@ -37,7 +37,6 @@ class TeamRegistration(TournamentSubModel):
final_ranking = models.IntegerField(null=True, blank=True)
points_earned = models.IntegerField(null=True, blank=True)
unique_random_index = models.IntegerField(default=0)
user_canceled_registration = False
def delete_dependencies(self):
for player_registration in self.player_registrations.all():
@ -109,7 +108,7 @@ class TeamRegistration(TournamentSubModel):
def formatted_team_names(self):
if self.name:
return self.name
names = [pr.get_last_name() for pr in self.players_sorted_by_rank][:2] # Take max first 2
names = [pr.last_name for pr in self.players_sorted_by_rank][:2] # Take max first 2
joined_names = " / ".join(names)
if joined_names:
return f"Paire {joined_names}"
@ -551,20 +550,3 @@ class TeamRegistration(TournamentSubModel):
# Check payment status for each player
payment_statuses = [player.get_player_registration_fee() for player in player_registrations]
return sum(payment_statuses)
def team_contact(self):
if self.user:
return self.user.email
else:
player_registrations = self.players_sorted_by_captain
if len(player_registrations) > 0:
return player_registrations[0].player_contact()
return None
def cancel_registration(self):
self.walk_out = True
self.user_canceled_registration = True
def user_did_cancel_registration(self):
return self.user_canceled_registration and self.walk_out

@ -96,9 +96,6 @@ class Tournament(BaseModel):
club_member_fee_deduction = models.FloatField(null=True, blank=True)
unregister_delta_in_hours = models.IntegerField(default=24)
currency_code = models.CharField(null=True, blank=True, max_length=3, default='EUR')
# parent = models.ForeignKey('self', blank=True, null=True, on_delete=models.SET_NULL, related_name='children')
# loser_index = models.IntegerField(default=0)
custom_club_name = models.CharField(null=True, blank=True, max_length=100)
def delete_dependencies(self):
for team_registration in self.team_registrations.all():
@ -207,13 +204,6 @@ class Tournament(BaseModel):
timezone = self.timezone()
return self.start_date.astimezone(timezone)
def local_end_date(self):
timezone = self.timezone()
if self.end_date:
return self.end_date.astimezone(timezone)
else:
return None
def local_start_date_formatted(self):
return formats.date_format(self.local_start_date(), format='l j F Y H:i').capitalize()
@ -235,7 +225,7 @@ class Tournament(BaseModel):
case AnimationType.CONSOLATION_BRACKET:
return "Consolante"
case AnimationType.CUSTOM:
return "Soirée"
return "Spécial"
case _:
return "Anim."
if self.federal_level_category == 1:
@ -305,19 +295,8 @@ class Tournament(BaseModel):
def get_tournament_status(self):
return self.get_online_registration_status().status_localized()
def is_team_tournament(self):
return self.minimum_player_per_team >= 2
def get_tournament_status_registration_count(self):
def get_tournament_status_team_count(self):
active_teams_count = self.team_registrations.filter(walk_out=False).count()
if self.is_team_tournament() is False:
# Count players instead of teams when minimum players per team is under 2
PlayerRegistration = apps.get_model('tournaments', 'PlayerRegistration')
active_players_count = PlayerRegistration.objects.filter(
team_registration__tournament=self,
team_registration__walk_out=False
).count()
return active_players_count
return min(active_teams_count, self.team_count)
def name_and_event(self):
@ -1246,9 +1225,6 @@ class Tournament(BaseModel):
# Entry fee
if self.entry_fee is not None and self.entry_fee > 0:
formatted_fee = currency_service.format_amount(self.entry_fee, self.currency_code)
if self.is_custom_animation():
options.append(f"{formatted_fee} par personne")
else:
options.append(f"Frais d'inscription: {formatted_fee} par joueur")
# Club member fee reduction
@ -1526,13 +1502,7 @@ class Tournament(BaseModel):
if is_woman is not None and self.federal_category == FederalCategory.WOMEN and is_woman is False:
reasons.append("Ce tournoi est réservé aux femmes")
if birth_year is None or birth_year == 'N/A':
return reasons if reasons else None
try:
tournament_start_year = self.season_year()
user_age = tournament_start_year - int(birth_year)
except (ValueError, TypeError):
if birth_year is None:
return reasons if reasons else None
tournament_start_year = self.season_year()
@ -1765,16 +1735,12 @@ class Tournament(BaseModel):
def umpire_mail(self):
if self.umpire_custom_mail is not None:
return self.umpire_custom_mail
if self.event and self.event.creator:
return self.event.creator.email
return None
def umpire_phone(self):
if self.umpire_custom_phone is not None:
return self.umpire_custom_phone
if self.event and self.event.creator:
return self.event.creator.phone
return None
def calculate_time_to_confirm(self, waiting_list_count):
"""
@ -1895,10 +1861,7 @@ class Tournament(BaseModel):
second=0,
microsecond=0
)
raw_deadline += timezone.timedelta(minutes=minutes_to_confirm)
print(f"Before hours: {before_hours}, After hours: {after_hours}")
print(f"Final deadline after adding confirmation time: {raw_deadline}")
tournament_start_date_minus_five = tournament_start_date - timedelta(minutes=5)
if raw_deadline >= tournament_start_date_minus_five:
@ -2177,7 +2140,7 @@ class Tournament(BaseModel):
for match in planned_matches:
# Convert to local time zone
local_date = match.local_planned_start_date()
local_date = timezone.localtime(match.planned_start_date)
day_key = local_date.date()
days.add(day_key)
@ -2202,7 +2165,7 @@ class Tournament(BaseModel):
# Group matches by hour
matches_by_hour = {}
for match in matches_by_day[selected_day]:
local_time = match.local_planned_start_date()
local_time = timezone.localtime(match.planned_start_date)
hour_key = local_time.strftime('%H:%M')
if hour_key not in matches_by_hour:
@ -2243,7 +2206,7 @@ class Tournament(BaseModel):
# Group matches by hour
matches_by_hour = {}
for match in matches_by_day[selected_day]:
local_time = match.local_planned_start_date()
local_time = timezone.localtime(match.planned_start_date)
hour_key = local_time.strftime('%H:%M')
if hour_key not in matches_by_hour:

@ -71,11 +71,11 @@ class TournamentEmailService:
return base_subject
@staticmethod
def send_registration_confirmation(request, tournament, team_registration, waiting_list_position, force_send=False):
def send_registration_confirmation(request, tournament, team_registration, waiting_list_position):
if waiting_list_position >= 0:
TournamentEmailService.notify_team(team_registration, tournament, TeamEmailType.WAITING_LIST, force_send)
TournamentEmailService.notify_team(team_registration, tournament, TeamEmailType.WAITING_LIST)
else:
TournamentEmailService.notify_team(team_registration, tournament, TeamEmailType.REGISTERED, force_send)
TournamentEmailService.notify_team(team_registration, tournament, TeamEmailType.REGISTERED)
@staticmethod
def _build_registration_confirmation_email_body(tournament, captain, tournament_details_str, other_player):
@ -515,9 +515,9 @@ class TournamentEmailService:
return f"\n\n{warning_text}{action_text}{account_info}"
@staticmethod
def notify(captain, other_player, tournament, message_type: TeamEmailType, force_send=False):
print("TournamentEmailService.notify", captain.player_contact(), captain.registered_online, tournament, message_type)
if not captain or (not captain.registered_online and not force_send) or not captain.player_contact():
def notify(captain, other_player, tournament, message_type: TeamEmailType):
print("TournamentEmailService.notify", captain.email, captain.registered_online, tournament, message_type)
if not captain or not captain.registered_online or not captain.email:
return
tournament_details_str = tournament.build_tournament_details_str()
@ -531,7 +531,7 @@ class TournamentEmailService:
topic = message_type.email_topic(tournament.federal_level_category, captain.time_to_confirm)
email_subject = TournamentEmailService.email_subject(tournament, topic)
TournamentEmailService._send_email(captain.player_contact(), email_subject, email_body)
TournamentEmailService._send_email(captain.email, email_subject, email_body)
@staticmethod
def _build_email_content(message_type, recipient, tournament, tournament_details_str, other_player, request=None, waiting_list_position=None):
@ -597,22 +597,22 @@ class TournamentEmailService:
)
email.content_subtype = "html"
email.send()
# print"TournamentEmailService._send_email", to, subject)
print("TournamentEmailService._send_email", to, subject)
@staticmethod
def notify_team(team, tournament, message_type: TeamEmailType, force_send=False):
def notify_team(team, tournament, message_type: TeamEmailType):
# Notify both players separately if there is no captain or the captain is unavailable
players = list(team.players_sorted_by_captain)
if len(players) == 2:
# print"TournamentEmailService.notify_team 2p", team)
print("TournamentEmailService.notify_team 2p", team)
first_player, second_player = players
TournamentEmailService.notify(first_player, second_player, tournament, message_type, force_send)
if first_player.player_contact() != second_player.player_contact():
TournamentEmailService.notify(second_player, first_player, tournament, message_type, force_send)
TournamentEmailService.notify(first_player, second_player, tournament, message_type)
if first_player.email != second_player.email:
TournamentEmailService.notify(second_player, first_player, tournament, message_type)
elif len(players) == 1:
# print"TournamentEmailService.notify_team 1p", team)
print("TournamentEmailService.notify_team 1p", team)
# If there's only one player, just send them the notification
TournamentEmailService.notify(players[0], None, tournament, message_type, force_send)
TournamentEmailService.notify(players[0], None, tournament, message_type)
@staticmethod
def notify_umpire(team, tournament, message_type):
@ -681,38 +681,12 @@ class TournamentEmailService:
# For unpaid teams, add payment instructions
formatted_fee = currency_service.format_amount(tournament.entry_fee, tournament.currency_code)
# print"team_registration.user", team_registration.user)
# Check if team has a user account attached
if team_registration.user:
# User has account - direct to login and pay
payment_info = [
"\n\n Paiement des frais d'inscription requis",
f"Les frais d'inscription de {formatted_fee} par joueur doivent être payés pour confirmer votre participation.",
"Vous pouvez effectuer le paiement en vous connectant à votre compte Padel Club.",
f"Lien pour payer: https://padelclub.app/tournament/{tournament.id}/info"
]
else:
# No user account - create payment link
from .payment_service import PaymentService
payment_link = PaymentService.create_payment_link(team_registration.id)
if payment_link:
payment_info = [
"\n\n Paiement des frais d'inscription requis",
f"Les frais d'inscription de {formatted_fee} par joueur doivent être payés pour confirmer votre participation.",
"Vous pouvez effectuer le paiement directement via ce lien sécurisé :",
f"💳 Payer maintenant: {payment_link}",
"\nAucun compte n'est requis pour effectuer le paiement."
]
else:
# Fallback if payment link creation fails
payment_info = [
"\n\n Paiement des frais d'inscription requis",
f"Les frais d'inscription de {formatted_fee} par joueur doivent être payés pour confirmer votre participation.",
"Veuillez contacter l'organisateur du tournoi pour effectuer le paiement.",
f"Informations du tournoi: https://padelclub.app/tournament/{tournament.id}/info"
]
return "\n".join(payment_info)
@ -747,13 +721,11 @@ class TournamentEmailService:
tournament_prefix_that = federal_level_category.localized_prefix_that()
processed_emails = set()
for player in player_registrations:
# Check both email and contact_email fields
player_email = player.player_contact()
if not player_email:
if not player.email or not player.registered_online:
continue
if player_email in processed_emails:
if player.email in processed_emails:
continue
processed_emails.add(player_email)
processed_emails.add(player.email)
tournament_details_str = tournament.build_tournament_details_str()
other_player = team_registration.get_other_player(player) if len(player_registrations) > 1 else None
@ -800,7 +772,7 @@ class TournamentEmailService:
email_body = "".join(body_parts)
email_subject = TournamentEmailService.email_subject(tournament, "Confirmation de paiement")
TournamentEmailService._send_email(player.player_contact(), email_subject, email_body)
TournamentEmailService._send_email(player.email, email_subject, email_body)
@staticmethod
def send_refund_confirmation(tournament, team_registration, refund_details):
@ -840,11 +812,11 @@ class TournamentEmailService:
tournament_prefix_that = federal_level_category.localized_prefix_that()
processed_emails = set()
for player in player_registrations:
if not player.player_contact() or not player.registered_online:
if not player.email or not player.registered_online:
continue
if player.player_contact() in processed_emails:
if player.email in processed_emails:
continue
processed_emails.add(player.player_contact())
processed_emails.add(player.email)
tournament_details_str = tournament.build_tournament_details_str()
other_player = team_registration.get_other_player(player) if len(player_registrations) > 1 else None
@ -884,4 +856,4 @@ class TournamentEmailService:
email_body = "".join(body_parts)
email_subject = TournamentEmailService.email_subject(tournament, "Confirmation de remboursement")
TournamentEmailService._send_email(player.player_contact(), email_subject, email_body)
TournamentEmailService._send_email(player.email, email_subject, email_body)

@ -4,10 +4,8 @@ from django.urls import reverse
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.db import transaction
import stripe
from datetime import datetime, timedelta
import traceback
from ..models import TeamRegistration, PlayerRegistration, Tournament
from ..models.player_registration import PlayerPaymentType
@ -78,8 +76,6 @@ class PaymentService:
if not team_registration:
print(f"[TOURNAMENT PAYMENT] Failed to create team registration")
raise Exception("Erreur lors de la création de la réservation")
if not customer_email:
customer_email = team_registration.team_contact()
team_registration_id = team_registration.id
print(f"[TOURNAMENT PAYMENT] Created team registration: {team_registration_id}")
@ -546,16 +542,12 @@ class PaymentService:
team_registration_id = metadata.get('team_registration_id')
registration_type = metadata.get('registration_type')
# Wrap all database operations in an atomic transaction
# This ensures either all changes are saved or none are
try:
with transaction.atomic():
if tournament_id and registration_type == 'cart':
try:
tournament = Tournament.objects.get(id=tournament_id)
tournament.reserved_spots = max(0, tournament.reserved_spots - 1)
tournament.save()
print(f"Decreased reserved spots for tournament {tournament_id}")
print(f"Decreased reserved spots for tournament {tournament_id} after payment failure")
except Tournament.DoesNotExist:
print(f"Tournament not found with ID: {tournament_id}")
except Exception as e:
@ -565,9 +557,9 @@ class PaymentService:
print("No team registration ID found in session")
return False
try:
print(f"Looking for team registration with ID: {team_registration_id}")
team_registration = TeamRegistration.objects.get(id=team_registration_id)
if tournament_id and registration_type == 'cart' and team_registration.tournament is None:
try:
tournament = Tournament.objects.get(id=tournament_id)
@ -580,110 +572,38 @@ class PaymentService:
print(f"Error saving tournament for team registration: {str(e)}")
if team_registration.is_paid():
print(f"Team registration {team_registration.id} is already paid")
return True
# Update player registration with payment info
team_registration.confirm_registration(checkout_session.payment_intent)
print(f"✅ Registration confirmed and committed to database")
TournamentEmailService.send_payment_confirmation(team_registration, checkout_session.payment_intent)
return True
except TeamRegistration.DoesNotExist:
print(f"Team registration not found with ID: {team_registration_id}")
return False
except Exception as e:
print(f"❌ Error in process_direct_payment database operations: {str(e)}")
traceback.print_exc()
print(f"Error in _process_direct_payment: {str(e)}")
return False
# After successful database commit, send confirmation email
# Email failures won't affect the payment confirmation
try:
print(f"Sending payment confirmation email...")
TournamentEmailService.send_payment_confirmation(team_registration, checkout_session.payment_intent)
print(f"✅ Email sent successfully")
except Exception as email_error:
print(f" Warning: Email sending failed but payment was confirmed: {str(email_error)}")
traceback.print_exc()
# Don't return False - payment is still confirmed
return True
@staticmethod
@csrf_exempt
@require_POST
def stripe_webhook(request):
payload = request.body
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')
# Check if this is a Connect account webhook (header method - for direct API calls)
stripe_account_header = request.META.get('HTTP_STRIPE_ACCOUNT')
print("=== WEBHOOK DEBUG ===")
print("Received webhook call")
print(f"Signature: {sig_header}")
print(f"Connect Account Header: {stripe_account_header}")
# First, try to construct the event with any available webhook secret to inspect the payload
webhook_secrets = []
if hasattr(settings, 'XLR_STRIPE_WEBHOOK_SECRET') and settings.XLR_STRIPE_WEBHOOK_SECRET:
webhook_secrets.append(('XLR', settings.XLR_STRIPE_WEBHOOK_SECRET))
if hasattr(settings, 'TOURNAMENT_STRIPE_WEBHOOK_SECRET') and settings.TOURNAMENT_STRIPE_WEBHOOK_SECRET:
webhook_secrets.append(('TOURNAMENT', settings.TOURNAMENT_STRIPE_WEBHOOK_SECRET))
if hasattr(settings, 'SHOP_STRIPE_WEBHOOK_SECRET') and settings.SHOP_STRIPE_WEBHOOK_SECRET:
webhook_secrets.append(('SHOP', settings.SHOP_STRIPE_WEBHOOK_SECRET))
print(f"Available webhook secrets: {[name for name, _ in webhook_secrets]}")
event = None
used_secret = None
# Try to verify with each secret to get the event payload
for secret_name, secret_value in webhook_secrets:
try:
print(f"Trying {secret_name} webhook secret...")
event = stripe.Webhook.construct_event(payload, sig_header, secret_value)
used_secret = secret_name
print(f"SUCCESS: Webhook verified with {secret_name} secret")
break
except stripe.error.SignatureVerificationError as e:
print(f"Failed with {secret_name} secret: {str(e)}")
continue
if not event:
print("ERROR: No webhook secret worked")
return HttpResponse("Webhook signature verification failed", status=400)
# Now check if this is a Connect webhook by looking at the payload
connect_account_id = event.get('account') # This is how Connect webhooks are identified
print(f"Connect Account ID from payload: {connect_account_id}")
# Log webhook details
print(f"Event ID: {event.get('id')}")
print(f"Event Type: {event.get('type')}")
print(f"Live Mode: {event.get('livemode')}")
# Determine if this should have used a different webhook secret based on the account
if connect_account_id:
print(f"This is a Connect webhook from account: {connect_account_id}")
# Check if the account matches the expected tournament account
if connect_account_id == "acct_1S0jbSAs9xuFLROy":
print("This matches the expected tournament Connect account")
if used_secret != 'TOURNAMENT':
print(f"WARNING: Used {used_secret} secret but should probably use TOURNAMENT secret")
else:
print(f"Unknown Connect account: {connect_account_id}")
else:
print("This is a platform/direct webhook (no Connect account)")
if used_secret != 'XLR':
print(f"WARNING: Used {used_secret} secret but should probably use XLR secret")
event = stripe.Webhook.construct_event(
payload, sig_header, settings.TOURNAMENT_STRIPE_WEBHOOK_SECRET
)
print(f"Tournament webhook event type: {event['type']}")
try:
# Process the webhook event
stripe_object = event['data']['object']
metadata = stripe_object.get('metadata', {})
print(f"is_corporate_tournament: {metadata.get('is_corporate_tournament', 'unknown')}")
print(f"payment_source: {metadata.get('payment_source', 'unknown')}")
print(f"stripe_account_type: {metadata.get('stripe_account_type', 'unknown')}")
print(f"stripe_account_id: {metadata.get('stripe_account_id', 'unknown')}")
# Debug: Print the object type
object_type = stripe_object.get('object', 'unknown')
print(f"Stripe object type: {object_type}")
if event['type'] == 'checkout.session.completed':
success = PaymentService.process_direct_payment(stripe_object)
@ -694,133 +614,30 @@ class PaymentService:
print(f"Failed to process completed checkout session")
return HttpResponse(status=400)
# Handle other event types if needed
elif event['type'] == 'payment_intent.succeeded':
print(f"Payment intent succeeded - you might want to handle this")
elif event['type'] == 'payment_intent.payment_failed':
success = PaymentService.process_failed_payment_intent(stripe_object)
if success:
print(f"Successfully processed failed payment intent")
return HttpResponse(status=200)
else:
print(f"Unhandled event type: {event['type']}")
return HttpResponse(status=200)
except Exception as e:
print(f"Error processing webhook: {str(e)}")
import traceback
traceback.print_exc()
return HttpResponse("Webhook processing failed", status=500)
@staticmethod
def create_payment_link(team_registration_id):
"""
Create a Stripe Payment Link for a team registration
Returns the payment link URL or None if failed
"""
try:
team_registration = TeamRegistration.objects.get(id=team_registration_id)
tournament = team_registration.tournament
if not tournament or tournament.is_free():
return None
stripe.api_key = settings.STRIPE_SECRET_KEY
currency_service = CurrencyService()
# Calculate the team fee
team_fee = team_registration.get_team_registration_fee()
stripe_amount = currency_service.convert_to_stripe_amount(team_fee, tournament.currency_code)
customer_email = team_registration.team_contact()
currency_code = tournament.currency_code or 'EUR'
print(f"[PAYMENT LINK] Tournament: {tournament.display_name()}")
print(f"[PAYMENT LINK] is_corporate_tournament: {tournament.is_corporate_tournament}")
# Base metadata (same as checkout session)
base_metadata = {
'tournament_id': str(tournament.id),
'team_registration_id': str(team_registration.id),
'customer_email': customer_email or 'Non fourni',
'payment_source': 'payment_link',
'registration_type': 'direct',
'currency_code': currency_code,
}
# Create payment link params
payment_link_params = {
'line_items': [{
'price_data': {
'currency': currency_code.lower(),
'product_data': {
'name': f'{tournament.broadcast_display_name()} du {tournament.formatted_start_date()}',
'description': f'Lieu {tournament.event.club.name}',
},
'unit_amount': stripe_amount,
},
'quantity': 1,
}],
'after_completion': {
'type': 'redirect',
'redirect': {
'url': f'https://padelclub.app/stripe/payment_complete/?tournament_id={tournament.id}&team_registration_id={team_registration.id}&payment=success'
}
},
'automatic_tax': {'enabled': False},
'billing_address_collection': 'auto',
}
# Handle corporate vs regular tournaments (same logic as checkout session)
if tournament.is_corporate_tournament:
print(f"[PAYMENT LINK] Corporate tournament - creating on platform account")
# Corporate tournament - create on platform account (no Connect account)
metadata = {
**base_metadata,
'is_corporate_tournament': 'true',
'stripe_account_type': 'direct'
}
payment_link_params['metadata'] = metadata
# Create payment link on platform account
payment_link = stripe.PaymentLink.create(**payment_link_params)
print(f"Failed to process failed payment intent")
return HttpResponse(status=400)
elif event['type'] == 'checkout.session.expired':
success = PaymentService.process_expired_checkout_session(stripe_object)
if success:
print(f"Successfully processed expired checkout session")
return HttpResponse(status=200)
else:
print(f"[PAYMENT LINK] Regular tournament - creating on connected account")
# Regular tournament - create on connected account
stripe_account_id = tournament.stripe_account_id
if not stripe_account_id:
print(f"[PAYMENT LINK] ERROR: No Stripe account ID for umpire")
return None
metadata = {
**base_metadata,
'is_corporate_tournament': 'false',
'stripe_account_type': 'connect',
'stripe_account_id': stripe_account_id
}
payment_link_params['metadata'] = metadata
print(f"[PAYMENT LINK] Creating payment link for connected account: {stripe_account_id}")
# Create payment link on connected account
payment_link = stripe.PaymentLink.create(
**payment_link_params,
stripe_account=stripe_account_id
)
print(f"Failed to process expired checkout session")
return HttpResponse(status=400)
print(f"[PAYMENT LINK] Created payment link: {payment_link.url}")
return payment_link.url
else:
print(f"Unhandled event type: {event['type']}")
return HttpResponse(status=200)
except Exception as e:
print(f"[PAYMENT LINK] Error creating payment link: {str(e)}")
print(f"Tournament webhook error: {str(e)}")
import traceback
traceback.print_exc()
return None
@staticmethod
def get_or_create_payment_link(team_registration_id):
"""
Get existing payment link or create a new one for a team registration
This method can be used to avoid creating multiple links for the same registration
"""
# In a real implementation, you might want to store payment links in the database
# and check if one already exists and is still valid
return PaymentService.create_payment_link(team_registration_id)
return HttpResponse(status=400)

@ -25,32 +25,6 @@ class RegistrationCartManager:
self.session = request.session
self.first_tournament = False
def _clean_expired_cart(self):
"""Clean up expired cart data from session"""
if 'registration_cart_expiry' in self.session:
try:
expiry_str = self.session['registration_cart_expiry']
expiry = datetime.datetime.fromisoformat(expiry_str)
if timezone.now() > expiry:
# Clear expired cart
keys_to_delete = [
'registration_cart_id',
'registration_tournament_id',
'registration_cart_players',
'registration_cart_expiry',
'registration_mobile_number',
'registration_email'
]
for key in keys_to_delete:
if key in self.session:
del self.session[key]
self.session.modified = True
except (ValueError, TypeError):
# Invalid expiry format, clear it
if 'registration_cart_expiry' in self.session:
del self.session['registration_cart_expiry']
self.session.modified = True
def get_or_create_cart_id(self):
"""Get or create a registration cart ID in the session"""
if 'registration_cart_id' not in self.session:
@ -76,11 +50,9 @@ class RegistrationCartManager:
try:
expiry = parse_datetime(expiry_str)
if expiry is None:
self._clean_expired_cart()
return True
return timezone.now() > expiry
except (ValueError, TypeError):
self._clean_expired_cart()
return True
def reset_cart_expiry(self):
@ -133,12 +105,9 @@ class RegistrationCartManager:
# Get user phone if authenticated
user_phone = ''
user_email = ''
if hasattr(self.request.user, 'phone'):
user_phone = self.request.user.phone
if hasattr(self.request.user, 'email'):
user_email = self.request.user.email
# Parse the expiry time from ISO format to datetime
expiry_str = self.get_cart_expiry()
expiry_datetime = None
@ -159,13 +128,12 @@ class RegistrationCartManager:
'is_cart_expired': self.is_cart_expired(),
'team_fee_from_cart_players': self.team_fee_from_cart_players(),
'team_fee_from_cart_players_formatted': self.team_fee_from_cart_players_formatted(),
'mobile_number': self.session.get('registration_mobile_number', user_phone),
'email': self.session.get('registration_email', user_email),
'mobile_number': self.session.get('registration_mobile_number', user_phone)
}
# Debug: print the cart content
# print(f"Cart data - Tournament ID: {cart_data['tournament_id']}")
# print(f"Cart data - Players count: {len(cart_data['players'])}")
print(f"Cart data - Tournament ID: {cart_data['tournament_id']}")
print(f"Cart data - Players count: {len(cart_data['players'])}")
return cart_data
@ -178,10 +146,11 @@ class RegistrationCartManager:
except Tournament.DoesNotExist:
return 0
players = self.session.get('registration_cart_players', [])
players = self.session.get('registration_cart_players', []),
entry_fee = tournament.entry_fee
if entry_fee is not None and entry_fee > 0 and tournament.enable_online_payment:
fee = entry_fee * len(players)
fee = entry_fee * tournament.minimum_player_per_team
players = self.session.get('registration_cart_players', [])
club_members = sum(1 for player in players if player.get('club_member', False))
if tournament.club_member_fee_deduction is not None:
return fee - club_members * tournament.club_member_fee_deduction
@ -204,7 +173,7 @@ class RegistrationCartManager:
def add_player(self, player_data):
"""Add a player to the registration cart"""
# print("add_player", player_data)
print("add_player", player_data)
if self.is_cart_expired():
return False, "Votre session d'inscription a expiré, veuillez réessayer."
@ -248,8 +217,8 @@ class RegistrationCartManager:
if tournament_federal_category == FederalCategory.MIXED and len(players) == 1:
other_player_is_woman = players[0].get('is_woman', False)
if players[0].get('found_in_french_federation', False):
is_woman = not other_player_is_woman
if other_player_is_woman == is_woman:
is_woman = not is_woman
player_data.update({
'rank': fed_data['rank'],
@ -260,7 +229,6 @@ class RegistrationCartManager:
if tournament_federal_category == FederalCategory.MIXED and len(players) == 1:
is_woman = fed_data.get('is_woman', False)
other_player_is_woman = players[0].get('is_woman', False)
if players[0].get('found_in_french_federation', False):
if other_player_is_woman == is_woman:
return False, f"En mixte l'équipe doit obligatoirement contenir une joueuse et un joueur. La licence {licence_id} correspond à {'une' if is_woman else 'un'} {'femme' if is_woman else 'homme'}."
@ -286,7 +254,7 @@ class RegistrationCartManager:
})
elif not first_name or not last_name:
# License not required or not found, but name is needed
# print("Le prénom et le nom sont obligatoires pour les joueurs dont la licence n'a pas été trouvée.")
print("Le prénom et le nom sont obligatoires pour les joueurs dont la licence n'a pas été trouvée.")
self.first_tournament = True
return False, "Le prénom et le nom sont obligatoires pour les joueurs dont la licence n'a pas été trouvée."
elif not tournament.license_is_required:
@ -376,13 +344,11 @@ class RegistrationCartManager:
return True, "Joueur retiré."
def update_contact_info(self, email=None, mobile_number=None):
def update_contact_info(self, mobile_number=None):
"""Update contact info for the cart"""
if self.is_cart_expired():
return False, "Votre session d'inscription a expiré, veuillez réessayer."
if email is not None:
self.session['registration_email'] = email
if mobile_number is not None:
self.session['registration_mobile_number'] = mobile_number
@ -393,7 +359,7 @@ class RegistrationCartManager:
def checkout(self, confirmed):
"""Convert cart to an actual tournament registration"""
# print("Checkout")
print("Checkout")
if self.is_cart_expired():
return False, "Votre session d'inscription a expiré, veuillez réessayer."
@ -402,7 +368,6 @@ class RegistrationCartManager:
tournament_id = cart_data.get('tournament_id')
players = cart_data.get('players')
mobile_number = cart_data.get('mobile_number')
email = cart_data.get('email')
# Validate cart data
if not tournament_id:
@ -444,7 +409,7 @@ class RegistrationCartManager:
registration_date=timezone.now(),
walk_out=False,
weight=weight,
user= self.request.user if self.request.user.is_authenticated else None
user=self.request.user
)
for player_data in players: # Compute rank and sex using the original logic
@ -520,8 +485,8 @@ class RegistrationCartManager:
rank=player_data.get('rank'),
computed_rank=player_data.get('computed_rank'),
licence_id=player_data.get('licence_id'),
contact_email=matching_user.email if matching_user else player_data.get('email', email),
contact_phone_number=matching_user.phone if matching_user else player_data.get('mobile_number', mobile_number),
email=matching_user.email if matching_user else player_data.get('email'),
phone_number=matching_user.phone if matching_user else player_data.get('mobile_number'),
registration_status=RegistrationStatus.CONFIRMED if self.session.get('waiting_list_position', 0) < 0 else RegistrationStatus.WAITING
)
@ -542,8 +507,7 @@ class RegistrationCartManager:
'registration_tournament_id',
'registration_cart_players',
'registration_cart_expiry',
'registration_mobile_number',
'registration_email',
'registration_mobile_number'
]
for key in keys_to_clear:
@ -610,6 +574,6 @@ class RegistrationCartManager:
assimilation_addition = FederalCategory.female_in_male_assimilation_addition(rank_int, tournament.season_year())
computed_rank = computed_rank + assimilation_addition
# print(f"_compute_rank_and_sex: {player_data.get('last_name')}, {sex}, {rank}, {computed_rank}")
print(f"_compute_rank_and_sex: {player_data.get('last_name')}, {sex}, {rank}, {computed_rank}")
return sex, rank, str(computed_rank)

@ -52,17 +52,17 @@ class TournamentUnregistrationService:
def _team_has_paid(self):
"""Check if team has paid for registration"""
if not self.team_registration:
# print("Team registration not found")
print("Team registration not found")
return False
# Check if any player registration has a payment ID
player_registrations = PlayerRegistration.objects.filter(team_registration=self.team_registration)
for player_reg in player_registrations:
if player_reg.payment_id and player_reg.payment_type == PlayerPaymentType.CREDIT_CARD:
# print("Player has paid")
print("Player has paid")
return True
# print("No player has paid")
print("No player has paid")
return False
def _process_refund(self):
@ -110,9 +110,7 @@ class TournamentUnregistrationService:
def _delete_registered_team(self):
team_registration = self.player_registration.team_registration
# team_registration.delete()
team_registration.cancel_registration()
team_registration.save()
team_registration.delete()
def _cleanup_session(self):
self.request.session['team_registration'] = []

@ -77,13 +77,6 @@ def unregister_team(sender, instance, **kwargs):
notify_team(instance, instance.tournament, TeamEmailType.UNREGISTERED)
teams = instance.tournament.teams(True)
# Check if the team being deleted is in the waiting list
team_being_deleted = next((team for team in teams if team.team_registration.id == instance.id), None)
is_team_in_waiting_list = team_being_deleted and team_being_deleted.team_registration.out_of_tournament() == True
# Only notify the first waiting list team if the deleted team is NOT in the waiting list
if not is_team_in_waiting_list:
first_waiting_list_team = instance.tournament.first_waiting_list_team(teams)
if first_waiting_list_team and first_waiting_list_team.id != instance.id:
if instance.tournament.automatic_waiting_list():
@ -161,7 +154,7 @@ def warn_team_walkout_status_change(sender, instance, **kwargs):
try:
previous_instance = TeamRegistration.objects.get(id=instance.id)
except TeamRegistration.DoesNotExist:
print("warn_team_walkout > TeamRegistration.DoesNotExist")
print("TeamRegistration.DoesNotExist")
return
ttc = None
@ -190,10 +183,6 @@ def warn_team_walkout_status_change(sender, instance, **kwargs):
notify_team(instance, instance.tournament, TeamEmailType.OUT_OF_WALKOUT_IS_IN)
elif not previous_instance.out_of_tournament() and instance.out_of_tournament():
instance.cancel_time_to_confirm()
print("User did cancel registration", instance.user_did_cancel_registration())
if instance.user_did_cancel_registration():
notify_team(instance, instance.tournament, TeamEmailType.UNREGISTERED)
else:
notify_team(instance, instance.tournament, TeamEmailType.WALKOUT)
if was_out and not is_out:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -61,23 +61,6 @@
justify-content: center;
}
.match-time-indication {
position: absolute;
color: #fff;
left: 50%; /* Center horizontally */
transform: translateX(-50%); /* Center it exactly */
text-align: center;
font-weight: bold;
width: 100%; /* Change from 100% to auto */
padding: 0px 0px;
white-space: nowrap; /* Prevent text from wrapping */
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.round-title.broadcast-mode {
font-size: 0.8em;
width: auto; /* Change from 100% to auto */

@ -1,12 +1,3 @@
// Debug flag - set to false to disable all debug console logs
const DEBUG_BRACKET = false;
function debug_console(...args) {
if (DEBUG_BRACKET) {
console.log(...args);
}
}
function renderBracket(options) {
const bracket = document.getElementById("bracket");
const matchTemplates = document.getElementById("match-templates").children;
@ -17,12 +8,6 @@ function renderBracket(options) {
const displayLoserFinal = options.displayLoserFinal;
const tournamentId = options.tournamentId;
const isBroadcast = options.isBroadcast;
debug_console("=== RENDER BRACKET START ===");
debug_console(
`Options: doubleButterflyMode=${doubleButterflyMode}, displayLoserFinal=${displayLoserFinal}, isBroadcast=${isBroadcast}`,
);
// Group matches by round
Array.from(matchTemplates).forEach((template) => {
const roundIndex = parseInt(template.dataset.matchRound);
@ -49,10 +34,6 @@ function renderBracket(options) {
let nextMatchDistance = baseDistance;
let minimumMatchDistance = 1;
debug_console(
`Dimensions: matchHeight=${matchHeight}, baseDistance=${baseDistance}, roundCount=${roundCount}, finalRoundIndex=${finalRoundIndex}`,
);
const screenWidth = window.innerWidth;
let roundTotalCount = roundCount;
let initialPadding = 40;
@ -60,7 +41,7 @@ function renderBracket(options) {
roundTotalCount = roundCount - 1;
initialPadding = 46;
}
const padding = initialPadding * roundTotalCount;
const padding = initialPadding * roundTotalCount; // Account for some padding/margin
const availableWidth = screenWidth - padding;
let responsiveMatchWidth = Math.min(
365,
@ -101,10 +82,6 @@ function renderBracket(options) {
}
}
debug_console(
`Layout: responsiveMatchWidth=${responsiveMatchWidth}, topMargin=${topMargin}`,
);
rounds.forEach((roundMatches, roundIndex) => {
if (rounds[0].length <= 2 && doubleButterflyMode) {
minimumMatchDistance = 2;
@ -131,13 +108,8 @@ function renderBracket(options) {
const firstMatchTemplate = roundMatches[0].closest(".match-template");
const matchGroupName = firstMatchTemplate.dataset.matchGroupName;
const matchFormat = firstMatchTemplate.dataset.matchFormat;
const roundId = firstMatchTemplate.dataset.roundId;
const realRoundIndex = firstMatchTemplate.dataset.roundIndex;
debug_console(`\n=== ROUND ${roundIndex} (${matchGroupName}) ===`);
debug_console(
`realRoundIndex=${realRoundIndex}, matches=${roundMatches.length}`,
);
const roundId = firstMatchTemplate.dataset.roundId; // Add this line
const realRoundIndex = firstMatchTemplate.dataset.roundIndex; // Add this line
let nameSpan = document.createElement("div");
nameSpan.className = "round-name";
@ -173,16 +145,10 @@ function renderBracket(options) {
if (matchPositions[roundIndex] == undefined) {
matchPositions[roundIndex] = {};
}
matchDisabled[roundIndex] = [];
matchDisabled[roundIndex] = []; // Initialize array for this round
roundMatches.forEach((matchTemplate, matchIndex) => {
const matchTitle = matchTemplate.dataset.matchTitle;
const matchRealIndex = matchTemplate.dataset.matchRealIndex;
debug_console(
`\n[${matchTitle}] START - roundIndex:${roundIndex}, matchIndex:${matchIndex}, realIndex:${matchRealIndex}`,
);
const matchDiv = document.createElement("div");
matchDiv.className = "butterfly-match";
@ -193,11 +159,7 @@ function renderBracket(options) {
let isOutgoingLineIsDisabled = isDisabled;
let top;
const currentMatchesCount = roundMatches.length;
if (roundIndex > finalRoundIndex) {
debug_console(
`[${matchTitle}] CASE: Reverse bracket (roundIndex > finalRoundIndex)`,
);
matchDiv.classList.add("reverse-bracket");
if (roundIndex <= finalRoundIndex + 2) {
@ -205,29 +167,18 @@ function renderBracket(options) {
matchPositions[roundCount - roundIndex - 1],
);
top = values[matchIndex];
debug_console(
`[${matchTitle}] Reverse pos from mirror: top=${top}, mirrorRound=${roundCount - roundIndex - 1}`,
);
} else {
top = matchPositions[roundIndex][matchRealIndex];
debug_console(`[${matchTitle}] Reverse pos direct: top=${top}`);
console.log(matchTitle, top);
}
}
if (roundIndex === 0) {
debug_console(`[${matchTitle}] CASE: First round (roundIndex === 0)`);
if (doubleButterflyMode == false) {
nextMatchDistance = 0;
debug_console(
`[${matchTitle}] Single butterfly: nextMatchDistance=0`,
);
} else {
if (realRoundIndex > 1) {
nextMatchDistance = 0;
debug_console(
`[${matchTitle}] Double butterfly realRound>1: nextMatchDistance=0`,
);
}
}
if (roundCount > 1) {
@ -235,85 +186,53 @@ function renderBracket(options) {
if (currentMatchesCount == nextMatchesCount && roundCount > 2) {
nextMatchDistance = 0;
debug_console(
`[${matchTitle}] Same match count: nextMatchDistance=0`,
);
}
}
top = matchIndex * (matchHeight + matchSpacing) * minimumMatchDistance;
debug_console(
`[${matchTitle}] Calc: top=${top} (matchIdx=${matchIndex}, spacing=${matchHeight + matchSpacing}, minDist=${minimumMatchDistance})`,
);
if (roundCount == 3 && doubleButterflyMode) {
top = top + (matchHeight + matchSpacing) / 2;
debug_console(`[${matchTitle}] 3-round adjustment: top=${top}`);
}
} else if (roundIndex === roundCount - 1 && doubleButterflyMode == true) {
debug_console(`[${matchTitle}] CASE: Last round double butterfly`);
if (roundCount > 3) {
nextMatchDistance = 0;
debug_console(`[${matchTitle}] Large bracket: nextMatchDistance=0`);
} else {
nextMatchDistance = nextMatchDistance / 2;
debug_console(
`[${matchTitle}] Small bracket: nextMatchDistance=${nextMatchDistance}`,
);
}
} else if (roundIndex == finalRoundIndex && realRoundIndex == 0) {
debug_console(`[${matchTitle}] CASE: Final round (realRoundIndex=0)`);
//realRoundIndex 0 means final's round
const values = Object.values(matchPositions[roundIndex - 1]);
const parentPos1 = values[0];
const parentPos2 = values[1];
debug_console(
`[${matchTitle}] Parent positions: pos1=${parentPos1}, pos2=${parentPos2}`,
);
if (doubleButterflyMode == true) {
debug_console(`[${matchTitle}] Double butterfly final`);
let lgth = matchPositions[0].length / 2;
let index = lgth + matchIndex - 1;
// If index goes negative, use 0 instead
if (displayLoserFinal == true) {
debug_console(`[${matchTitle}] With loser final`);
if (matchIndex == 0) {
top = parentPos1 - baseDistance / 2;
debug_console(`[${matchTitle}] Winner final: top=${top}`);
} else {
top = parentPos1 + baseDistance / 2;
debug_console(`[${matchTitle}] Loser final: top=${top}`);
}
nextMatchDistance = 0;
} else {
top = parentPos1;
nextMatchDistance = 0;
debug_console(`[${matchTitle}] Single final: top=${top}`);
}
} else {
debug_console(`[${matchTitle}] Single butterfly final`);
top = (parentPos1 + parentPos2) / 2;
debug_console(`[${matchTitle}] Center between parents: top=${top}`);
if (matchIndex == 0) {
nextMatchDistance = parentPos2 - parentPos1;
debug_console(
`[${matchTitle}] First final match: nextMatchDistance=${nextMatchDistance}`,
);
} else {
nextMatchDistance = 0;
debug_console(
`[${matchTitle}] Second+ final match: nextMatchDistance=0`,
);
}
if (displayLoserFinal == true) {
if (matchIndex == 1) {
top = matchPositions[roundIndex][0] + baseDistance + 80;
isIncomingLineIsDisabled = true;
debug_console(`[${matchTitle}] Loser final offset: top=${top}`);
}
}
}
@ -321,72 +240,39 @@ function renderBracket(options) {
(roundIndex == finalRoundIndex && realRoundIndex != 0) ||
roundIndex < finalRoundIndex
) {
debug_console(`[${matchTitle}] CASE: Intermediate round`);
const parentIndex1 = matchRealIndex * 2 + 1;
const parentIndex2 = matchRealIndex * 2 + 2;
const parentPos1 = matchPositions[roundIndex - 1][parentIndex1];
const parentPos2 = matchPositions[roundIndex - 1][parentIndex2];
const parent1Disable = matchDisabled[roundIndex - 1][parentIndex1];
const parent2Disable = matchDisabled[roundIndex - 1][parentIndex2];
debug_console(
`[${matchTitle}] Parents: idx1=${parentIndex1}(pos=${parentPos1}, disabled=${parent1Disable}), idx2=${parentIndex2}(pos=${parentPos2}, disabled=${parent2Disable})`,
);
if (
(parent1Disable == undefined || parent1Disable == true) &&
(parent2Disable == undefined || parent2Disable == true)
) {
isIncomingLineIsDisabled = true;
debug_console(
`[${matchTitle}] Both parents disabled, incoming line disabled`,
);
}
if (
matchPositions[roundIndex - 1][parentIndex1] != undefined &&
matchPositions[roundIndex - 1][parentIndex2] != undefined
) {
debug_console(`[${matchTitle}] Both parents exist`);
top = (parentPos1 + parentPos2) / 2;
if (parent1Disable && parent2Disable) {
nextMatchDistance = 0;
const keys = Object.keys(matchPositions[roundIndex]).map(Number);
const lastKey = Math.max(...keys);
top =
(matchHeight + matchSpacing) * minimumMatchDistance * keys.length;
debug_console(
`[${matchTitle}] Both disabled: top=${top}, nextMatchDistance=0`,
);
} else {
nextMatchDistance = parentPos2 - parentPos1;
debug_console(
`[${matchTitle}] Center calc: top=${top}, nextMatchDistance=${nextMatchDistance}`,
);
}
} else if (matchPositions[roundIndex - 1][parentIndex1] != undefined) {
debug_console(`[${matchTitle}] Only parent1 exists`);
nextMatchDistance = 0;
top = matchPositions[roundIndex - 1][parentIndex1];
debug_console(`[${matchTitle}] Use parent1: top=${top}`);
} else if (matchPositions[roundIndex - 1][parentIndex2] != undefined) {
debug_console(`[${matchTitle}] Only parent2 exists`);
nextMatchDistance = 0;
top = matchPositions[roundIndex - 1][parentIndex2];
debug_console(`[${matchTitle}] Use parent2: top=${top}`);
} else {
debug_console(`[${matchTitle}] No parents exist`);
nextMatchDistance = 0;
top = 0;
debug_console(`[${matchTitle}] Default: top=0`);
}
} else if (roundIndex < roundCount) {
debug_console(
`[${matchTitle}] CASE: Setting future positions (roundIndex < roundCount)`,
);
const parentIndex1 = matchRealIndex * 2 + 1;
const parentIndex2 = matchRealIndex * 2 + 2;
const parentMatch1 = rounds[roundIndex + 1].find(
@ -396,64 +282,47 @@ function renderBracket(options) {
(match) => parseInt(match.dataset.matchRealIndex) === parentIndex2,
);
debug_console(
`[${matchTitle}] Looking for children: idx1=${parentIndex1}, idx2=${parentIndex2}`,
);
debug_console(
`[${matchTitle}] Found: match1=${parentMatch1?.dataset.matchTitle || "none"}, match2=${parentMatch2?.dataset.matchTitle || "none"}`,
);
if (matchPositions[roundIndex + 1] == undefined) {
matchPositions[roundIndex + 1] = {};
}
if (
parentMatch1 != undefined &&
parentMatch2 != undefined &&
parentMatch1.dataset.disabled == "false" &&
parentMatch2.dataset.disabled == "false"
) {
debug_console(
`[${matchTitle}] Both children active - setting their positions`,
console.log(
roundIndex,
matchTitle,
parentMatch1.dataset.matchTitle,
parentMatch2.dataset.matchTitle,
parentMatch1.dataset.disabled,
parentMatch2.dataset.disabled,
top,
);
nextMatchDistance = baseDistance;
matchPositions[roundIndex + 1][parentIndex1] = top - baseDistance / 2;
matchPositions[roundIndex + 1][parentIndex2] = top + baseDistance / 2;
debug_console(
`[${matchTitle}] Set: [${parentIndex1}]=${matchPositions[roundIndex + 1][parentIndex1]}, [${parentIndex2}]=${matchPositions[roundIndex + 1][parentIndex2]}`,
);
console.log(matchPositions[roundIndex + 1]);
// } else if (parentMatch1 != undefined) {
// matchPositions[roundIndex + 1][parentIndex1] = top;
// nextMatchDistance = 0;
// } else if (parentMatch2 != undefined) {
// matchPositions[roundIndex + 1][parentIndex1] = top;
// nextMatchDistance = 0;
} else if (
parentMatch2 != undefined &&
parentMatch2.dataset.disabled == "false"
) {
debug_console(`[${matchTitle}] Only child2 active`);
if (realRoundIndex == 1 && doubleButterflyMode) {
nextMatchDistance = baseDistance;
debug_console(
`[${matchTitle}] Quarterfinal missing match: nextMatchDistance=${baseDistance}`,
);
} else {
nextMatchDistance = 0;
}
matchPositions[roundIndex + 1][parentIndex2] = top;
debug_console(`[${matchTitle}] Set child2: [${parentIndex2}]=${top}`);
} else if (
parentMatch1 != undefined &&
parentMatch1.dataset.disabled == "false"
) {
debug_console(`[${matchTitle}] Only child1 active`);
if (realRoundIndex == 1 && doubleButterflyMode) {
nextMatchDistance = baseDistance;
debug_console(
`[${matchTitle}] Quarterfinal missing match: nextMatchDistance=${baseDistance}`,
);
} else {
nextMatchDistance = 0;
}
matchPositions[roundIndex + 1][parentIndex1] = top;
debug_console(`[${matchTitle}] Set child1: [${parentIndex1}]=${top}`);
} else {
debug_console(`[${matchTitle}] No active children`);
isOutgoingLineIsDisabled = true;
}
}
@ -461,27 +330,20 @@ function renderBracket(options) {
if (doubleButterflyMode == true) {
if (roundIndex >= finalRoundIndex - 2) {
if (roundIndex == finalRoundIndex - 1) {
debug_console(`[${matchTitle}] Semifinal adjustments`);
matchDiv.classList.add("reverse-bracket");
isIncomingLineIsDisabled = true;
nextMatchDistance = nextMatchDistance / 2;
}
if (roundIndex == finalRoundIndex + 1) {
debug_console(`[${matchTitle}] Post-final adjustments`);
matchDiv.classList.remove("reverse-bracket");
isOutgoingLineIsDisabled = true;
nextMatchDistance = nextMatchDistance;
}
}
}
debug_console(
`[${matchTitle}] FINAL: top=${top}, nextMatchDistance=${nextMatchDistance}, disabled=${isDisabled}`,
);
matchDiv.style.setProperty(
"--semi-final-distance",
`${baseDistance / 2.3}px`,
`${baseDistance / 2}px`,
);
matchDiv.style.setProperty(
@ -503,13 +365,25 @@ function renderBracket(options) {
matchPositions[roundIndex][matchRealIndex] = top;
if (matchIndex === 0) {
// // Add logo for final round
// if (roundIndex == finalRoundIndex) {
// const logoDiv = document.createElement('div');
// logoDiv.className = 'round-logo';
// const logoImg = document.createElement('img');
// logoImg.src = '/static/tournaments/images/PadelClub_logo_512.png';
// logoImg.alt = 'PadelClub Logo';
// logoDiv.appendChild(logoImg);
// logoDiv.style.transform = `translateX(-50%)`;
// matchesContainer.appendChild(logoDiv);
// }
// Position title above the first match
titleDiv.style.top = `${topMargin - roundTopMargin}px`;
titleDiv.style.top = `${topMargin - roundTopMargin}px`; // Adjust the 60px offset as needed
if (
(roundIndex == finalRoundIndex && realRoundIndex == 0) ||
isBroadcast == true
) {
titleDiv.style.top = `${top + topMargin - roundTopMargin}px`;
titleDiv.style.top = `${top + topMargin - roundTopMargin}px`; // Adjust the 60px offset as needed
}
titleDiv.style.position = "absolute";
if (roundCount >= 5 && doubleButterflyMode == true) {
@ -548,7 +422,7 @@ function renderBracket(options) {
titleDiv.className = "round-title";
titleDiv.appendChild(nameSpan);
titleDiv.appendChild(formatSpan);
titleDiv.style.top = `${top + topMargin - 80}px`;
titleDiv.style.top = `${top + topMargin - 80}px`; // Adjust the 60px offset as needed
titleDiv.style.position = "absolute";
matchesContainer.appendChild(titleDiv);
}
@ -591,7 +465,24 @@ function renderBracket(options) {
}
}
matchesContainer.appendChild(matchDiv);
// if (
// roundIndex == finalRoundIndex - 1 &&
// displayLoserFinal == true &&
// doubleButterflyMode == true
// ) {
// const matchDiv2 = document.createElement("div");
// matchDiv2.className = "butterfly-match";
// matchDiv2.classList.add("inward");
// matchDiv2.classList.add("semi-final");
// matchDiv2.style.setProperty(
// "--next-match-distance",
// `${baseDistance}px`,
// );
// matchDiv2.style.top = `${top}px`;
// matchDiv2.innerHTML = `<div class="match-content">${rounds[0][0].innerHTML}</div>`;
// matchesContainer.appendChild(matchDiv2); // Append to matchesContainer instead of roundDiv
// }
matchesContainer.appendChild(matchDiv); // Append to matchesContainer instead of roundDiv
});
bracket.appendChild(roundDiv);
@ -644,7 +535,7 @@ function renderBracket(options) {
// Create a container that will sit at the same position for all rounds
const footerContainer = document.createElement("div");
footerContainer.style.position = "absolute";
footerContainer.style.top = `${globalMaxBottom}px`;
footerContainer.style.top = `${globalMaxBottom}px`; // Same position for all footers
footerContainer.style.width = "100%";
footerContainer.appendChild(footerDiv);
@ -657,6 +548,4 @@ function renderBracket(options) {
});
}, 100);
}
debug_console("=== RENDER BRACKET END ===\n");
}

@ -136,14 +136,10 @@
<div style="font-size: 32px; font-weight: bold;">{{ total_players }}</div>
<div style="opacity: 0.9; font-size: 16px;">Total Players</div>
</div>
<div style="margin-bottom: 20px;">
<div>
<div style="font-size: 20px; font-weight: bold;">{{ avg_teams_per_tournament }}</div>
<div style="opacity: 0.9; font-size: 14px;">Avg Teams/Tournament</div>
</div>
<div>
<div style="font-size: 20px; font-weight: bold;">{{ email_count }}</div>
<div style="opacity: 0.9; font-size: 14px;">Distinct emails</div>
</div>
</div>
</div>

@ -1,81 +0,0 @@
{% extends "admin/base_site.html" %}
{% load static %}
{% block extrahead %}
{{ block.super }}
{{ media }}
<script type="text/javascript" src="{% url 'admin:jsi18n' %}"></script>
{% endblock %}
{% block extrastyle %}
{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/forms.css' %}">
{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">Home</a>
&rsaquo; <a href="{% url 'admin:tournaments_event_changelist' %}">Events</a>
&rsaquo; Set Club
</div>
{% endblock %}
{% block content %}
<h1>{{ title }}</h1>
<p>You are about to set the club for the following {{ events|length }} event(s):</p>
<div style="margin: 20px 0; padding: 15px; background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px;">
<ul style="margin: 0; padding-left: 20px;">
{% for event in events %}
<li>
<strong>{{ event.name }}</strong>
{% if event.club %}
(currently: {{ event.club.name }})
{% else %}
(currently: No club assigned)
{% endif %}
</li>
{% endfor %}
</ul>
</div>
<form method="post" id="club-form" action="">
{% csrf_token %}
{# Hidden fields to preserve the selection #}
{% for hidden in form.hidden_fields %}
{{ hidden }}
{% endfor %}
{% if form.non_field_errors %}
<div class="errors">
{{ form.non_field_errors }}
</div>
{% endif %}
<div style="margin: 20px 0;">
<fieldset class="module aligned">
<div class="form-row field-club_id">
<div>
<label for="{{ form.club_id.id_for_label }}" class="required">{{ form.club_id.label }}:</label>
<div class="related-widget-wrapper">
{{ form.club_id }}
</div>
{% if form.club_id.help_text %}
<div class="help">{{ form.club_id.help_text }}</div>
{% endif %}
{% if form.club_id.errors %}
<div class="errors">{{ form.club_id.errors }}</div>
{% endif %}
</div>
</div>
</fieldset>
</div>
<div class="submit-row" style="margin-top: 20px; padding: 10px; text-align: right;">
<input type="submit" name="apply" value="Set Club" class="default" style="margin-right: 10px;"/>
<a href="{% url 'admin:tournaments_event_changelist' %}" class="button cancel-link">Cancel</a>
</div>
</form>
{% endblock %}

@ -32,13 +32,7 @@
<p><strong>✅ Votre paiement a bien été effectué et enregistré.</strong></p>
{% endif %}
<p style="text-align: justify;">
{% if user.email %}
Un email de confirmation a été envoyé à l'adresse associée à votre compte Padel Club ({{ user.email }}). Pensez à vérifier vos spams si vous ne recevez pas l'email. En cas de problème, contactez le juge-arbitre.
{% elif registered_team.team_contact %}
Un email de confirmation a été envoyé à l'adresse associée à votre compte Padel Club ({{ registered_team.team_contact }}). Pensez à vérifier vos spams si vous ne recevez pas l'email. En cas de problème, contactez le juge-arbitre.
{% else %}
Aucun email de confirmation n'a été envoyé car vous n'avez pas fourni d'adresse email. Contactez le juge-arbitre.
{% endif %}
</p>
{% else %}
{% if not registration_successful %}
@ -150,24 +144,12 @@
{% endif %}
{% if add_player_form.first_tournament or add_player_form.user_without_licence or tournament.license_is_required is False %}
{% if not add_player_form.user_without_licence and tournament.license_is_required is True %}
{% if current_players|length > 0 %}
<div class="semibold">
Padel Club n'a pas trouvé votre partenaire, il se peut qu'il s'agisse de son premier tournoi. Contacter le juge-arbitre après l'inscription si ce n'est pas le cas.
</div>
<div class="semibold">
Précisez les informations du joueur :
</div>
{% elif current_players|length == 0 and not user.is_authenticated %}
<div class="semibold">
Veuillez renseigner vos informations :
</div>
{% endif %}
{% endif %}
{% if tournament.license_is_required is False and current_players|length == 0 and not user.is_authenticated %}
<div class="semibold">
Veuillez renseigner vos informations :
</div>
{% endif %}
{% if not add_player_form.user_without_licence %}

@ -1,30 +0,0 @@
{% extends 'tournaments/base.html' %}
{% block head_title %} Paiement {% endblock %}
{% block first_title %} Padel Club {% endblock %}
{% block second_title %} Paiement {% endblock %}
{% block content %}
{% load static %}
{% load tz %}
<div class="grid-x">
<div class="bubble">
<div class="cell medium-6 large-6 padding10">
{% if payment_status == 'success' %}
<label class="title">Paiement réussi !</label>
<p>Votre inscription a été confirmée. Un email de confirmation vous a été envoyé.</p>
{% if show_details and tournament %}
<p>Tournoi : <strong>{{ tournament.display_name }}</strong></p>
{% endif %}
{% elif payment_status == 'cancel' %}
<label class="title">Paiement annulé</label>
<p>Votre paiement a été annulé. Aucun montant n'a été prélevé.</p>
{% else %}
<label class="title">Paiement en cours de traitement</label>
<p>Votre paiement est en cours de traitement. Vous recevrez un email de confirmation sous peu.</p>
{% endif %}
<p>Vous pouvez maintenant fermer cette page.</p>
</div>
</div>
</div>
{% endblock %}

@ -158,15 +158,8 @@
// Create the match content using our HTML generator
template.innerHTML = `<div class="bubble broadcast-bracket-match ${(!match.ended && match.started) ? 'match-running' : ''}">${createMatchHTML(match)}</div>`;
template_time = document.createElement('div');
template_time.className = 'match-time-indication';
template_time.style.textAlign = 'center';
template_time.innerHTML = `<div>${match.time_indication}</div>`;
template.appendChild(template_time);
tempContainer.appendChild(template);
});
});

@ -275,9 +275,9 @@
</div>
<div class="cell medium-4 large-4 price-padding">
<div class="center bubble">
<div class="price-title">PACK</div>
<div class="price">125€ HT</div>
<div>10 tournois</div>
<div class="price-title">ABONNEMENT MENSUEL</div>
<div class="price">50€ HT</div>
<div>jusqu'à 5 tournois</div>
</div>
</div>
<div class="cell medium-4 large-4 price-padding">

@ -1,5 +1,3 @@
{% load tournament_tags %}
<nav class="margin10">
<a href="{% url 'index' %}" class="topmargin5 orange">Accueil</a>
@ -11,14 +9,6 @@
<a href="{% url 'tournament-live' tournament.id %}" class="topmargin5 orange">Live</a>
{% endif %}
{% if tournament.will_start_soon and request.user.is_authenticated %}
{% with user_team=tournament|get_user_team:request.user %}
{% if user_team %}
<a href="{% url 'team-details' tournament.id user_team.id %}" class="topmargin5 orange">Mon équipe</a>
{% endif %}
{% endwith %}
{% endif %}
{% if tournament.display_prog %}
<a href="{% url 'tournament-prog' tournament.id %}" class="topmargin5 orange">Programmation</a>
{% endif %}

@ -164,7 +164,7 @@
{% else %}
<div class="topmargin20">
<p class="minor info">
La désinscription en ligne n'est plus possible. Veuillez contacter directement le juge-arbitre si besoin.
La désincription en ligne n'est plus possible. Veuillez contacter directement le juge-arbitre si besoin.
</p>
</div>
{% endif %}
@ -176,10 +176,8 @@
<h1 class="club padding10">{{ tournament.full_name }}</h1>
<div class="bubble">
<div class="semibold">{{ tournament.local_start_date_formatted }}</div>
{% if not tournament.is_custom_animation %}
<div>{{ tournament.day_duration_formatted }}</div>
<div>{{ tournament.court_count }} piste{{ tournament.court_count|pluralize }}</div>
{% endif %}
<p>
</p>
@ -204,7 +202,6 @@
{% endif %}
<hr/>
{% if not tournament.is_custom_animation %}
<p>
{% if tournament.umpire_contact %}
<div class="semibold">Organisateur</div>
@ -217,7 +214,7 @@
<div><a href="tel:{{ tournament.umpire_phone }}" class="styled-link">{{ tournament.umpire_phone }}</a></div>
{% endif %}
</p>
{% endif %}
{% if tournament.information %}
<p>
<div class="semibold">Infos</div>
@ -228,9 +225,7 @@
{% if tournament.options_fee %}
<p>
{% if not tournament.is_custom_animation %}
<div class="semibold">Frais d'inscription</div>
{% endif %}
<ul>
{% for option in tournament.options_fee %}
<li>{{ option }}</li>
@ -240,7 +235,6 @@
{% endif %}
{% with status=tournament.get_online_registration_status %}
{% if tournament.enable_online_registration %}
{% if not tournament.is_custom_animation %}
<p>
<div class="semibold">Inscription en ligne</div>
@ -269,7 +263,6 @@
</div>
</p>
{% endif %}
{% endif %}
{% if status.display_register_option and team is None %}
{% if tournament.account_is_required is False or user.is_authenticated and user.is_active %}

@ -42,12 +42,8 @@
</div>
{% endif %}
{% else %}
{% if tournament.is_team_tournament %}
<div class="small">Équipes</div>
{% else %}
<div class="small">Inscriptions</div>
{% endif %}
<div class="very-large">{{ tournament.get_tournament_status_registration_count }}</div>
<div class="very-large">{{ tournament.get_tournament_status_team_count }}</div>
{% if status.display_box %}
<div class="box small {{ status.box_class }}">
{{ status.short_label }}
@ -56,12 +52,8 @@
{% endif %}
{% endwith %}
{% else %}
{% if tournament.is_team_tournament %}
<div class="small">Équipes</div>
{% else %}
<div class="small">Inscriptions</div>
{% endif %}
<div class="very-large">{{ tournament.get_tournament_status_registration_count }}</div>
<div class="very-large">{{ tournament.get_tournament_status_team_count }}</div>
{% if status.display_box %}
<div class="box small {{ status.box_class }}">
{{ status.short_label }}

@ -6,11 +6,6 @@ register = template.Library()
def get_player_status(tournament, user):
return tournament.get_player_registration_status_by_licence(user)
@register.filter
def get_user_team(tournament, user):
"""Get the team registration for a user in a tournament"""
return tournament.get_user_team_registration(user)
@register.filter
def lookup(dictionary, key):
"""Template filter to lookup dictionary values by key"""

@ -79,7 +79,6 @@ urlpatterns = [
path('activation-success/', views.activation_success, name='activation_success'),
path('activation-failed/', views.activation_failed, name='activation_failed'),
path('tournaments/<str:tournament_id>/confirm/', views.confirm_tournament_registration, name='confirm_tournament_registration'),
path('stripe/payment_complete/', views.stripe_payment_complete, name='stripe-payment-complete'),
path('stripe-onboarding-complete/', views.stripe_onboarding_complete, name='stripe-onboarding-complete'),
path('stripe-refresh-account-link/', views.stripe_refresh_account_link, name='stripe-refresh-account-link'),
path('tournaments/<str:tournament_id>/toggle-private/', views.toggle_tournament_private, name='toggle_tournament_private'),

@ -30,7 +30,7 @@ def get_player_name_from_csv(category, licence_id, base_folder=None):
else:
cleaned_licence_id = None
# print("get_player_name_from_csv", cleaned_licence_id, folder_path)
print("get_player_name_from_csv", cleaned_licence_id, folder_path)
def extract_date(file_name):
"""
@ -58,7 +58,7 @@ def get_player_name_from_csv(category, licence_id, base_folder=None):
def search_file(file_path, is_woman):
if not file_path or not os.path.exists(file_path):
# print("no file found")
print("no file found")
return None, False
last_rank = None
@ -109,13 +109,13 @@ def get_player_name_from_csv(category, licence_id, base_folder=None):
return None, False
# print("Look for woman", FederalCategory.WOMEN)
print("Look for woman", FederalCategory.WOMEN)
dames_file = find_most_recent_file("CLASSEMENT-PADEL-DAMES-")
result, found = search_file(dames_file, True)
if found or category is FederalCategory.WOMEN:
return result, found
# print("Look for man")
print("Look for man")
messieurs_file = find_most_recent_file("CLASSEMENT-PADEL-MESSIEURS-")
result, found = search_file(messieurs_file, False)
return result, found

@ -69,12 +69,6 @@ from .models import AnimationType
logger = logging.getLogger(__name__)
def get_object(type, id):
try:
return type.objects.get(pk=id)
except (type.DoesNotExist, ValueError, ValidationError):
raise Http404(f"{type.__name__} does not exist")
def index(request):
now = timezone.now()
thirty_days_ago = now - timedelta(days=30)
@ -84,7 +78,7 @@ def index(request):
if club_id:
tournaments = tournaments_query(Q(end_date__isnull=True), club_id, True, 50)
else:
tournaments = tournaments_query(Q(end_date__isnull=True, start_date__gte=thirty_days_ago, start_date__lte=thirty_days_future), club_id, True, 100)
tournaments = tournaments_query(Q(end_date__isnull=True, start_date__gte=thirty_days_ago, start_date__lte=thirty_days_future), club_id, True, 50)
display_tournament = [t for t in tournaments if t.display_tournament()]
live = []
@ -124,7 +118,7 @@ def tournaments_query(query, club_id, ascending, limit=None):
club = None
if club_id:
club = get_object(Club, club_id)
club = get_object_or_404(Club, pk=club_id)
q_club = Q(event__club=club)
queries.append(q_club)
else:
@ -399,16 +393,12 @@ def event(request, event_id):
else:
name = 'Événement'
first_title = ''
if event.club:
first_title = event.club.name
return render(
request,
"tournaments/tournaments_list.html",
{
'tournaments': tournaments,
'first_title': first_title,
'first_title': event.club.name,
'second_title': name,
'head_title': name,
'first_tournament_prog_url': first_tournament_prog_url,
@ -1593,14 +1583,9 @@ def proceed_to_payment(request, tournament_id):
messages.error(request, f"Erreur lors de la création de la session de paiement: {str(e)}")
return redirect('tournament-info', tournament_id=tournament_id)
@login_required
def tournament_payment_success(request, tournament_id):
"""Handle successful Stripe payment for tournament registration"""
# For unauthenticated users, process payment and redirect directly to registration page
if not request.user.is_authenticated:
return _handle_unauthenticated_payment_success(request, tournament_id)
# Original logic for authenticated users
# Get checkout session ID from session
checkout_session_id = request.session.get('stripe_checkout_session_id')
if not checkout_session_id:
@ -1656,68 +1641,6 @@ def tournament_payment_success(request, tournament_id):
# For direct payments, go to tournament info
return redirect('tournament-info', tournament_id=tournament_id)
def _handle_unauthenticated_payment_success(request, tournament_id):
"""Handle payment success for unauthenticated users"""
print(f"[PAYMENT SUCCESS] Handling unauthenticated user payment for tournament {tournament_id}")
# Get checkout session ID from session
checkout_session_id = request.session.get('stripe_checkout_session_id')
if not checkout_session_id:
print(f"[PAYMENT SUCCESS] No checkout session ID found")
messages.error(request, "Session de paiement introuvable.")
return redirect('register_tournament', tournament_id=tournament_id)
try:
# Verify payment status with Stripe
stripe.api_key = settings.STRIPE_SECRET_KEY
print(f"[PAYMENT SUCCESS] Retrieving checkout session: {checkout_session_id}")
stripe_account_id = request.session.get('stripe_account_id')
if not stripe_account_id:
checkout_session = stripe.checkout.Session.retrieve(checkout_session_id)
else:
checkout_session = stripe.checkout.Session.retrieve(checkout_session_id, stripe_account=stripe_account_id)
print(f"[PAYMENT SUCCESS] Payment status: {checkout_session.payment_status}")
if checkout_session.payment_status == 'paid':
# Process the payment success
payment_service = PaymentService(request)
success = payment_service.process_successful_payment(checkout_session)
print(f"[PAYMENT SUCCESS] Payment processing success: {success}")
if success:
# Always set success flags for unauthenticated users since they come from registration
request.session['registration_successful'] = True
request.session['registration_paid'] = True
# Clear payment-related session data
for key in ['stripe_checkout_session_id', 'team_registration_id', 'payment_source_page', 'stripe_account_id']:
if key in request.session:
del request.session[key]
print(f"[PAYMENT SUCCESS] Redirecting to registration page with success flags")
# Redirect directly to registration page with success context
return redirect('register_tournament', tournament_id=tournament_id)
else:
messages.error(request, "Erreur lors du traitement du paiement.")
else:
messages.error(request, "Le paiement n'a pas été complété.")
except Exception as e:
print(f"[PAYMENT SUCCESS] Payment processing error: {str(e)}")
messages.error(request, f"Erreur lors de la vérification du paiement: {str(e)}")
# Clean up session variables even if there was an error
for key in ['stripe_checkout_session_id', 'team_registration_id', 'payment_source_page', 'stripe_account_id']:
if key in request.session:
del request.session[key]
# Always redirect to registration page for unauthenticated users
return redirect('register_tournament', tournament_id=tournament_id)
@csrf_protect
def register_tournament(request, tournament_id):
tournament = get_object_or_404(Tournament, id=tournament_id)
@ -1734,7 +1657,7 @@ def register_tournament(request, tournament_id):
# Check for registration_successful flag
registration_successful = request.session.pop('registration_successful', False)
registration_paid = request.session.pop('registration_paid', False)
registered_team = None
# Handle payment cancellation - check for cancelled team registration
cancel_team_registration_id = request.session.pop('cancel_team_registration_id', None)
if cancel_team_registration_id:
@ -1753,8 +1676,7 @@ def register_tournament(request, tournament_id):
if not team_registration.is_paid():
team_registration.delete()
print(f"[PAYMENT CANCEL] Deleted unpaid team registration {cancel_team_registration_id}")
else:
registered_team = team_registration
except TeamRegistration.DoesNotExist:
print(f"[PAYMENT CANCEL] Team registration {cancel_team_registration_id} not found")
except Exception as e:
@ -1780,7 +1702,6 @@ def register_tournament(request, tournament_id):
'tournament': tournament,
'registration_successful': True,
'registration_paid': registration_paid,
'registered_team': registered_team,
'current_players': [],
'cart_data': {'players': []}
}
@ -1902,32 +1823,18 @@ def handle_add_player_request(request, tournament, cart_manager, context):
if team_form.is_valid():
# Update cart with mobile number before adding player
cart_manager.update_contact_info(
email=team_form.cleaned_data.get('email'),
mobile_number=team_form.cleaned_data.get('mobile_number')
)
# Get player data from form
player_data = add_player_form.cleaned_data.copy()
# If authenticated user is adding themselves (no players in cart yet)
# and names are missing, use their profile names
if request.user.is_authenticated and len(context['current_players']) == 0:
if not player_data.get('first_name'):
player_data['first_name'] = request.user.first_name
if not player_data.get('last_name'):
player_data['last_name'] = request.user.last_name
if not player_data.get('email'):
player_data['email'] = request.user.email
success, message = cart_manager.add_player(player_data)
success, message = cart_manager.add_player(add_player_form.cleaned_data)
if success:
messages.success(request, message)
# Refresh cart data
cart_data = cart_manager.get_cart_data()
context['current_players'] = cart_data['players']
context['cart_data'] = cart_data
context['team_form'] = TournamentRegistrationForm(initial={
'email': request.user.email if request.user.is_authenticated else cart_data.get('email', ''),
'email': request.user.email if request.user.is_authenticated else '',
'mobile_number': cart_data.get('mobile_number', '')
})
@ -1957,7 +1864,6 @@ def handle_remove_player_request(request, tournament, cart_manager, context):
# Refresh cart data
cart_data = cart_manager.get_cart_data()
context['current_players'] = cart_data['players']
context['cart_data'] = cart_data
# Update the add player form based on the new state
if not cart_data['players'] and request.user.is_authenticated:
@ -1991,7 +1897,6 @@ def handle_register_team_request(request, tournament, cart_manager, context):
# Update cart with contact info
cart_manager.update_contact_info(
email=team_form.cleaned_data.get('email'),
mobile_number=team_form.cleaned_data.get('mobile_number')
)
@ -2016,7 +1921,6 @@ def handle_register_team_request(request, tournament, cart_manager, context):
)
context['registration_successful'] = True
context['registered_team'] = result
context['registration_paid'] = False
context['current_players'] = []
context['add_player_form'] = None # No more adding players after success
@ -2034,7 +1938,6 @@ def handle_payment_request(request, cart_manager, context, tournament_id):
if team_form.is_valid():
# Update cart with contact info
cart_manager.update_contact_info(
email=team_form.cleaned_data.get('email'),
mobile_number=team_form.cleaned_data.get('mobile_number')
)
@ -2327,39 +2230,6 @@ def tournament_live_matches(request, tournament_id):
'live_matches': live_matches,
})
def stripe_payment_complete(request):
"""Handle payment complete page for Stripe Payment Links"""
tournament_id = request.GET.get('tournament_id')
team_registration_id = request.GET.get('team_registration_id')
payment_status = request.GET.get('payment', 'unknown')
context = {
'payment_status': payment_status,
'tournament': None,
'team_registration': None,
'players': [],
'show_details': False
}
# Try to get tournament and team registration details
if tournament_id and team_registration_id:
try:
tournament = Tournament.objects.get(id=tournament_id)
team_registration = TeamRegistration.objects.get(id=team_registration_id)
context.update({
'tournament': tournament,
'team_registration': team_registration,
'players': team_registration.players_sorted_by_captain,
'show_details': True,
'amount_paid': team_registration.get_team_registration_fee(),
'currency': tournament.currency_code
})
except (Tournament.DoesNotExist, TeamRegistration.DoesNotExist):
print(f"Tournament or team registration not found: {tournament_id}, {team_registration_id}")
return render(request, 'stripe/payment_complete.html', context)
class UserListExportView(LoginRequiredMixin, View):
def get(self, request, *args, **kwargs):

Loading…
Cancel
Save