Compare commits

...

174 Commits

Author SHA1 Message Date
Laurent 3ce30cf5f7 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 3 days ago
Laurent cf28db9fd0 avoid getting mails for get_object_or_404 failures 3 days ago
Razmig Sarkissian 08fd01e119 add nov 2025 rank 6 days ago
Razmig Sarkissian f97dbd79cc Enhance Club Selection Form in EventAdmin Action 7 days ago
Razmig Sarkissian 240bb3fc25 Add admin template for setting club for multiple events 7 days ago
Razmig Sarkissian 5102e4c295 Add set club bulk action to EventAdmin 7 days ago
Razmig Sarkissian 6be947706e Increase tournament listing limit from 50 to 100 1 week ago
Razmig Sarkissian eb25d0e609 Add get_last_name method for anonymous players 1 week ago
Razmig Sarkissian efdb414345 Remove synchronization check when creating ModelLogs 2 weeks ago
Razmig Sarkissian 85c56981a6 Add null check to umpire_mail method 2 weeks ago
Razmig Sarkissian f01a681e93 Add null check for event and event creator in umpire_phone 2 weeks ago
Razmig Sarkissian 3522ee87f5 Update player_registration.py 2 weeks ago
Razmig Sarkissian e215ca7e1d Add "My Team" link to tournament navigation for authenticated users 2 weeks ago
Razmig Sarkissian 174c2988b2 Update Padel rankings unranked male values 2 weeks ago
Razmig Sarkissian ec079e1a7a Add debug logging to Round and Tournament Bracket fix annelise issue 2 weeks ago
Laurent a31796aad0 fix date issue 3 weeks ago
Laurent 7c31c511dd revert 3 weeks ago
Laurent 441815d9a8 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 3 weeks ago
Laurent 521acaf747 fix issue 3 weeks ago
Razmig Sarkissian 93a27f9583 Modify team unregistration to cancel registration instead of deleting 3 weeks ago
Laurent d9130b0fdf updates last_update on each save 3 weeks ago
Laurent 1218c74d26 fix WS notification issue 3 weeks ago
Razmig Sarkissian 49d497d48f Cancel Team Registration by Deleting Team Registration 3 weeks ago
Razmig Sarkissian 0d330f3dcf Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 3 weeks ago
Razmig Sarkissian 34924db360 Add user-initiated registration cancellation logic 3 weeks ago
Razmig Sarkissian 11d6913807 Improve Payment Service: Add Transaction Safety and Error Handling 3 weeks ago
Laurent 59a39ffd49 Adds round index filter for TeamScore 3 weeks ago
Laurent 7bf560a6a2 sync get improvements and logs 3 weeks ago
Laurent 77b999fbb3 logs update 3 weeks ago
Razmig Sarkissian 8de8a9ac49 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 4 weeks ago
Razmig Sarkissian 80b6bc1136 Fix email filtering for tournament registrations 4 weeks ago
Laurent 1339b65731 Fix crash 4 weeks ago
Razmig Sarkissian 0158ce150d Add API endpoint to get payment link for team registration 4 weeks ago
Razmig Sarkissian fcb2ef9549 Add force_send option to resend registration emails 4 weeks ago
Razmig Sarkissian 005d8877e7 Add migration for new fields in Activity and Tournament models 4 weeks ago
Razmig Sarkissian 093015dac6 Add custom club name field to Tournament model 4 weeks ago
Razmig Sarkissian f152a441d4 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 4 weeks ago
Razmig Sarkissian ef0e7b6326 Add missing player information from authenticated user profile 4 weeks ago
Laurent 1572bed50d fix margin 4 weeks ago
Laurent 4fb9460572 Adds distinct email in the dashboard 4 weeks ago
Laurent 015934c663 change TeamRegistrationAdmin 4 weeks ago
Laurent 16bc3e4428 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 4 weeks ago
Laurent 3db14c6180 attempt to fix crash 4 weeks ago
Razmig Sarkissian 6918677009 Fix user age calculation and handle 'N/A' birth year 4 weeks ago
Razmig Sarkissian 3b56d59321 update ranking oct 2025 accuracy 4 weeks ago
Razmig Sarkissian 7c1c37746c Improve webhook handling and validation logic 1 month ago
Laurent 908c0b7dc8 Activity changes update the last_update field of the Prospect 1 month ago
Laurent 00228e4c8a Add links to the prospects dashboard 1 month ago
Laurent 808d65a5a3 adds a plus button to add activities to prospect in the dashboard, and setting related_user if needed 1 month ago
Laurent 0f7516a617 update dashboard with the contact again list 1 month ago
Laurent 1bf31744b5 change many to many to autocomplete in the admin 1 month ago
Laurent 4df1ccba28 dashboard improvements 1 month ago
Laurent 108fd9cafa UI improvements for the prospect dashboard 1 month ago
Laurent 76b0b02933 Adds prospects dashboard 1 month ago
Laurent 6a8b5c4d97 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Laurent 69ad1bef02 Adds Whatsapp as contact mean 1 month ago
Razmig Sarkissian 632651a5ef Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Razmig Sarkissian b6de2f5653 Only serve media files in development mode 1 month ago
Laurent 8583523a0e Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Laurent c6d5af345f Adds declination reasons 1 month ago
Razmig Sarkissian 5c36eb8781 Add umpire data export functionality 1 month ago
Razmig Sarkissian 35884b728f Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Razmig Sarkissian 511066ccc8 Add support for single player tournament registration 1 month ago
Laurent c8c54b5ac8 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Laurent 12ddbb2c29 update PurchaseAdmin 1 month ago
Razmig Sarkissian 2d0dfd1b8f Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Razmig Sarkissian ff7718d044 add oct 2025 rankings 1 month ago
Laurent 169bc465c5 change param list 1 month ago
Laurent 7120bddd26 adds phone to prospect list 1 month ago
Laurent 58557c01aa fix sync crash 1 month ago
Laurent c37a6a8c12 Adds mobile only filter for prospects 1 month ago
Laurent 85cdf26fcf update activity admin 1 month ago
Razmig Sarkissian ae4660ffb8 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Razmig Sarkissian 358f025cc5 Remove debug logging statements 1 month ago
Laurent 95496508ac test 1 month ago
Laurent a221bb0090 adds log to sync signal 1 month ago
Laurent a7dd1a4122 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Laurent 23e1651dad update 1 month ago
Laurent ff8788c527 remove biz objects from sync 1 month ago
Razmig Sarkissian 06e8375e15 Remove duplicate media file serving pattern 1 month ago
Razmig Sarkissian 9e9d476922 Add media files serving configuration for production 1 month ago
Razmig Sarkissian d9c7a1ae4a Serve media files in all environments 1 month ago
Laurent 73c18bfbf8 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Laurent 5d60993748 Adds ModelLog creation for DataAccess to be shared between concerned users 1 month ago
Razmig Sarkissian 1404adc802 Replace payment link endpoints with resend email option 1 month ago
Razmig Sarkissian f4d8b1a536 Add support for corporate tournament Stripe payments 1 month ago
Razmig Sarkissian 22b06b4494 Remove cancel_url from Stripe checkout params 1 month ago
Razmig Sarkissian c004325ac8 Remove unused tournament model fields 1 month ago
Razmig Sarkissian a5c9765366 Add Stripe payment links for tournament registration 1 month ago
Razmig Sarkissian 4fbfce8393 Fix player contact email handling across service methods 1 month ago
Razmig Sarkissian 371bce35d7 Refactor contact information handling in tournament registration 1 month ago
Laurent 0074548dd4 exclude activities from EmailTemplate admin 1 month ago
Laurent 708e086ded update name replacement in emails 1 month ago
Laurent 60d36278c6 order activities by last_update 1 month ago
Laurent aa6cb5c84e remove logs 1 month ago
Laurent 4e96ba5a13 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Laurent e5ac3750d1 fixes sharing/revocations issues 1 month ago
Razmig Sarkissian de5cd64679 Add HTTPS requirement for production Stripe account links 1 month ago
Razmig Sarkissian 4826b4b8b7 Add debug print statement for Stripe account link base path 1 month ago
Razmig Sarkissian fe3e224d15 Update CustomUserAdmin to use date_joined instead of creation_date 1 month ago
Razmig Sarkissian 8f7b21d0de Add 'creation_date' to CustomUserAdmin user fields 1 month ago
Razmig Sarkissian 769f969ba2 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 1 month ago
Razmig Sarkissian 5e45b6d96a Refactor Tournament Summary Serializer for enhanced readability 1 month ago
Laurent 1a9eb14dd9 reorder filters 1 month ago
Laurent a8276d5f4b Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Laurent c8ea7699c5 adds prospect option 2 months ago
Razmig Sarkissian e6756f40dd Remove unused BeautifulSoup import from utils.py 2 months ago
Razmig Sarkissian 9c2fbed0d5 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Razmig Sarkissian 2c47025a77 Refactor Playwright scraping with environment-specific browser and 2 months ago
Laurent 5754a655bc Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Laurent de4336d13a add search by phone for prospect 2 months ago
Razmig Sarkissian a7cbf4c6a6 Refactor FFT tournament scraping using Playwright with detailed error 2 months ago
Razmig Sarkissian 34d8fac0d5 Refactor FFT tournament scraping with Queue-It fallback 2 months ago
Razmig Sarkissian 7d997fdb7d Remove hardcoded page wait timeouts 2 months ago
Razmig Sarkissian 40705b061c Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Razmig Sarkissian 1269b97765 add waiting fft scraping 2 months ago
Laurent cf831be3c6 remove reset button 2 months ago
Laurent e0047fbdc3 ease search for prospects 2 months ago
Laurent 9d71efb51a adds an activity when sending an email 2 months ago
Laurent 03f860cf48 improve mail sending error management 2 months ago
Laurent 409777f6b6 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Laurent 8132826866 renamed agents into supervisors and added organizers who can create tournaments 2 months ago
Razmig Sarkissian 33f170dd3e Replace timezone.localtime with local_planned_start_date method 2 months ago
Razmig Sarkissian 9a3f92306d Replace timezone.localtime with local planned start date method 2 months ago
Razmig Sarkissian 6875081097 Fix double butterfly mode condition for rounds count 2 months ago
Razmig Sarkissian ad6139852d Improve player name display in tournaments 2 months ago
Razmig Sarkissian e6a6268143 Fix tournament registration fee calculation 2 months ago
Razmig Sarkissian 7709409a63 Fix tournament cart fee calculation logic 2 months ago
Razmig Sarkissian 1019c20890 Fix fee calculation for tournament registration 2 months ago
Razmig Sarkissian 721650a8b6 Fix name display with null checks 2 months ago
Razmig Sarkissian c0d97721dd Fix calculation of tournament registration fee 2 months ago
Laurent 8c4799c1e6 fix problem with event admin 2 months ago
Laurent b1690b44c9 adds prospect filter to remove mobile phone prospect 2 months ago
Laurent 05c70c94e1 hide data_access_ids 2 months ago
Laurent 8c8cc21895 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Laurent c41eadfe36 small improvements on new stuff 2 months ago
Razmig Sarkissian 309e3d7ee1 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Razmig Sarkissian 89e68c3033 Fix double butterfly match positioning in quarterfinals 2 months ago
Laurent b3a20f69f4 fix issue 2 months ago
Laurent 3af02f98a7 remove id change 2 months ago
Laurent e1e2fb08ef rename Campaign into ProspectGroup 2 months ago
Laurent 146dae4039 update gitignore 2 months ago
Laurent d4de2ae399 replace int id by uuid id + bonus 2 months ago
Laurent f1c02a7d1b Adds new Campaign model to make groups of users + way of creating them from users 2 months ago
Laurent 147c8e9ba3 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Laurent 09282698cf Adds sharable property to BaseModel classes 2 months ago
Razmig Sarkissian 26b7aea651 Move online registration block inside custom animation check 2 months ago
Razmig Sarkissian 289e8e8e8c Hide online registration for custom animation tournaments 2 months ago
Razmig Sarkissian 07d5d20800 fix special custom animation stuff 2 months ago
Razmig Sarkissian 99a722c63c Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Razmig Sarkissian 7fec722362 Add time indication for matches in broadcast bracket 2 months ago
Laurent 46001fc4e7 fix admin issue 2 months ago
Laurent 17d5a1e7a5 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Laurent d5b2591925 add action to convert users into prospects 2 months ago
Razmig Sarkissian 9717e71988 Change IntegrityError to ValidationError in user serializer 2 months ago
Razmig Sarkissian ac76622995 Add min_start_date filter to Tournament summary view 2 months ago
Razmig Sarkissian 754c1d5796 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Razmig Sarkissian 9666a998c4 Fix bracket rendering when both parents are disabled 2 months ago
Laurent b2bd41b737 admin update 2 months ago
Laurent 980e5f6420 add raw_id for events 2 months ago
Laurent 75a00c0fa9 add raw_id fields for tournament 2 months ago
Laurent 319efc28f5 adds id of DataAccess in the admin 2 months ago
Laurent 334bcad30f Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Laurent 30b17810e9 update offer 2 months ago
Razmig Sarkissian 1482f7f670 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Razmig Sarkissian 5320d0a5be Remove ReferrerMiddleware and move logic to login view 2 months ago
Laurent 10168de3cd adds filter for user that have or dont have a prospect with the same email 2 months ago
Laurent c4be3c9ce2 add filter for user with/without purchases 2 months ago
Laurent 42bdb3bfed Add filter for user that have created an event 2 months ago
Laurent bc792ed470 make user a raw_id field 2 months ago
Laurent 6759ce7af8 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Razmig Sarkissian 82900cfe5e Add confirmation time to registration deadline 2 months ago
Razmig Sarkissian eee03f7708 Merge branch 'main' of https://gitea.staxriver.com/staxriver/padelclub_backend 2 months ago
Razmig Sarkissian b71ac1c645 Remove Registration Cart Cleanup Middleware 2 months ago
  1. 1
      .gitignore
  2. 1
      api/admin.py
  3. 99
      api/serializers.py
  4. 5
      api/urls.py
  5. 900
      api/utils.py
  6. 96
      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. 50
      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. 1289
      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. 30
      tournaments/models/player_registration.py
  42. 111
      tournaments/models/round.py
  43. 20
      tournaments/models/team_registration.py
  44. 55
      tournaments/models/tournament.py
  45. 86
      tournaments/services/email_service.py
  46. 313
      tournaments/services/payment_service.py
  47. 74
      tournaments/services/tournament_registration.py
  48. 10
      tournaments/services/tournament_unregistration.py
  49. 33
      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. 229
      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. 28
      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,6 +7,7 @@ padelclub_backend/settings_local.py
myenv/ myenv/
shared/config_local.py shared/config_local.py
logs/
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/

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

@ -49,7 +49,7 @@ class UserSerializer(serializers.ModelSerializer):
username_lower = validated_data['username'].lower() username_lower = validated_data['username'].lower()
if CustomUser.objects.filter(username__iexact=username_lower) | CustomUser.objects.filter(email__iexact=username_lower): if CustomUser.objects.filter(username__iexact=username_lower) | CustomUser.objects.filter(email__iexact=username_lower):
raise IntegrityError("Cet identifiant est déjà utilisé. Veuillez en choisir un autre :)") raise serializers.ValidationError("Cet identifiant est déjà utilisé. Veuillez en choisir un autre :)")
user = CustomUser.objects.create_user( user = CustomUser.objects.create_user(
username=validated_data['username'], username=validated_data['username'],
@ -135,15 +135,100 @@ class TournamentSerializer(serializers.ModelSerializer):
fields = '__all__' fields = '__all__'
class TournamentSummarySerializer(serializers.ModelSerializer): class TournamentSummarySerializer(serializers.ModelSerializer):
# English field names for all the information described in the comment
registration_count = serializers.SerializerMethodField() 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()
class Meta: class Meta:
model = Tournament model = Tournament
fields = ['id', 'name', 'start_date', 'day_duration', 'team_count', 'federal_category', 'federal_level_category', 'federal_age_category', 'registration_count'] fields = [
'id',
def get_registration_count(self, obj): 'tournament_name',
return len(obj.teams(True)) '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()
class EventSerializer(serializers.ModelSerializer): class EventSerializer(serializers.ModelSerializer):
class Meta: class Meta:

@ -8,7 +8,7 @@ from authentication.views import CustomAuthToken, Logout, ChangePasswordView
router = routers.DefaultRouter() router = routers.DefaultRouter()
router.register(r'users', views.UserViewSet) router.register(r'users', views.UserViewSet)
router.register(r'user-agents', views.ShortUserViewSet) router.register(r'user-supervisors', views.SupervisorViewSet)
router.register(r'clubs', views.ClubViewSet) router.register(r'clubs', views.ClubViewSet)
router.register(r'tournaments', views.TournamentViewSet) router.register(r'tournaments', views.TournamentViewSet)
router.register(r'tournament-summaries', views.TournamentSummaryViewSet) router.register(r'tournament-summaries', views.TournamentSummaryViewSet)
@ -63,5 +63,6 @@ urlpatterns = [
path('dj-rest-auth/', include('dj_rest_auth.urls')), 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/', views.create_stripe_connect_account, name='create_stripe_account'),
path('stripe/create-account-link/', views.create_stripe_account_link, name='create_account_link'), 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,3 +1,4 @@
from pandas.core.groupby import base
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
@ -15,6 +16,7 @@ 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 .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.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 from biz.models import Activity, Prospect, Entity
@ -40,6 +42,7 @@ import pandas as pd
import os import os
import logging import logging
from datetime import datetime
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -83,10 +86,22 @@ class TournamentSummaryViewSet(SoftDeleteViewSet):
if self.request.user.is_anonymous: if self.request.user.is_anonymous:
return Tournament.objects.none() return Tournament.objects.none()
return self.queryset.filter( queryset = self.queryset.filter(
Q(event__creator=self.request.user) | Q(related_user=self.request.user) Q(event__creator=self.request.user) | Q(related_user=self.request.user)
).distinct() ).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): class TournamentViewSet(SoftDeleteViewSet):
queryset = Tournament.objects.all() queryset = Tournament.objects.all()
@ -328,13 +343,13 @@ class UnregisteredPlayerViewSet(SoftDeleteViewSet):
return self.queryset.filter(unregistered_team__tournament__event__creator=self.request.user) return self.queryset.filter(unregistered_team__tournament__event__creator=self.request.user)
return [] return []
class ShortUserViewSet(viewsets.ModelViewSet): class SupervisorViewSet(viewsets.ModelViewSet):
queryset = CustomUser.objects.all() queryset = CustomUser.objects.all()
serializer_class = ShortUserSerializer serializer_class = ShortUserSerializer
permission_classes = [] # Users are public whereas the other requests are only for logged users permission_classes = [] # Users are public whereas the other requests are only for logged users
def get_queryset(self): def get_queryset(self):
return self.request.user.agents return self.request.user.supervisors
class ImageViewSet(viewsets.ModelViewSet): class ImageViewSet(viewsets.ModelViewSet):
""" """
@ -505,8 +520,13 @@ def create_stripe_account_link(request):
}, status=400) }, status=400)
try: try:
base_path = f"{request.scheme}://{request.get_host()}" # 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/" refresh_url = f"{base_path}/stripe-refresh-account-link/"
return_url = f"{base_path}/stripe-onboarding-complete/" return_url = f"{base_path}/stripe-onboarding-complete/"
@ -599,12 +619,76 @@ def validate_stripe_account(request):
'needs_onboarding': True, 'needs_onboarding': True,
}, status=200) }, 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']) @api_view(['GET'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def is_granted_unlimited_access(request): def is_granted_unlimited_access(request):
can_create = False can_create = False
if request.user and request.user.is_anonymous == False and request.user.owners: if request.user and request.user.is_anonymous == False and request.user.organising_for:
for owner in request.user.owners.all(): for owner in request.user.organising_for.all():
purchases = Purchase.objects.filter(user=owner,product_id='app.padelclub.tournament.subscription.unlimited') purchases = Purchase.objects.filter(user=owner,product_id='app.padelclub.tournament.subscription.unlimited')
for purchase in purchases: for purchase in purchases:
if purchase.is_active(): if purchase.is_active():

@ -6,15 +6,16 @@ from django.shortcuts import render, redirect
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.utils.html import format_html from django.utils.html import format_html
from django.core.mail import send_mail from django.core.mail import send_mail
from django.db.models import Q, Max, Subquery, OuterRef
import csv import csv
import io import io
import time import time
import logging import logging
from .models import Entity, Prospect, Activity, Status, ActivityType, EmailTemplate, DeclinationReason from .models import Entity, Prospect, Activity, Status, ActivityType, EmailTemplate, DeclinationReason, ProspectGroup
from .forms import FileImportForm, EmailTemplateSelectionForm from .forms import FileImportForm, EmailTemplateSelectionForm
from .filters import ContactAgainFilter, ProspectStatusFilter, StaffUserFilter, ProspectProfileFilter, ProspectDeclineReasonFilter from .filters import ContactAgainFilter, ProspectStatusFilter, StaffUserFilter, ProspectProfileFilter, ProspectDeclineReasonFilter, ProspectGroupFilter, PhoneFilter
from tournaments.models import CustomUser from tournaments.models import CustomUser
from tournaments.models.enums import UserOrigin from tournaments.models.enums import UserOrigin
@ -42,6 +43,7 @@ class EntityAdmin(SyncedObjectAdmin):
class EmailTemplateAdmin(SyncedObjectAdmin): class EmailTemplateAdmin(SyncedObjectAdmin):
list_display = ('name', 'subject', 'body') list_display = ('name', 'subject', 'body')
search_fields = ('name', 'subject') search_fields = ('name', 'subject')
exclude = ('data_access_ids', 'activities',)
def contacted_by_sms(modeladmin, request, queryset): def contacted_by_sms(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, ActivityType.SMS, Status.CONTACTED, None) create_default_activity_for_prospect(modeladmin, request, queryset, ActivityType.SMS, Status.CONTACTED, None)
@ -75,6 +77,14 @@ def declined_android_user(modeladmin, request, queryset):
create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.DECLINED, DeclinationReason.USE_ANDROID) create_default_activity_for_prospect(modeladmin, request, queryset, None, Status.DECLINED, DeclinationReason.USE_ANDROID)
declined_android_user.short_description = "Declined 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): def create_default_activity_for_prospect(modeladmin, request, queryset, type, status, reason):
for prospect in queryset: for prospect in queryset:
activity = Activity.objects.create( activity = Activity.objects.create(
@ -112,16 +122,21 @@ class ProspectAdmin(SyncedObjectAdmin):
'fields': ['related_activities', 'id', 'entity_names', 'first_name', 'last_name', 'email', 'phone', 'contact_again', 'official_user', 'name_unsure', 'entities', 'related_user'] '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', 'last_update_date', 'current_status', 'current_text', 'contact_again') list_display = ('first_name', 'last_name', 'entity_names', 'phone', 'last_update_date', 'current_status', 'contact_again')
list_filter = (ContactAgainFilter, ProspectStatusFilter, ProspectDeclineReasonFilter, 'creation_date', StaffUserFilter, 'source', ProspectProfileFilter) list_filter = (ContactAgainFilter, ProspectStatusFilter, ProspectDeclineReasonFilter, ProspectGroupFilter, PhoneFilter, 'creation_date', StaffUserFilter, 'source', ProspectProfileFilter)
search_fields = ('first_name', 'last_name', 'email', 'entities__name') search_fields = ('first_name', 'last_name', 'email', 'phone')
date_hierarchy = 'creation_date' date_hierarchy = 'creation_date'
change_list_template = "admin/biz/prospect/change_list.html" change_list_template = "admin/biz/prospect/change_list.html"
ordering = ['-last_update'] ordering = ['-last_update']
filter_horizontal = ['entities'] 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, declined_too_expensive, declined_use_something_else, declined_android_user] 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]
raw_id_fields = ['official_user'] 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)
def last_update_date(self, obj): def last_update_date(self, obj):
return obj.last_update.date() if obj.last_update else None return obj.last_update.date() if obj.last_update else None
@ -142,12 +157,68 @@ class ProspectAdmin(SyncedObjectAdmin):
def get_urls(self): def get_urls(self):
urls = super().get_urls() urls = super().get_urls()
custom_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_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('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'), path('cleanup/', self.admin_site.admin_view(self.cleanup), name='cleanup'),
] ]
return custom_urls + urls 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): def cleanup(self, request):
Entity.objects.all().delete() Entity.objects.all().delete()
Prospect.objects.all().delete() Prospect.objects.all().delete()
@ -229,15 +300,17 @@ class ProspectAdmin(SyncedObjectAdmin):
try: try:
# Read the file content # Read the file content
file_content = file.read().decode('utf-8') file_content = file.read().decode('utf-8')
csv_reader = csv.reader(io.StringIO(file_content)) csv_reader = csv.reader(io.StringIO(file_content), delimiter=';')
created_prospects = 0 created_prospects = 0
updated_prospects = 0 updated_prospects = 0
created_entities = 0 created_entities = 0
created_events = 0 created_events = 0
for row in csv_reader: for row in csv_reader:
if len(row) < 8: print(f'>>> row size is {len(row)}')
if len(row) < 5:
print(f'>>> WARNING: row size is {len(row)}: {row}')
continue # Skip rows that don't have enough columns continue # Skip rows that don't have enough columns
entity_name = row[0].strip() entity_name = row[0].strip()
@ -247,9 +320,9 @@ class ProspectAdmin(SyncedObjectAdmin):
phone = row[4].strip() if row[4].strip() else None phone = row[4].strip() if row[4].strip() else None
if phone and not phone.startswith('0'): if phone and not phone.startswith('0'):
phone = '0' + phone phone = '0' + phone
attachment_text = row[5].strip() if row[5].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 # status_text = row[6].strip() if row[6].strip() else None
related_user_name = row[7].strip() if row[7].strip() else None # related_user_name = row[7].strip() if row[7].strip() else None
# Create or get Entity # Create or get Entity
entity = None entity = None
@ -262,13 +335,13 @@ class ProspectAdmin(SyncedObjectAdmin):
created_entities += 1 created_entities += 1
# Get related user if provided # Get related user if provided
related_user = None # related_user = None
if related_user_name: # if related_user_name:
try: # try:
related_user = User.objects.get(username=related_user_name) # related_user = User.objects.get(username=related_user_name)
except User.DoesNotExist: # except User.DoesNotExist:
# Try to find by first name if username doesn't exist # # 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 = User.objects.filter(first_name__icontains=related_user_name).first()
# Create or update Prospect # Create or update Prospect
prospect, prospect_created = Prospect.objects.get_or_create( prospect, prospect_created = Prospect.objects.get_or_create(
@ -278,69 +351,68 @@ class ProspectAdmin(SyncedObjectAdmin):
'last_name': last_name, 'last_name': last_name,
'phone': phone, 'phone': phone,
'name_unsure': False, 'name_unsure': False,
'related_user': related_user,
'source': source, 'source': source,
} }
) )
if prospect_created: if prospect_created:
created_prospects += 1 created_prospects += 1
else: # else:
# Check if names are different and mark as name_unsure # # Check if names are different and mark as name_unsure
if (prospect.first_name != first_name or prospect.last_name != last_name): # if (prospect.first_name != first_name or prospect.last_name != last_name):
prospect.name_unsure = True # prospect.name_unsure = True
# Update related_user if provided # # Update related_user if provided
if related_user: # if related_user:
prospect.related_user = related_user # prospect.related_user = related_user
prospect.save() # prospect.save()
updated_prospects += 1 # updated_prospects += 1
# Associate entity with prospect # Associate entity with prospect
if entity: if entity:
prospect.entities.add(entity) prospect.entities.add(entity)
# Create Event if attachment_text or status is provided # Create Event if attachment_text or status is provided
if attachment_text or status_text: # if attachment_text or status_text:
# Map status text to Status enum # # Map status text to Status enum
status_value = None # status_value = None
declination_reason = None # declination_reason = None
if status_text: # if status_text:
if 'CONTACTED' in status_text: # if 'CONTACTED' in status_text:
status_value = Status.CONTACTED # status_value = Status.CONTACTED
elif 'RESPONDED' in status_text: # elif 'RESPONDED' in status_text:
status_value = Status.RESPONDED # status_value = Status.RESPONDED
elif 'SHOULD_TEST' in status_text: # elif 'SHOULD_TEST' in status_text:
status_value = Status.SHOULD_TEST # status_value = Status.SHOULD_TEST
elif 'CUSTOMER' in status_text: # elif 'CUSTOMER' in status_text:
status_value = Status.CUSTOMER # status_value = Status.CUSTOMER
elif 'TESTING' in status_text: # elif 'TESTING' in status_text:
status_value = Status.TESTING # status_value = Status.TESTING
elif 'LOST' in status_text: # elif 'LOST' in status_text:
status_value = Status.LOST # status_value = Status.LOST
elif 'DECLINED_TOO_EXPENSIVE' in status_text: # elif 'DECLINED_TOO_EXPENSIVE' in status_text:
status_value = Status.DECLINED # status_value = Status.DECLINED
declination_reason = DeclinationReason.TOO_EXPENSIVE # declination_reason = DeclinationReason.TOO_EXPENSIVE
elif 'USE_OTHER_PRODUCT' in status_text: # elif 'USE_OTHER_PRODUCT' in status_text:
status_value = Status.DECLINED # status_value = Status.DECLINED
declination_reason = DeclinationReason.USE_OTHER_PRODUCT # declination_reason = DeclinationReason.USE_OTHER_PRODUCT
elif 'USE_ANDROID' in status_text: # elif 'USE_ANDROID' in status_text:
status_value = Status.DECLINED # status_value = Status.DECLINED
declination_reason = DeclinationReason.USE_ANDROID # declination_reason = DeclinationReason.USE_ANDROID
elif 'NOK' in status_text: # elif 'NOK' in status_text:
status_value = Status.DECLINED # status_value = Status.DECLINED
declination_reason = DeclinationReason.UNKNOWN # declination_reason = DeclinationReason.UNKNOWN
elif 'DECLINED_UNRELATED' in status_text: # elif 'DECLINED_UNRELATED' in status_text:
status_value = Status.DECLINED_UNRELATED # status_value = Status.DECLINED_UNRELATED
activity = Activity.objects.create( # activity = Activity.objects.create(
type=ActivityType.SMS, # type=ActivityType.SMS,
attachment_text=attachment_text, # attachment_text=attachment_text,
status=status_value, # status=status_value,
declination_reason=declination_reason, # declination_reason=declination_reason,
description=f"Imported from CSV - Status: {status_text}" if status_text else "Imported from CSV" # description=f"Imported from CSV - Status: {status_text}" if status_text else "Imported from CSV"
) # )
activity.prospects.add(prospect) # activity.prospects.add(prospect)
created_events += 1 # created_events += 1
result = f"Successfully imported: {created_prospects} new prospects, {updated_prospects} updated prospects, {created_entities} new entities, {created_events} new events" result = f"Successfully imported: {created_prospects} new prospects, {updated_prospects} updated prospects, {created_entities} new entities, {created_events} new events"
return result return result
@ -357,9 +429,12 @@ class ProspectAdmin(SyncedObjectAdmin):
if form.is_valid(): if form.is_valid():
email_template = form.cleaned_data['email_template'] email_template = form.cleaned_data['email_template']
self.process_selected_items_with_template(request, queryset, email_template) sent_count, failed_count = self.process_selected_items_with_template(request, queryset, email_template)
self.message_user(request, f"Email sent to {queryset.count()} prospects using the '{email_template.name}' template.", messages.SUCCESS) 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)
return HttpResponseRedirect(request.get_full_path()) return HttpResponseRedirect(request.get_full_path())
else: else:
form = EmailTemplateSelectionForm() form = EmailTemplateSelectionForm()
@ -380,7 +455,11 @@ class ProspectAdmin(SyncedObjectAdmin):
logger.info(f'Sending email to {queryset.count()} users...') logger.info(f'Sending email to {queryset.count()} users...')
for prospect in queryset: for prospect in queryset:
mail_body = email_template.body.replace('{{name}}', prospect.first_name) 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)
all_emails.append(prospect.email) all_emails.append(prospect.email)
try: try:
@ -392,19 +471,38 @@ class ProspectAdmin(SyncedObjectAdmin):
fail_silently=False, fail_silently=False,
) )
sent_count += 1 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: except Exception as e:
error_emails.append(prospect.email) error_emails.append(prospect.email)
logger.error(f'Failed to send email to {prospect.email}: {str(e)}')
time.sleep(1) 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) @admin.register(Activity)
class ActivityAdmin(SyncedObjectAdmin): class ActivityAdmin(SyncedObjectAdmin):
list_display = ('prospect_names', 'creation_date', 'status', 'type', 'description', 'attachment_text', ) # raw_id_fields = ['prospects']
list_display = ('prospect_names', 'last_update', 'status', 'type', 'description', 'attachment_text', )
list_filter = ('status', 'type') list_filter = ('status', 'type')
search_fields = ('description',) search_fields = ('attachment_text',)
filter_horizontal = ('prospects',) date_hierarchy = 'last_update'
date_hierarchy = 'creation_date' autocomplete_fields = ['prospects', 'related_user']
def get_form(self, request, obj=None, **kwargs): def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs) form = super().get_form(request, obj, **kwargs)
@ -426,60 +524,11 @@ class ActivityAdmin(SyncedObjectAdmin):
return form 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): def get_event_display(self, obj):
return str(obj) return str(obj)
get_event_display.short_description = 'Activity' 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 dateutil.relativedelta import relativedelta
from .models import Activity, Prospect, Status, DeclinationReason from .models import Activity, Prospect, Status, DeclinationReason, ProspectGroup
User = get_user_model() User = get_user_model()
@ -110,6 +110,19 @@ class ProspectDeclineReasonFilter(admin.SimpleListFilter):
else: else:
return queryset 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): class ContactAgainFilter(admin.SimpleListFilter):
title = 'Contact again' # or whatever you want title = 'Contact again' # or whatever you want
parameter_name = 'contact_again' parameter_name = 'contact_again'
@ -126,3 +139,25 @@ class ContactAgainFilter(admin.SimpleListFilter):
# if self.value() == '0': # if self.value() == '0':
# return queryset.filter(my_field__isnull=True) # return queryset.filter(my_field__isnull=True)
return queryset 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

@ -0,0 +1,38 @@
# 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,
},
),
]

@ -0,0 +1,19 @@
# 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),
# ),
]

@ -0,0 +1,37 @@
# 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',
),
]

@ -0,0 +1,23 @@
# 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,11 +25,14 @@ class Status(models.TextChoices):
# DECLINED_UNRELATED = 'DECLINED_UNRELATED', 'Declined without significance' # DECLINED_UNRELATED = 'DECLINED_UNRELATED', 'Declined without significance'
NOT_CONCERNED = 'NOT_CONCERNED', 'Not concerned' NOT_CONCERNED = 'NOT_CONCERNED', 'Not concerned'
SHOULD_BUY = 'SHOULD_BUY', 'Should buy' SHOULD_BUY = 'SHOULD_BUY', 'Should buy'
HAVE_CREATED_ACCOUNT = 'HAVE_CREATED_ACCOUNT', 'Have created account'
class DeclinationReason(models.TextChoices): class DeclinationReason(models.TextChoices):
TOO_EXPENSIVE = 'TOO_EXPENSIVE', 'Too expensive' TOO_EXPENSIVE = 'TOO_EXPENSIVE', 'Too expensive'
USE_OTHER_PRODUCT = 'USE_OTHER_PRODUCT', 'Use other product' USE_OTHER_PRODUCT = 'USE_OTHER_PRODUCT', 'Use other product'
USE_ANDROID = 'USE_ANDROID', 'Use Android' USE_ANDROID = 'USE_ANDROID', 'Use Android'
TOO_FEW_TOURNAMENTS = 'TOO_FEW_TOURNAMENTS', 'Too few tournaments'
NOT_INTERESTED = 'NOT_INTERESTED', 'Not interested'
UNKNOWN = 'UNKNOWN', 'Unknown' UNKNOWN = 'UNKNOWN', 'Unknown'
class ActivityType(models.TextChoices): class ActivityType(models.TextChoices):
@ -38,6 +41,7 @@ class ActivityType(models.TextChoices):
CALL = 'CALL', 'Call' CALL = 'CALL', 'Call'
PRESS = 'PRESS', 'Press Release' PRESS = 'PRESS', 'Press Release'
WORD_OF_MOUTH = 'WORD_OF_MOUTH', 'Word of mouth' WORD_OF_MOUTH = 'WORD_OF_MOUTH', 'Word of mouth'
WHATS_APP = 'WHATS_APP', 'WhatsApp'
class Entity(BaseModel): class Entity(BaseModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
@ -85,6 +89,12 @@ class Prospect(BaseModel):
return last_activity.status return last_activity.status
return Status.NONE 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): def current_text(self):
last_activity = self.activities.exclude(status=None).order_by('-creation_date').first() last_activity = self.activities.exclude(status=None).order_by('-creation_date').first()
if last_activity: if last_activity:
@ -134,6 +144,11 @@ class Activity(BaseModel):
def delete_dependencies(self): def delete_dependencies(self):
pass 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: class Meta:
verbose_name_plural = "Activities" verbose_name_plural = "Activities"
ordering = ['-creation_date'] ordering = ['-creation_date']
@ -170,6 +185,20 @@ class EmailTemplate(BaseModel):
def delete_dependencies(self): def delete_dependencies(self):
pass 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): # class EmailCampaign(models.Model):
# event = models.OneToOneField(Event, on_delete=models.CASCADE) # event = models.OneToOneField(Event, on_delete=models.CASCADE)
# subject = models.CharField(max_length=200) # subject = models.CharField(max_length=200)

@ -0,0 +1,448 @@
{% 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,8 +3,9 @@
{% block object-tools-items %} {% block object-tools-items %}
{{ block.super }} {{ block.super }}
<li> <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_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: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> </li>
{% endblock %} {% endblock %}

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

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

@ -42,6 +42,7 @@ STRIPE_PUBLISHABLE_KEY = ''
STRIPE_SECRET_KEY = '' STRIPE_SECRET_KEY = ''
SHOP_STRIPE_WEBHOOK_SECRET = 'whsec_...' # Your existing webhook secret SHOP_STRIPE_WEBHOOK_SECRET = 'whsec_...' # Your existing webhook secret
TOURNAMENT_STRIPE_WEBHOOK_SECRET = 'whsec_...' # New webhook secret for tournaments 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 STRIPE_FEE = 0.0075
TOURNAMENT_SETTINGS = { TOURNAMENT_SETTINGS = {
'TIME_PROXIMITY_RULES': { 'TIME_PROXIMITY_RULES': {

@ -18,7 +18,7 @@ from django.urls import include, path
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static 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 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
urlpatterns = [ urlpatterns = [
@ -38,6 +38,11 @@ urlpatterns = [
path('kingdom/', admin.site.urls), path('kingdom/', admin.site.urls),
path('api-auth/', include('rest_framework.urls')), path('api-auth/', include('rest_framework.urls')),
path('dj-auth/', include('django.contrib.auth.urls')), path('dj-auth/', include('django.contrib.auth.urls')),
path(
"kingdom/debug/gather-monthly-umpires/",
gather_monthly_tournaments_and_umpires,
name="gather_monthly_umpires",
),
] ]

@ -0,0 +1,21 @@
### 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): class SyncedObjectAdmin(admin.ModelAdmin):
exclude = ('data_access_ids',) exclude = ('data_access_ids',)
raw_id_fields = ['related_user'] raw_id_fields = ['related_user', 'last_updated_by']
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
if isinstance(obj, BaseModel): if isinstance(obj, BaseModel):
obj.last_updated_by = request.user obj.last_updated_by = request.user
obj.last_update = timezone.now() # obj.last_update = timezone.now()
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)
def delete_model(self, request, obj): def delete_model(self, request, obj):
@ -24,15 +24,14 @@ class SyncedObjectAdmin(admin.ModelAdmin):
queryset.delete() queryset.delete()
class ModelLogAdmin(admin.ModelAdmin): class ModelLogAdmin(admin.ModelAdmin):
list_display = ['user', 'formatted_time', 'operation', 'model_id', 'model_name', 'count'] list_display = ['user', 'formatted_time', 'model_name', 'operation', 'model_id', 'device_id']
list_filter = ['user', 'operation', 'model_name'] list_filter = ['operation', 'model_name', 'user']
ordering = ['-date'] ordering = ['-date']
search_fields = ['model_id'] search_fields = ['model_id']
readonly_fields = ['date'] readonly_fields = ['date']
class DataAccessAdmin(SyncedObjectAdmin): class DataAccessAdmin(SyncedObjectAdmin):
list_display = ['related_user', 'get_shared_users', 'model_name', 'model_id'] list_display = ['id', 'related_user', 'get_shared_users', 'model_name', 'model_id']
list_filter = ['related_user', 'shared_with'] list_filter = ['related_user', 'shared_with']
ordering = ['-granted_at'] ordering = ['-granted_at']

@ -116,7 +116,7 @@ class SyncModelChildrenManager:
str or None: The reverse relationship name if found 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: try:
for field in for_model._meta.get_fields(): for field in for_model._meta.get_fields():
# Check ForeignKey, OneToOneField fields # Check ForeignKey, OneToOneField fields

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

@ -77,7 +77,7 @@ class DataAccess(BaseModel):
with transaction.atomic(): with transaction.atomic():
for instance in related_instance: 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): if isinstance(instance, BaseModel):
instance.add_data_access_relation(self) instance.add_data_access_relation(self)
instance.save() instance.save()

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

@ -8,7 +8,7 @@ from authentication.models import Device
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from .ws_sender import websocket_sender from .ws_sender import websocket_sender
from .registry import device_registry, related_users_registry from .registry import device_registry, related_users_registry, model_registry
import logging import logging
import traceback import traceback
@ -24,7 +24,7 @@ def presave_handler(sender, instance, **kwargs):
try: try:
# some other classes are excluded in settings_app.py: SYNC_APPS # some other classes are excluded in settings_app.py: SYNC_APPS
if not isinstance(instance, (BaseModel, User)) or isinstance(instance, DataAccess): if not isinstance(instance, (BaseModel, User)):
return return
signal = kwargs.get('signal') signal = kwargs.get('signal')
@ -54,11 +54,17 @@ def synchronization_notifications(sender, instance, created=False, **kwargs):
if not isinstance(instance, BaseModel) and not isinstance(instance, User): if not isinstance(instance, BaseModel) and not isinstance(instance, User):
return return
model_name = instance.__class__.__name__
if model_registry.get_model(model_name) is None:
return
try: try:
process_foreign_key_changes(sender, instance, **kwargs) process_foreign_key_changes(sender, instance, **kwargs)
signal = kwargs.get('signal') signal = kwargs.get('signal')
save_model_log_if_possible(instance, signal, created) save_model_log_if_possible(instance, signal, created)
notify_impacted_users(instance) notify_impacted_users(instance)
# print(f'!!!!! related_users_registry.unregister for {instance.__class__.__name__} / {signal}')
related_users_registry.unregister(instance.id) related_users_registry.unregister(instance.id)
except Exception as e: except Exception as e:
logger.info(f'*** ERROR2: {e}') logger.info(f'*** ERROR2: {e}')
@ -68,6 +74,7 @@ def synchronization_notifications(sender, instance, created=False, **kwargs):
def notify_impacted_users(instance): def notify_impacted_users(instance):
device_id = device_registry.get_device_id(instance.id) device_id = device_registry.get_device_id(instance.id)
users = related_users_registry.get_users(instance.id) users = related_users_registry.get_users(instance.id)
logger.info(f'>>> notify_impacted_users: {users} for {instance.id}')
if users: if users:
user_ids = [user.id for user in users] user_ids = [user.id for user in users]
@ -95,7 +102,6 @@ def save_model_log_if_possible(instance, signal, created):
else: else:
operation = ModelOperation.DELETE operation = ModelOperation.DELETE
model_name = instance.__class__.__name__
store_id = None store_id = None
if isinstance(instance, SideStoreModel): if isinstance(instance, SideStoreModel):
store_id = instance.store_id store_id = instance.store_id
@ -106,6 +112,7 @@ def save_model_log_if_possible(instance, signal, created):
# user_ids = [user.id for user in users] # user_ids = [user.id for user in users]
# # print(f'users to notify: {user_ids}') # # print(f'users to notify: {user_ids}')
# instance._users_to_notify = user_ids # save this for the post_save signal # 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) save_model_log(users, operation, model_name, instance.id, store_id)
else: else:
@ -119,19 +126,18 @@ def save_model_log(users, model_operation, model_name, model_id, store_id):
with transaction.atomic(): with transaction.atomic():
created_logs = [] created_logs = []
for user in users: 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()}')
# logger.info(f'Creating ModelLog for user {user.id} - user exists: {User.objects.filter(id=user.id).exists()}') model_log = ModelLog(
model_log = ModelLog( user=user,
user=user, operation=model_operation,
operation=model_operation, model_name=model_name,
model_name=model_name, model_id=model_id,
model_id=model_id, store_id=store_id,
store_id=store_id, device_id=device_id
device_id=device_id )
) model_log.save()
model_log.save() # logger.info(f'ModelLog saved with ID: {model_log.id}')
# logger.info(f'ModelLog saved with ID: {model_log.id}') created_logs.append(model_log.id)
created_logs.append(model_log.id)
# Immediate verification within transaction # Immediate verification within transaction
# immediate_count = ModelLog.objects.filter(id__in=created_logs).count() # immediate_count = ModelLog.objects.filter(id__in=created_logs).count()
@ -268,6 +274,8 @@ def handle_shared_with_changes(sender, instance, action, pk_set, **kwargs):
# print(f'm2m changed = {pk_set}') # print(f'm2m changed = {pk_set}')
users = User.objects.filter(id__in=pk_set) users = User.objects.filter(id__in=pk_set)
save_model_log(users, ModelOperation.PUT, DataAccess.__name__, instance.id, None)
with transaction.atomic(): with transaction.atomic():
if action == "post_add": if action == "post_add":
instance.create_access_log(users, 'SHARED_ACCESS') instance.create_access_log(users, 'SHARED_ACCESS')
@ -275,6 +283,7 @@ def handle_shared_with_changes(sender, instance, action, pk_set, **kwargs):
instance.create_access_log(users, 'REVOKED_ACCESS') instance.create_access_log(users, 'REVOKED_ACCESS')
device_id = device_registry.get_device_id(instance.id) 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) websocket_sender.send_message(pk_set, device_id)
# for user_id in pk_set: # for user_id in pk_set:
@ -298,9 +307,12 @@ def data_access_post_save(sender, instance, **kwargs):
@receiver(pre_delete, sender=DataAccess) @receiver(pre_delete, sender=DataAccess)
def revoke_access_after_delete(sender, instance, **kwargs): def revoke_access_after_delete(sender, instance, **kwargs):
# logger.info(f'.........PRE_DELETE DATAACCESS = {instance.id}..........')
try: try:
instance.cleanup_references() instance.cleanup_references()
instance.create_revoke_access_log() 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()) related_users_registry.register(instance.id, instance.shared_with.all())
instance._user = instance.related_user instance._user = instance.related_user
@ -321,6 +333,8 @@ def data_access_post_delete(sender, instance, **kwargs):
logger.info(f'*** ERROR5: {e}') logger.info(f'*** ERROR5: {e}')
logger.info(traceback.format_exc()) logger.info(traceback.format_exc())
raise raise
# logger.info(f'.........POST_DELETE END DATAACCESS = {instance.id}..........')
def related_users(instance): def related_users(instance):
users = set() users = set()
@ -333,8 +347,8 @@ def related_users(instance):
for data_access in data_access_list: for data_access in data_access_list:
users.add(data_access.related_user) users.add(data_access.related_user)
users.update(data_access.shared_with.all()) users.update(data_access.shared_with.all())
if isinstance(instance, DataAccess):
users.update(instance.shared_with.all()) # print(f'find users for {instance.__class__.__name__}, count = {len(users)}')
return {user for user in users if user is not None} 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): def get_serialized_data_by_id(model_name, model_id):
# print(f'model_name = {model_name}') # print(f'model_name = {model_name}')
model = model_registry.get_model(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) instance = model.objects.get(id=model_id)
serializer = get_serializer(instance, model_name) serializer = get_serializer(instance, model_name)
return serializer.data return serializer.data
@ -101,10 +101,11 @@ class HierarchyOrganizer:
self.add_related_children(instance) self.add_related_children(instance)
def add_related_children(self, instance): def add_related_children(self, instance):
instance.get_shared_children(self.children) self.children = instance.get_shared_children(set())
def grouped_children(self): def grouped_children(self):
grouped = defaultdict(list) grouped = defaultdict(list)
for instance in self.children: for instance in self.children:
class_name = instance.__class__.__name__ class_name = instance.__class__.__name__
grouped[class_name].append(instance.data_identifier_dict()) 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}"}, return Response({"error": f"Invalid date format for last_update: {decoded_last_update}"},
status=status.HTTP_400_BAD_REQUEST) status=status.HTTP_400_BAD_REQUEST)
print(f'>>> GET last modifications since: {last_update_str} / converted = {last_update}') print(f'>>> {request.user.username} : GET last modifications since: {last_update_str} / converted = {last_update}')
device_id = request.query_params.get('device_id') device_id = request.query_params.get('device_id')
logs = self.query_model_logs(last_update, request.user, device_id) logs = self.query_model_logs(last_update, request.user, device_id)
@ -287,7 +287,6 @@ class LogProcessingResult:
def process_logs(self, logs): def process_logs(self, logs):
"""Process logs to collect basic operations and handle grant/revoke efficiently.""" """Process logs to collect basic operations and handle grant/revoke efficiently."""
for log in logs: for log in logs:
self.last_log_date = log.date
try: try:
if log.operation in ['POST', 'PUT', 'RELATIONSHIP_SET']: if log.operation in ['POST', 'PUT', 'RELATIONSHIP_SET']:
data = get_serialized_data_by_id(log.model_name, log.model_id) data = get_serialized_data_by_id(log.model_name, log.model_id)
@ -324,7 +323,10 @@ class LogProcessingResult:
self.shared_relationship_sets[log.model_name][log.model_id] = data self.shared_relationship_sets[log.model_name][log.model_id] = data
elif log.operation == 'SHARED_RELATIONSHIP_REMOVED': elif log.operation == 'SHARED_RELATIONSHIP_REMOVED':
self.shared_relationship_removals[log.model_name].append(log.data_identifier_dict()) 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: except ObjectDoesNotExist:
logger.warning(f'log processing failed, unable to find {log.model_name} : {log.model_id}')
pass pass
# Convert updates dict to list for each model # Convert updates dict to list for each model
@ -387,17 +389,17 @@ class LogProcessingResult:
# First, collect all revocations # First, collect all revocations
for model_name, items in self.revoke_info.items(): for model_name, items in self.revoke_info.items():
revocations[model_name].extend(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 # Process parent hierarchies for each revoked item
model = model_registry.get_model(model_name) model = model_registry.get_model(model_name)
for item in items: for item in items:
logger.info(f'$$$ item revoked = {item}') # logger.info(f'$$$ item revoked = {item}')
try: try:
instance = model.objects.get(id=item['model_id']) 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) revocated_relations_organizer.add_relations(instance)
except model.DoesNotExist: except model.DoesNotExist:
@ -413,6 +415,8 @@ class LogProcessingResult:
shared, grants = self.process_shared() shared, grants = self.process_shared()
revocations, revocated_relations_organizer = self.process_revocations() 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.deletions = {dict(self.deletions)}')
# print(f'self.shared_relationship_sets = {self.shared_relationship_sets}') # print(f'self.shared_relationship_sets = {self.shared_relationship_sets}')
# print(f'self.shared_relationship_removals = {self.shared_relationship_removals}') # print(f'self.shared_relationship_removals = {self.shared_relationship_removals}')

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

@ -1,19 +1,24 @@
from django.contrib import admin from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
from django.utils import timezone from django.utils import timezone
from django.contrib.admin.models import LogEntry, ADDITION, CHANGE, DELETION from django.contrib.admin.models import LogEntry, ADDITION, CHANGE, DELETION
from django.utils.html import escape from django.utils.html import escape
from django.urls import reverse, path # Add path import from django.urls import reverse, path
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.shortcuts import render # Add this import from django.shortcuts import render
from django.db.models import Sum, Count, Avg, Q # Add these imports from django.db.models import Avg, Count
from datetime import datetime, timedelta # Add this import from datetime import timedelta, datetime
from biz.models import Prospect, ProspectGroup
from .models import Club, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, Court, DateInterval, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer, Image 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 .forms import CustomUserCreationForm, CustomUserChangeForm
from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter, StartDateRangeFilter from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter, StartDateRangeFilter, UserWithEventsFilter, UserWithPurchasesFilter, UserWithProspectFilter, TeamScoreRoundIndexFilter
from sync.admin import SyncedObjectAdmin from sync.admin import SyncedObjectAdmin
import logging
logger = logging.getLogger(__name__)
class CustomUserAdmin(UserAdmin): class CustomUserAdmin(UserAdmin):
form = CustomUserChangeForm form = CustomUserChangeForm
@ -21,20 +26,21 @@ class CustomUserAdmin(UserAdmin):
model = CustomUser model = CustomUser
search_fields = ['username', 'email', 'phone', 'first_name', 'last_name', 'licence_id'] search_fields = ['username', 'email', 'phone', 'first_name', 'last_name', 'licence_id']
filter_horizontal = ('clubs',) 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_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'] list_filter = ['is_active', 'origin', UserWithEventsFilter, UserWithPurchasesFilter, UserWithProspectFilter]
ordering = ['-date_joined'] ordering = ['-date_joined']
raw_id_fields = ['agents'] autocomplete_fields = ['supervisors', 'organizers']
fieldsets = [ fieldsets = [
(None, {'fields': ['id', 'username', 'email', 'password', 'first_name', 'last_name', 'is_active']}), (None, {'fields': ['id', 'username', 'email', 'password', 'first_name', 'last_name', 'is_active', 'date_joined']}),
('Permissions', {'fields': ['is_staff', 'is_superuser', 'groups', 'user_permissions']}), ('Permissions', {'fields': ['is_staff', 'is_superuser', 'groups', 'user_permissions']}),
('Personal Info', {'fields': ['registration_payment_mode', 'clubs', 'country', 'phone', 'licence_id', 'umpire_code']}), ('Personal Info', {'fields': ['registration_payment_mode', 'clubs', 'country', 'phone', 'licence_id', 'umpire_code']}),
('Tournament Settings', {'fields': [ ('Tournament Settings', {'fields': [
'summons_message_body', 'summons_message_signature', 'summons_available_payment_methods', 'summons_message_body', 'summons_message_signature', 'summons_available_payment_methods',
'summons_display_format', 'summons_display_entry_fee', 'summons_use_full_custom_message', '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', 'match_formats_default_duration', 'bracket_match_format_preference', 'group_stage_match_format_preference',
'loser_bracket_match_format_preference', 'device_id', 'loser_bracket_mode', 'origin', 'agents', 'should_synchronize', 'can_synchronize' 'loser_bracket_match_format_preference', 'device_id', 'loser_bracket_mode', 'origin', 'supervisors', 'organizers', 'should_synchronize', 'can_synchronize'
]}), ]}),
] ]
@ -52,13 +58,66 @@ class CustomUserAdmin(UserAdmin):
obj.last_update = timezone.now() obj.last_update = timezone.now()
super().save_model(request, obj, form, change) 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): class EventAdmin(SyncedObjectAdmin):
list_display = ['creation_date', 'name', 'club', 'creator', 'creator_full_name', 'tenup_id', 'display_images'] list_display = ['creation_date', 'name', 'club', 'creator', 'tenup_id', 'display_images']
list_filter = ['creator', 'club', 'tenup_id'] list_filter = ['creator', 'club', 'tenup_id']
search_fields = ['name', 'club__name', 'creator__email'] search_fields = ['name', 'club__name', 'creator__email']
raw_id_fields = ['creator', 'club'] raw_id_fields = ['related_user', 'creator', 'club']
ordering = ['-creation_date'] ordering = ['-creation_date']
readonly_fields = ['display_images_preview'] readonly_fields = ['display_images_preview']
actions = ['set_club_action']
fieldsets = [ fieldsets = [
(None, {'fields': ['last_update', 'related_user', 'name', 'club', 'creator', 'creation_date', 'tenup_id']}), (None, {'fields': ['last_update', 'related_user', 'name', 'club', 'creator', 'creation_date', 'tenup_id']}),
@ -88,11 +147,80 @@ class EventAdmin(SyncedObjectAdmin):
return mark_safe(html) return mark_safe(html)
display_images_preview.short_description = 'Images Preview' 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): class TournamentAdmin(SyncedObjectAdmin):
list_display = ['display_name', 'event', 'is_private', 'start_date', 'payment', 'creator', 'is_canceled'] list_display = ['display_name', 'event', 'is_private', 'start_date', 'payment', 'creator', 'is_canceled']
list_filter = [StartDateRangeFilter, 'is_deleted', 'event__creator'] list_filter = [StartDateRangeFilter, 'is_deleted', 'event__creator']
ordering = ['-start_date'] ordering = ['-start_date']
search_fields = ['id', 'federal_level_category'] search_fields = ['id', 'federal_level_category']
raw_id_fields = ['last_updated_by', 'event']
def dashboard_view(self, request): def dashboard_view(self, request):
"""Tournament dashboard view with comprehensive statistics""" """Tournament dashboard view with comprehensive statistics"""
@ -181,6 +309,10 @@ class TournamentAdmin(SyncedObjectAdmin):
avg_teams=Avg('tournament__team_count') avg_teams=Avg('tournament__team_count')
)['avg_teams'] or 0 )['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_entry_fee = Tournament.objects.exclude(is_deleted=True).aggregate(
avg_fee=Avg('entry_fee') avg_fee=Avg('entry_fee')
)['avg_fee'] or 0 )['avg_fee'] or 0
@ -265,6 +397,7 @@ class TournamentAdmin(SyncedObjectAdmin):
'tournaments_with_payment': tournaments_with_payment, 'tournaments_with_payment': tournaments_with_payment,
'avg_teams_per_tournament': round(avg_teams_per_tournament, 1), 'avg_teams_per_tournament': round(avg_teams_per_tournament, 1),
'avg_entry_fee': round(avg_entry_fee, 2), 'avg_entry_fee': round(avg_entry_fee, 2),
'email_count': email_count,
# User statistics # User statistics
'total_users': total_users, 'total_users': total_users,
@ -297,12 +430,13 @@ class TeamRegistrationAdmin(SyncedObjectAdmin):
list_display = ['player_names', 'group_stage', 'name', 'tournament', 'registration_date'] list_display = ['player_names', 'group_stage', 'name', 'tournament', 'registration_date']
list_filter = [SimpleTournamentListFilter] list_filter = [SimpleTournamentListFilter]
search_fields = ['id'] search_fields = ['id']
raw_id_fields = ['related_user', 'tournament']
class TeamScoreAdmin(SyncedObjectAdmin): class TeamScoreAdmin(SyncedObjectAdmin):
list_display = ['team_registration', 'score', 'walk_out', 'match'] list_display = ['team_registration', 'score', 'walk_out', 'match']
list_filter = [TeamScoreTournamentListFilter] list_filter = [TeamScoreRoundIndexFilter, TeamScoreTournamentListFilter]
search_fields = ['id', 'team_registration__player_registrations__first_name', 'team_registration__player_registrations__last_name'] search_fields = ['id', 'team_registration__player_registrations__first_name', 'team_registration__player_registrations__last_name']
raw_id_fields = ['team_registration', 'match'] # Add this line raw_id_fields = ['team_registration', 'match']
list_per_page = 50 # Controls pagination on the list view list_per_page = 50 # Controls pagination on the list view
def get_queryset(self, request): def get_queryset(self, request):
@ -359,6 +493,7 @@ class PurchaseAdmin(SyncedObjectAdmin):
list_display = ['id', 'user', 'product_id', 'quantity', 'purchase_date', 'revocation_date', 'expiration_date'] list_display = ['id', 'user', 'product_id', 'quantity', 'purchase_date', 'revocation_date', 'expiration_date']
list_filter = ['user'] list_filter = ['user']
ordering = ['-purchase_date'] ordering = ['-purchase_date']
raw_id_fields = ['user']
class CourtAdmin(SyncedObjectAdmin): class CourtAdmin(SyncedObjectAdmin):
list_display = ['index', 'name', 'club'] list_display = ['index', 'name', 'club']

File diff suppressed because it is too large Load Diff

@ -43,6 +43,13 @@ class CustomLoginView(auth_views.LoginView):
return context return context
def get(self, request, *args, **kwargs): 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 # Clear any potential password reset session data
keys_to_clear = [key for key in request.session.keys() keys_to_clear = [key for key in request.session.keys()
if 'reset' in key or 'password' in key] if 'reset' in key or 'password' in key]

@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from .models import Tournament, Match from .models import Tournament, Match, Round
from django.db.models import Q from django.db.models import Q, Count
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
@ -135,3 +135,82 @@ class StartDateRangeFilter(admin.SimpleListFilter):
start_date__gte=today - timedelta(days=3), start_date__gte=today - timedelta(days=3),
start_date__lte=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): class TournamentRegistrationForm(forms.Form):
#first_name = forms.CharField(label='Prénom', max_length=50) #first_name = forms.CharField(label='Prénom', max_length=50)
#last_name = forms.CharField(label='Nom', max_length=50) #last_name = forms.CharField(label='Nom', max_length=50)
email = forms.EmailField(label='E-mail', widget=forms.EmailInput(attrs={'readonly': 'readonly'})) email = forms.EmailField(label='E-mail')
mobile_number = forms.CharField( mobile_number = forms.CharField(
label='Téléphone', label='Téléphone',
max_length=15, max_length=15,

@ -1,53 +0,0 @@
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

@ -0,0 +1,28 @@
# 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),
),
]

@ -0,0 +1,19 @@
# 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),
),
]

@ -0,0 +1,18 @@
# 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,6 +27,8 @@ class Club(BaseModel):
broadcast_code = models.CharField(max_length=10, null=True, blank=True, unique=True) broadcast_code = models.CharField(max_length=10, null=True, blank=True, unique=True)
admin_visible = models.BooleanField(default=False) admin_visible = models.BooleanField(default=False)
sharable = False
def delete_dependencies(self): def delete_dependencies(self):
for court in self.courts.all(): for court in self.courts.all():
# court.delete_dependencies() # court.delete_dependencies()

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

@ -85,10 +85,14 @@ class PlayerRegistration(TournamentSubModel):
return "Anonyme" return "Anonyme"
name = self.name() name = self.name()
if (len(name) > 20 or forced) and self.first_name: if (len(name) > 20 or forced) and self.first_name:
name = f"{self.first_name[0]}. {self.last_name}" if self.last_name is None:
if len(name) > 20 or forced: 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:
name_parts = self.last_name.split(" ") name_parts = self.last_name.split(" ")
name = f"{self.first_name[0]}. {name_parts[0]}" 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 return name
def clean_club_name(self): def clean_club_name(self):
@ -134,17 +138,18 @@ class PlayerRegistration(TournamentSubModel):
return None return None
tournament = self.team_registration.tournament tournament = self.team_registration.tournament
tournament_status_team_count = tournament.get_tournament_status_team_count() tournament_status_team_count = tournament.get_tournament_status_registration_count()
# If custom animation type, replace header by "Inscriptions" # If custom animation type, replace header by "Inscriptions"
if tournament.is_custom_animation(): if tournament.is_team_tournament():
header = "Inscriptions"
else:
header = "Équipes" header = "Équipes"
else:
header = "Inscriptions"
if tournament.is_canceled(): if tournament.is_canceled():
return { return {
'header': header, 'header': header,
'position': tournament_status_team_count, 'position': tournament_status_team_count,
'is_team_tournament': tournament.is_team_tournament(),
'display_box': True, 'display_box': True,
'box_class': 'light-red', 'box_class': 'light-red',
'short_label': 'annulé' 'short_label': 'annulé'
@ -153,6 +158,7 @@ class PlayerRegistration(TournamentSubModel):
status = { status = {
'header': header, 'header': header,
'position': tournament_status_team_count, 'position': tournament_status_team_count,
'is_team_tournament': True,
'display_box': True, 'display_box': True,
'box_class': 'gray', 'box_class': 'gray',
'short_label': 'inscrit' 'short_label': 'inscrit'
@ -224,3 +230,13 @@ class PlayerRegistration(TournamentSubModel):
return self.team_registration.tournament.entry_fee return self.team_registration.tournament.entry_fee
else: else:
return 0 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,6 +13,15 @@ class Round(TournamentSubModel):
group_stage_loser_bracket = models.BooleanField(default=False) group_stage_loser_bracket = models.BooleanField(default=False)
loser_bracket_mode = models.IntegerField(default=0) 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): def delete_dependencies(self):
for round in self.children.all(): for round in self.children.all():
round.delete_dependencies() round.delete_dependencies()
@ -112,16 +121,35 @@ class Round(TournamentSubModel):
return True return True
def prepare_match_group(self, next_round, parent_round, loser_final, double_butterfly_mode, secondHalf): 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') 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: if len(matches) == 0:
Round.debug_print(f"[{self.name()}] No matches, returning None")
return None return None
if next_round: if next_round:
next_round_matches = next_round.matches.filter(disabled=False).order_by('index') 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: else:
next_round_matches = [] next_round_matches = []
Round.debug_print(f"[{self.name()}] No next round")
if len(matches) < len(next_round_matches): 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') 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 = [] filtered_matches = []
# Process matches in pairs # Process matches in pairs
@ -131,52 +159,127 @@ class Round(TournamentSubModel):
current_match = all_matches[i] current_match = all_matches[i]
pair_match = all_matches[i+1] if i+1 < len(all_matches) else None 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 # Only filter out the pair if both matches are disabled
if current_match.disabled and pair_match and pair_match.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 # Skip one of the matches in the pair
if next_round_matches.filter(index=current_match.index // 2).exists(): if next_round_matches.filter(index=current_match.index // 2).exists():
filtered_matches.append(current_match) filtered_matches.append(current_match)
filtered_matches.append(pair_match) # filtered_matches.append(pair_match)
pass # 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")
else: else:
# Keep the current match # Keep the current match
if current_match.disabled == False: if current_match.disabled == False:
filtered_matches.append(current_match) 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 there's a pair match, keep it too
if pair_match and pair_match.disabled == False: if pair_match and pair_match.disabled == False:
filtered_matches.append(pair_match) filtered_matches.append(pair_match)
Round.debug_print(f"[{self.name()}] Keeping pair match {pair_match.index}")
# Move to the next pair # Move to the next pair
i += 2 i += 2
# Replace the matches list with our filtered list # Replace the matches list with our filtered list
matches = filtered_matches matches = filtered_matches
Round.debug_print(f"[{self.name()}] After filtering: {len(matches)} matches - indices: {[m.index for m in matches]}")
if matches: if matches:
if len(matches) > 1 and double_butterfly_mode: if len(matches) > 1 and double_butterfly_mode:
midpoint = int(len(matches) / 2) 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}")
first_half_matches = matches[:midpoint] first_half_matches = matches[:midpoint]
if secondHalf: if secondHalf:
first_half_matches = matches[midpoint:] 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: else:
Round.debug_print(f"[{self.name()}] NO SPLITTING: singleButterfly or single match")
first_half_matches = list(matches) # Convert QuerySet to a list 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: if self.index == 0 and loser_final:
loser_match = loser_final.matches.first() loser_match = loser_final.matches.first()
if loser_match: if loser_match:
first_half_matches.append(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: if first_half_matches:
name = self.plural_name() name = self.plural_name()
if parent_round and first_half_matches[0].name is not None: if parent_round and first_half_matches[0].name is not None:
name = first_half_matches[0].name 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( match_group = self.tournament.create_match_group(
name=name, name=name,
matches=first_half_matches, matches=first_half_matches,
round_id=self.id, round_id=self.id,
round_index=self.index, round_index=self.index,
short_names=double_butterfly_mode short_names=short_names
) )
Round.debug_print(f"[{self.name()}] === END prepare_match_group - SUCCESS ===\n")
return match_group return match_group
Round.debug_print(f"[{self.name()}] === END prepare_match_group - NO MATCHES ===\n")
return None return None

@ -37,6 +37,7 @@ class TeamRegistration(TournamentSubModel):
final_ranking = models.IntegerField(null=True, blank=True) final_ranking = models.IntegerField(null=True, blank=True)
points_earned = models.IntegerField(null=True, blank=True) points_earned = models.IntegerField(null=True, blank=True)
unique_random_index = models.IntegerField(default=0) unique_random_index = models.IntegerField(default=0)
user_canceled_registration = False
def delete_dependencies(self): def delete_dependencies(self):
for player_registration in self.player_registrations.all(): for player_registration in self.player_registrations.all():
@ -108,7 +109,7 @@ class TeamRegistration(TournamentSubModel):
def formatted_team_names(self): def formatted_team_names(self):
if self.name: if self.name:
return self.name return self.name
names = [pr.last_name for pr in self.players_sorted_by_rank][:2] # Take max first 2 names = [pr.get_last_name() for pr in self.players_sorted_by_rank][:2] # Take max first 2
joined_names = " / ".join(names) joined_names = " / ".join(names)
if joined_names: if joined_names:
return f"Paire {joined_names}" return f"Paire {joined_names}"
@ -550,3 +551,20 @@ class TeamRegistration(TournamentSubModel):
# Check payment status for each player # Check payment status for each player
payment_statuses = [player.get_player_registration_fee() for player in player_registrations] payment_statuses = [player.get_player_registration_fee() for player in player_registrations]
return sum(payment_statuses) 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,6 +96,9 @@ class Tournament(BaseModel):
club_member_fee_deduction = models.FloatField(null=True, blank=True) club_member_fee_deduction = models.FloatField(null=True, blank=True)
unregister_delta_in_hours = models.IntegerField(default=24) unregister_delta_in_hours = models.IntegerField(default=24)
currency_code = models.CharField(null=True, blank=True, max_length=3, default='EUR') 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): def delete_dependencies(self):
for team_registration in self.team_registrations.all(): for team_registration in self.team_registrations.all():
@ -204,6 +207,13 @@ class Tournament(BaseModel):
timezone = self.timezone() timezone = self.timezone()
return self.start_date.astimezone(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): def local_start_date_formatted(self):
return formats.date_format(self.local_start_date(), format='l j F Y H:i').capitalize() return formats.date_format(self.local_start_date(), format='l j F Y H:i').capitalize()
@ -225,7 +235,7 @@ class Tournament(BaseModel):
case AnimationType.CONSOLATION_BRACKET: case AnimationType.CONSOLATION_BRACKET:
return "Consolante" return "Consolante"
case AnimationType.CUSTOM: case AnimationType.CUSTOM:
return "Spécial" return "Soirée"
case _: case _:
return "Anim." return "Anim."
if self.federal_level_category == 1: if self.federal_level_category == 1:
@ -295,8 +305,19 @@ class Tournament(BaseModel):
def get_tournament_status(self): def get_tournament_status(self):
return self.get_online_registration_status().status_localized() return self.get_online_registration_status().status_localized()
def get_tournament_status_team_count(self): def is_team_tournament(self):
return self.minimum_player_per_team >= 2
def get_tournament_status_registration_count(self):
active_teams_count = self.team_registrations.filter(walk_out=False).count() 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) return min(active_teams_count, self.team_count)
def name_and_event(self): def name_and_event(self):
@ -1225,7 +1246,10 @@ class Tournament(BaseModel):
# Entry fee # Entry fee
if self.entry_fee is not None and self.entry_fee > 0: if self.entry_fee is not None and self.entry_fee > 0:
formatted_fee = currency_service.format_amount(self.entry_fee, self.currency_code) formatted_fee = currency_service.format_amount(self.entry_fee, self.currency_code)
options.append(f"Frais d'inscription: {formatted_fee} par joueur") 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 # Club member fee reduction
if self.club_member_fee_deduction and self.club_member_fee_deduction > 0: if self.club_member_fee_deduction and self.club_member_fee_deduction > 0:
@ -1502,7 +1526,13 @@ class Tournament(BaseModel):
if is_woman is not None and self.federal_category == FederalCategory.WOMEN and is_woman is False: 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") reasons.append("Ce tournoi est réservé aux femmes")
if birth_year is None: if birth_year is None or birth_year == 'N/A':
return reasons if reasons else None
try:
tournament_start_year = self.season_year()
user_age = tournament_start_year - int(birth_year)
except (ValueError, TypeError):
return reasons if reasons else None return reasons if reasons else None
tournament_start_year = self.season_year() tournament_start_year = self.season_year()
@ -1735,12 +1765,16 @@ class Tournament(BaseModel):
def umpire_mail(self): def umpire_mail(self):
if self.umpire_custom_mail is not None: if self.umpire_custom_mail is not None:
return self.umpire_custom_mail return self.umpire_custom_mail
return self.event.creator.email if self.event and self.event.creator:
return self.event.creator.email
return None
def umpire_phone(self): def umpire_phone(self):
if self.umpire_custom_phone is not None: if self.umpire_custom_phone is not None:
return self.umpire_custom_phone return self.umpire_custom_phone
return self.event.creator.phone if self.event and self.event.creator:
return self.event.creator.phone
return None
def calculate_time_to_confirm(self, waiting_list_count): def calculate_time_to_confirm(self, waiting_list_count):
""" """
@ -1861,7 +1895,10 @@ class Tournament(BaseModel):
second=0, second=0,
microsecond=0 microsecond=0
) )
raw_deadline += timezone.timedelta(minutes=minutes_to_confirm)
print(f"Before hours: {before_hours}, After hours: {after_hours}") 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) tournament_start_date_minus_five = tournament_start_date - timedelta(minutes=5)
if raw_deadline >= tournament_start_date_minus_five: if raw_deadline >= tournament_start_date_minus_five:
@ -2140,7 +2177,7 @@ class Tournament(BaseModel):
for match in planned_matches: for match in planned_matches:
# Convert to local time zone # Convert to local time zone
local_date = timezone.localtime(match.planned_start_date) local_date = match.local_planned_start_date()
day_key = local_date.date() day_key = local_date.date()
days.add(day_key) days.add(day_key)
@ -2165,7 +2202,7 @@ class Tournament(BaseModel):
# Group matches by hour # Group matches by hour
matches_by_hour = {} matches_by_hour = {}
for match in matches_by_day[selected_day]: for match in matches_by_day[selected_day]:
local_time = timezone.localtime(match.planned_start_date) local_time = match.local_planned_start_date()
hour_key = local_time.strftime('%H:%M') hour_key = local_time.strftime('%H:%M')
if hour_key not in matches_by_hour: if hour_key not in matches_by_hour:
@ -2206,7 +2243,7 @@ class Tournament(BaseModel):
# Group matches by hour # Group matches by hour
matches_by_hour = {} matches_by_hour = {}
for match in matches_by_day[selected_day]: for match in matches_by_day[selected_day]:
local_time = timezone.localtime(match.planned_start_date) local_time = match.local_planned_start_date()
hour_key = local_time.strftime('%H:%M') hour_key = local_time.strftime('%H:%M')
if hour_key not in matches_by_hour: if hour_key not in matches_by_hour:

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

@ -4,8 +4,10 @@ from django.urls import reverse
from django.http import HttpResponse from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from django.db import transaction
import stripe import stripe
from datetime import datetime, timedelta from datetime import datetime, timedelta
import traceback
from ..models import TeamRegistration, PlayerRegistration, Tournament from ..models import TeamRegistration, PlayerRegistration, Tournament
from ..models.player_registration import PlayerPaymentType from ..models.player_registration import PlayerPaymentType
@ -76,6 +78,8 @@ class PaymentService:
if not team_registration: if not team_registration:
print(f"[TOURNAMENT PAYMENT] Failed to create team registration") print(f"[TOURNAMENT PAYMENT] Failed to create team registration")
raise Exception("Erreur lors de la création de la réservation") 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 team_registration_id = team_registration.id
print(f"[TOURNAMENT PAYMENT] Created team registration: {team_registration_id}") print(f"[TOURNAMENT PAYMENT] Created team registration: {team_registration_id}")
@ -542,47 +546,67 @@ class PaymentService:
team_registration_id = metadata.get('team_registration_id') team_registration_id = metadata.get('team_registration_id')
registration_type = metadata.get('registration_type') registration_type = metadata.get('registration_type')
if tournament_id and registration_type == 'cart': # Wrap all database operations in an atomic transaction
try: # This ensures either all changes are saved or none are
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} after payment failure")
except Tournament.DoesNotExist:
print(f"Tournament not found with ID: {tournament_id}")
except Exception as e:
print(f"Error saving tournament for team registration: {str(e)}")
if not team_registration_id:
print("No team registration ID found in session")
return False
try: try:
print(f"Looking for team registration with ID: {team_registration_id}") with transaction.atomic():
team_registration = TeamRegistration.objects.get(id=team_registration_id) if tournament_id and registration_type == 'cart':
if tournament_id and registration_type == 'cart' and team_registration.tournament is None: try:
try: tournament = Tournament.objects.get(id=tournament_id)
tournament = Tournament.objects.get(id=tournament_id) tournament.reserved_spots = max(0, tournament.reserved_spots - 1)
team_registration.tournament = tournament tournament.save()
team_registration.save() print(f"Decreased reserved spots for tournament {tournament_id}")
print(f"Saved tournament for team registration {team_registration.id}") except Tournament.DoesNotExist:
except Tournament.DoesNotExist: print(f"Tournament not found with ID: {tournament_id}")
print(f"Tournament not found with ID: {tournament_id}") except Exception as e:
except Exception as e: print(f"Error saving tournament for team registration: {str(e)}")
print(f"Error saving tournament for team registration: {str(e)}")
if not team_registration_id:
if team_registration.is_paid(): print("No team registration ID found in session")
return True return False
team_registration.confirm_registration(checkout_session.payment_intent) 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)
team_registration.tournament = tournament
team_registration.save()
print(f"Saved tournament for team registration {team_registration.id}")
except Tournament.DoesNotExist:
print(f"Tournament not found with ID: {tournament_id}")
except Exception as e:
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: except TeamRegistration.DoesNotExist:
print(f"Team registration not found with ID: {team_registration_id}") print(f"Team registration not found with ID: {team_registration_id}")
return False
except Exception as e: except Exception as e:
print(f"Error in _process_direct_payment: {str(e)}") print(f"❌ Error in process_direct_payment database operations: {str(e)}")
return False traceback.print_exc()
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 @staticmethod
@csrf_exempt @csrf_exempt
@ -590,20 +614,76 @@ class PaymentService:
def stripe_webhook(request): def stripe_webhook(request):
payload = request.body payload = request.body
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE') sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')
print("Received webhook call")
# 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(f"Signature: {sig_header}") print(f"Signature: {sig_header}")
print(f"Connect Account Header: {stripe_account_header}")
try: # First, try to construct the event with any available webhook secret to inspect the payload
event = stripe.Webhook.construct_event( webhook_secrets = []
payload, sig_header, settings.TOURNAMENT_STRIPE_WEBHOOK_SECRET if hasattr(settings, 'XLR_STRIPE_WEBHOOK_SECRET') and settings.XLR_STRIPE_WEBHOOK_SECRET:
) webhook_secrets.append(('XLR', settings.XLR_STRIPE_WEBHOOK_SECRET))
print(f"Tournament webhook event type: {event['type']}") 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))
stripe_object = event['data']['object'] 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")
# Debug: Print the object type try:
object_type = stripe_object.get('object', 'unknown') # Process the webhook event
print(f"Stripe object type: {object_type}") 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')}")
if event['type'] == 'checkout.session.completed': if event['type'] == 'checkout.session.completed':
success = PaymentService.process_direct_payment(stripe_object) success = PaymentService.process_direct_payment(stripe_object)
@ -614,30 +694,133 @@ class PaymentService:
print(f"Failed to process completed checkout session") print(f"Failed to process completed checkout session")
return HttpResponse(status=400) return HttpResponse(status=400)
elif event['type'] == 'payment_intent.payment_failed': # Handle other event types if needed
success = PaymentService.process_failed_payment_intent(stripe_object) elif event['type'] == 'payment_intent.succeeded':
if success: print(f"Payment intent succeeded - you might want to handle this")
print(f"Successfully processed failed payment intent") return HttpResponse(status=200)
return HttpResponse(status=200)
else:
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"Failed to process expired checkout session")
return HttpResponse(status=400)
else: else:
print(f"Unhandled event type: {event['type']}") print(f"Unhandled event type: {event['type']}")
return HttpResponse(status=200) return HttpResponse(status=200)
except Exception as e: except Exception as e:
print(f"Tournament webhook error: {str(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)
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"[PAYMENT LINK] Created payment link: {payment_link.url}")
return payment_link.url
except Exception as e:
print(f"[PAYMENT LINK] Error creating payment link: {str(e)}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
return HttpResponse(status=400) 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)

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

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

@ -77,15 +77,22 @@ def unregister_team(sender, instance, **kwargs):
notify_team(instance, instance.tournament, TeamEmailType.UNREGISTERED) notify_team(instance, instance.tournament, TeamEmailType.UNREGISTERED)
teams = instance.tournament.teams(True) teams = instance.tournament.teams(True)
first_waiting_list_team = instance.tournament.first_waiting_list_team(teams)
if first_waiting_list_team and first_waiting_list_team.id != instance.id: # Check if the team being deleted is in the waiting list
if instance.tournament.automatic_waiting_list(): team_being_deleted = next((team for team in teams if team.team_registration.id == instance.id), None)
waiting_list_teams = instance.tournament.waiting_list_teams(teams) is_team_in_waiting_list = team_being_deleted and team_being_deleted.team_registration.out_of_tournament() == True
ttc = None
if waiting_list_teams is not None: # Only notify the first waiting list team if the deleted team is NOT in the waiting list
ttc = instance.tournament.calculate_time_to_confirm(len(waiting_list_teams)) if not is_team_in_waiting_list:
first_waiting_list_team.set_time_to_confirm(ttc) first_waiting_list_team = instance.tournament.first_waiting_list_team(teams)
notify_team(first_waiting_list_team, instance.tournament, TeamEmailType.OUT_OF_WAITING_LIST) if first_waiting_list_team and first_waiting_list_team.id != instance.id:
if instance.tournament.automatic_waiting_list():
waiting_list_teams = instance.tournament.waiting_list_teams(teams)
ttc = None
if waiting_list_teams is not None:
ttc = instance.tournament.calculate_time_to_confirm(len(waiting_list_teams))
first_waiting_list_team.set_time_to_confirm(ttc)
notify_team(first_waiting_list_team, instance.tournament, TeamEmailType.OUT_OF_WAITING_LIST)
@receiver(post_save, sender=Tournament) @receiver(post_save, sender=Tournament)
def notify_players_of_tournament_cancellation(sender, instance, **kwargs): def notify_players_of_tournament_cancellation(sender, instance, **kwargs):
@ -154,7 +161,7 @@ def warn_team_walkout_status_change(sender, instance, **kwargs):
try: try:
previous_instance = TeamRegistration.objects.get(id=instance.id) previous_instance = TeamRegistration.objects.get(id=instance.id)
except TeamRegistration.DoesNotExist: except TeamRegistration.DoesNotExist:
print("TeamRegistration.DoesNotExist") print("warn_team_walkout > TeamRegistration.DoesNotExist")
return return
ttc = None ttc = None
@ -183,7 +190,11 @@ def warn_team_walkout_status_change(sender, instance, **kwargs):
notify_team(instance, instance.tournament, TeamEmailType.OUT_OF_WALKOUT_IS_IN) notify_team(instance, instance.tournament, TeamEmailType.OUT_OF_WALKOUT_IS_IN)
elif not previous_instance.out_of_tournament() and instance.out_of_tournament(): elif not previous_instance.out_of_tournament() and instance.out_of_tournament():
instance.cancel_time_to_confirm() instance.cancel_time_to_confirm()
notify_team(instance, instance.tournament, TeamEmailType.WALKOUT) 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: if was_out and not is_out:
first_out_of_list = instance.tournament.first_waiting_list_team(current_teams) first_out_of_list = instance.tournament.first_waiting_list_team(current_teams)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -61,6 +61,23 @@
justify-content: center; 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 { .round-title.broadcast-mode {
font-size: 0.8em; font-size: 0.8em;
width: auto; /* Change from 100% to auto */ width: auto; /* Change from 100% to auto */

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

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

@ -0,0 +1,81 @@
{% 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,7 +32,13 @@
<p><strong>✅ Votre paiement a bien été effectué et enregistré.</strong></p> <p><strong>✅ Votre paiement a bien été effectué et enregistré.</strong></p>
{% endif %} {% endif %}
<p style="text-align: justify;"> <p style="text-align: justify;">
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. {% 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> </p>
{% else %} {% else %}
{% if not registration_successful %} {% if not registration_successful %}
@ -144,11 +150,23 @@
{% endif %} {% endif %}
{% if add_player_form.first_tournament or add_player_form.user_without_licence or tournament.license_is_required is False %} {% 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 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"> <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. Veuillez renseigner vos informations :
</div>
<div class="semibold">
Précisez les informations du joueur :
</div> </div>
{% endif %} {% endif %}

@ -0,0 +1,30 @@
{% 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,8 +158,15 @@
// Create the match content using our HTML generator // 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.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); tempContainer.appendChild(template);
}); });
}); });

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

@ -1,3 +1,5 @@
{% load tournament_tags %}
<nav class="margin10"> <nav class="margin10">
<a href="{% url 'index' %}" class="topmargin5 orange">Accueil</a> <a href="{% url 'index' %}" class="topmargin5 orange">Accueil</a>
@ -9,6 +11,14 @@
<a href="{% url 'tournament-live' tournament.id %}" class="topmargin5 orange">Live</a> <a href="{% url 'tournament-live' tournament.id %}" class="topmargin5 orange">Live</a>
{% endif %} {% 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 %} {% if tournament.display_prog %}
<a href="{% url 'tournament-prog' tournament.id %}" class="topmargin5 orange">Programmation</a> <a href="{% url 'tournament-prog' tournament.id %}" class="topmargin5 orange">Programmation</a>
{% endif %} {% endif %}

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

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

@ -6,6 +6,11 @@ register = template.Library()
def get_player_status(tournament, user): def get_player_status(tournament, user):
return tournament.get_player_registration_status_by_licence(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 @register.filter
def lookup(dictionary, key): def lookup(dictionary, key):
"""Template filter to lookup dictionary values by key""" """Template filter to lookup dictionary values by key"""

@ -79,6 +79,7 @@ urlpatterns = [
path('activation-success/', views.activation_success, name='activation_success'), path('activation-success/', views.activation_success, name='activation_success'),
path('activation-failed/', views.activation_failed, name='activation_failed'), path('activation-failed/', views.activation_failed, name='activation_failed'),
path('tournaments/<str:tournament_id>/confirm/', views.confirm_tournament_registration, name='confirm_tournament_registration'), 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-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('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'), 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: else:
cleaned_licence_id = None 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): 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): def search_file(file_path, is_woman):
if not file_path or not os.path.exists(file_path): if not file_path or not os.path.exists(file_path):
print("no file found") # print("no file found")
return None, False return None, False
last_rank = None last_rank = None
@ -109,13 +109,13 @@ def get_player_name_from_csv(category, licence_id, base_folder=None):
return None, False return None, False
print("Look for woman", FederalCategory.WOMEN) # print("Look for woman", FederalCategory.WOMEN)
dames_file = find_most_recent_file("CLASSEMENT-PADEL-DAMES-") dames_file = find_most_recent_file("CLASSEMENT-PADEL-DAMES-")
result, found = search_file(dames_file, True) result, found = search_file(dames_file, True)
if found or category is FederalCategory.WOMEN: if found or category is FederalCategory.WOMEN:
return result, found return result, found
print("Look for man") # print("Look for man")
messieurs_file = find_most_recent_file("CLASSEMENT-PADEL-MESSIEURS-") messieurs_file = find_most_recent_file("CLASSEMENT-PADEL-MESSIEURS-")
result, found = search_file(messieurs_file, False) result, found = search_file(messieurs_file, False)
return result, found return result, found

@ -69,6 +69,12 @@ from .models import AnimationType
logger = logging.getLogger(__name__) 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): def index(request):
now = timezone.now() now = timezone.now()
thirty_days_ago = now - timedelta(days=30) thirty_days_ago = now - timedelta(days=30)
@ -78,7 +84,7 @@ def index(request):
if club_id: if club_id:
tournaments = tournaments_query(Q(end_date__isnull=True), club_id, True, 50) tournaments = tournaments_query(Q(end_date__isnull=True), club_id, True, 50)
else: else:
tournaments = tournaments_query(Q(end_date__isnull=True, start_date__gte=thirty_days_ago, start_date__lte=thirty_days_future), club_id, True, 50) tournaments = tournaments_query(Q(end_date__isnull=True, start_date__gte=thirty_days_ago, start_date__lte=thirty_days_future), club_id, True, 100)
display_tournament = [t for t in tournaments if t.display_tournament()] display_tournament = [t for t in tournaments if t.display_tournament()]
live = [] live = []
@ -118,7 +124,7 @@ def tournaments_query(query, club_id, ascending, limit=None):
club = None club = None
if club_id: if club_id:
club = get_object_or_404(Club, pk=club_id) club = get_object(Club, club_id)
q_club = Q(event__club=club) q_club = Q(event__club=club)
queries.append(q_club) queries.append(q_club)
else: else:
@ -393,12 +399,16 @@ def event(request, event_id):
else: else:
name = 'Événement' name = 'Événement'
first_title = ''
if event.club:
first_title = event.club.name
return render( return render(
request, request,
"tournaments/tournaments_list.html", "tournaments/tournaments_list.html",
{ {
'tournaments': tournaments, 'tournaments': tournaments,
'first_title': event.club.name, 'first_title': first_title,
'second_title': name, 'second_title': name,
'head_title': name, 'head_title': name,
'first_tournament_prog_url': first_tournament_prog_url, 'first_tournament_prog_url': first_tournament_prog_url,
@ -1583,9 +1593,14 @@ def proceed_to_payment(request, tournament_id):
messages.error(request, f"Erreur lors de la création de la session de paiement: {str(e)}") 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) return redirect('tournament-info', tournament_id=tournament_id)
@login_required
def tournament_payment_success(request, tournament_id): def tournament_payment_success(request, tournament_id):
"""Handle successful Stripe payment for tournament registration""" """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 # Get checkout session ID from session
checkout_session_id = request.session.get('stripe_checkout_session_id') checkout_session_id = request.session.get('stripe_checkout_session_id')
if not checkout_session_id: if not checkout_session_id:
@ -1641,6 +1656,68 @@ def tournament_payment_success(request, tournament_id):
# For direct payments, go to tournament info # For direct payments, go to tournament info
return redirect('tournament-info', tournament_id=tournament_id) 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 @csrf_protect
def register_tournament(request, tournament_id): def register_tournament(request, tournament_id):
tournament = get_object_or_404(Tournament, id=tournament_id) tournament = get_object_or_404(Tournament, id=tournament_id)
@ -1657,7 +1734,7 @@ def register_tournament(request, tournament_id):
# Check for registration_successful flag # Check for registration_successful flag
registration_successful = request.session.pop('registration_successful', False) registration_successful = request.session.pop('registration_successful', False)
registration_paid = request.session.pop('registration_paid', False) registration_paid = request.session.pop('registration_paid', False)
registered_team = None
# Handle payment cancellation - check for cancelled team registration # Handle payment cancellation - check for cancelled team registration
cancel_team_registration_id = request.session.pop('cancel_team_registration_id', None) cancel_team_registration_id = request.session.pop('cancel_team_registration_id', None)
if cancel_team_registration_id: if cancel_team_registration_id:
@ -1676,7 +1753,8 @@ def register_tournament(request, tournament_id):
if not team_registration.is_paid(): if not team_registration.is_paid():
team_registration.delete() team_registration.delete()
print(f"[PAYMENT CANCEL] Deleted unpaid team registration {cancel_team_registration_id}") print(f"[PAYMENT CANCEL] Deleted unpaid team registration {cancel_team_registration_id}")
else:
registered_team = team_registration
except TeamRegistration.DoesNotExist: except TeamRegistration.DoesNotExist:
print(f"[PAYMENT CANCEL] Team registration {cancel_team_registration_id} not found") print(f"[PAYMENT CANCEL] Team registration {cancel_team_registration_id} not found")
except Exception as e: except Exception as e:
@ -1702,6 +1780,7 @@ def register_tournament(request, tournament_id):
'tournament': tournament, 'tournament': tournament,
'registration_successful': True, 'registration_successful': True,
'registration_paid': registration_paid, 'registration_paid': registration_paid,
'registered_team': registered_team,
'current_players': [], 'current_players': [],
'cart_data': {'players': []} 'cart_data': {'players': []}
} }
@ -1823,18 +1902,32 @@ def handle_add_player_request(request, tournament, cart_manager, context):
if team_form.is_valid(): if team_form.is_valid():
# Update cart with mobile number before adding player # Update cart with mobile number before adding player
cart_manager.update_contact_info( cart_manager.update_contact_info(
email=team_form.cleaned_data.get('email'),
mobile_number=team_form.cleaned_data.get('mobile_number') mobile_number=team_form.cleaned_data.get('mobile_number')
) )
success, message = cart_manager.add_player(add_player_form.cleaned_data) # 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)
if success: if success:
messages.success(request, message) messages.success(request, message)
# Refresh cart data # Refresh cart data
cart_data = cart_manager.get_cart_data() cart_data = cart_manager.get_cart_data()
context['current_players'] = cart_data['players'] context['current_players'] = cart_data['players']
context['cart_data'] = cart_data
context['team_form'] = TournamentRegistrationForm(initial={ context['team_form'] = TournamentRegistrationForm(initial={
'email': request.user.email if request.user.is_authenticated else '', 'email': request.user.email if request.user.is_authenticated else cart_data.get('email', ''),
'mobile_number': cart_data.get('mobile_number', '') 'mobile_number': cart_data.get('mobile_number', '')
}) })
@ -1864,6 +1957,7 @@ def handle_remove_player_request(request, tournament, cart_manager, context):
# Refresh cart data # Refresh cart data
cart_data = cart_manager.get_cart_data() cart_data = cart_manager.get_cart_data()
context['current_players'] = cart_data['players'] context['current_players'] = cart_data['players']
context['cart_data'] = cart_data
# Update the add player form based on the new state # Update the add player form based on the new state
if not cart_data['players'] and request.user.is_authenticated: if not cart_data['players'] and request.user.is_authenticated:
@ -1897,6 +1991,7 @@ def handle_register_team_request(request, tournament, cart_manager, context):
# Update cart with contact info # Update cart with contact info
cart_manager.update_contact_info( cart_manager.update_contact_info(
email=team_form.cleaned_data.get('email'),
mobile_number=team_form.cleaned_data.get('mobile_number') mobile_number=team_form.cleaned_data.get('mobile_number')
) )
@ -1921,6 +2016,7 @@ def handle_register_team_request(request, tournament, cart_manager, context):
) )
context['registration_successful'] = True context['registration_successful'] = True
context['registered_team'] = result
context['registration_paid'] = False context['registration_paid'] = False
context['current_players'] = [] context['current_players'] = []
context['add_player_form'] = None # No more adding players after success context['add_player_form'] = None # No more adding players after success
@ -1938,6 +2034,7 @@ def handle_payment_request(request, cart_manager, context, tournament_id):
if team_form.is_valid(): if team_form.is_valid():
# Update cart with contact info # Update cart with contact info
cart_manager.update_contact_info( cart_manager.update_contact_info(
email=team_form.cleaned_data.get('email'),
mobile_number=team_form.cleaned_data.get('mobile_number') mobile_number=team_form.cleaned_data.get('mobile_number')
) )
@ -2230,6 +2327,39 @@ def tournament_live_matches(request, tournament_id):
'live_matches': live_matches, '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): class UserListExportView(LoginRequiredMixin, View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):

Loading…
Cancel
Save