Compare commits

...

67 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 5 days ago
Razmig Sarkissian f97dbd79cc Enhance Club Selection Form in EventAdmin Action 6 days ago
Razmig Sarkissian 240bb3fc25 Add admin template for setting club for multiple events 6 days ago
Razmig Sarkissian 5102e4c295 Add set club bulk action to EventAdmin 6 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 3 weeks ago
Razmig Sarkissian 80b6bc1136 Fix email filtering for tournament registrations 3 weeks ago
Laurent 1339b65731 Fix crash 3 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 4 weeks 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
  1. 2
      api/urls.py
  2. 39
      api/views.py
  3. 72
      biz/admin.py
  4. 23
      biz/migrations/0008_alter_activity_declination_reason_and_more.py
  5. 14
      biz/models.py
  6. 448
      biz/templates/admin/biz/dashboard.html
  7. 1
      biz/templates/admin/biz/prospect/change_list.html
  8. 10
      padelclub_backend/urls.py
  9. 4
      sync/admin.py
  10. 8
      sync/models/base.py
  11. 27
      sync/signals.py
  12. 4
      sync/views.py
  13. 9
      sync/ws_sender.py
  14. 87
      tournaments/admin.py
  15. 1289
      tournaments/admin_utils.py
  16. 27
      tournaments/filters.py
  17. 18
      tournaments/migrations/0140_tournament_custom_club_name.py
  18. 17
      tournaments/models/player_registration.py
  19. 68
      tournaments/models/round.py
  20. 10
      tournaments/models/team_registration.py
  21. 34
      tournaments/models/tournament.py
  22. 20
      tournaments/services/email_service.py
  23. 193
      tournaments/services/payment_service.py
  24. 9
      tournaments/services/tournament_registration.py
  25. 4
      tournaments/services/tournament_unregistration.py
  26. 33
      tournaments/signals.py
  27. 16151
      tournaments/static/rankings/CLASSEMENT-PADEL-DAMES-10-2025.csv
  28. 16913
      tournaments/static/rankings/CLASSEMENT-PADEL-DAMES-11-2025.csv
  29. 110963
      tournaments/static/rankings/CLASSEMENT-PADEL-MESSIEURS-10-2025.csv
  30. 117908
      tournaments/static/rankings/CLASSEMENT-PADEL-MESSIEURS-11-2025.csv
  31. 213
      tournaments/static/tournaments/js/tournament_bracket.js
  32. 6
      tournaments/templates/admin/tournaments/dashboard.html
  33. 81
      tournaments/templates/admin/tournaments/set_club_action.html
  34. 20
      tournaments/templates/register_tournament.html
  35. 10
      tournaments/templates/tournaments/navigation_tournament.html
  36. 2
      tournaments/templates/tournaments/tournament_info.html
  37. 12
      tournaments/templates/tournaments/tournament_row.html
  38. 5
      tournaments/templatetags/tournament_tags.py
  39. 31
      tournaments/views.py

@ -64,5 +64,5 @@ urlpatterns = [
path('stripe/create-account/', views.create_stripe_connect_account, name='create_stripe_account'),
path('stripe/create-account-link/', views.create_stripe_account_link, name='create_account_link'),
path('resend-payment-email/<str:team_registration_id>/', views.resend_payment_email, name='resend-payment-email'),
path('payment-link/<str:team_registration_id>/', views.get_payment_link, name='get-payment-link'),
]

@ -633,7 +633,8 @@ def resend_payment_email(request, team_registration_id):
request,
tournament,
team_registration,
waiting_list_position=-1
waiting_list_position=-1,
force_send=True
)
return Response({
@ -646,6 +647,42 @@ def resend_payment_email(request, team_registration_id):
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_payment_link(request, team_registration_id):
"""
Get payment link for a team registration.
Only accessible by the umpire (tournament creator).
"""
try:
team_registration = get_object_or_404(TeamRegistration, id=team_registration_id)
# Check if the user is the umpire (creator) of the tournament
if request.user != team_registration.tournament.event.creator:
return Response({
'success': False,
'message': "Vous n'êtes pas autorisé à accéder à ce lien de paiement"
}, status=403)
# Create payment link
payment_link = PaymentService.create_payment_link(team_registration.id)
if payment_link:
return Response({
'success': True,
'payment_link': payment_link
})
else:
return Response({
'success': False,
'message': 'Impossible de créer le lien de paiement'
}, status=500)
except TeamRegistration.DoesNotExist:
return Response({'error': 'Team not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def is_granted_unlimited_access(request):

@ -6,6 +6,7 @@ from django.shortcuts import render, redirect
from django.contrib.auth import get_user_model
from django.utils.html import format_html
from django.core.mail import send_mail
from django.db.models import Q, Max, Subquery, OuterRef
import csv
import io
@ -130,7 +131,12 @@ class ProspectAdmin(SyncedObjectAdmin):
ordering = ['-last_update']
filter_horizontal = ['entities']
actions = ['send_email', create_activity_for_prospect, mark_as_inbound, contacted_by_sms, mark_as_should_test, mark_as_testing, mark_as_customer, mark_as_have_account, declined_too_expensive, declined_use_something_else, declined_android_user, mark_as_not_concerned]
raw_id_fields = ['official_user', 'related_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):
return obj.last_update.date() if obj.last_update else None
@ -151,12 +157,68 @@ class ProspectAdmin(SyncedObjectAdmin):
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('dashboard/', self.admin_site.admin_view(self.dashboard), name='biz_dashboard'),
path('import_file/', self.admin_site.admin_view(self.import_file), name='import_file'),
path('import_app_users/', self.admin_site.admin_view(self.import_app_users), name='import_app_users'),
path('cleanup/', self.admin_site.admin_view(self.cleanup), name='cleanup'),
]
return custom_urls + urls
def dashboard(self, request):
"""
Dashboard view showing prospects organized by status columns
"""
# Get filter parameter - if 'my' is true, filter by current user
filter_my = request.GET.get('my', 'false') == 'true'
# Base queryset
base_queryset = Prospect.objects.select_related().prefetch_related('entities', 'activities')
# Apply user filter if requested
if filter_my:
base_queryset = base_queryset.filter(related_user=request.user)
# Helper function to get prospects by status
def get_prospects_by_status(statuses):
# Get the latest activity status for each prospect
latest_activity = Activity.objects.filter(
prospects=OuterRef('pk'),
status__isnull=False
).order_by('-creation_date')
prospects = base_queryset.annotate(
latest_status=Subquery(latest_activity.values('status')[:1])
).filter(
latest_status__in=statuses
).order_by('last_update')
return prospects
# Get prospects for each column
should_test_prospects = get_prospects_by_status([Status.SHOULD_TEST])
testing_prospects = get_prospects_by_status([Status.TESTING])
responded_prospects = get_prospects_by_status([Status.RESPONDED])
others_prospects = get_prospects_by_status([Status.INBOUND, Status.SHOULD_BUY])
# Get prospects with contact_again date set, sorted by oldest first
contact_again_prospects = base_queryset.filter(
contact_again__isnull=False
).order_by('contact_again')
context = {
'title': 'CRM Dashboard',
'should_test_prospects': should_test_prospects,
'testing_prospects': testing_prospects,
'responded_prospects': responded_prospects,
'others_prospects': others_prospects,
'contact_again_prospects': contact_again_prospects,
'filter_my': filter_my,
'opts': self.model._meta,
'has_view_permission': self.has_view_permission(request),
}
return render(request, 'admin/biz/dashboard.html', context)
def cleanup(self, request):
Entity.objects.all().delete()
Prospect.objects.all().delete()
@ -435,11 +497,12 @@ class ProspectGroupAdmin(SyncedObjectAdmin):
@admin.register(Activity)
class ActivityAdmin(SyncedObjectAdmin):
raw_id_fields = ['prospects']
# raw_id_fields = ['prospects']
list_display = ('prospect_names', 'last_update', 'status', 'type', 'description', 'attachment_text', )
list_filter = ('status', 'type')
search_fields = ('attachment_text',)
date_hierarchy = 'last_update'
autocomplete_fields = ['prospects', 'related_user']
def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
@ -461,6 +524,11 @@ class ActivityAdmin(SyncedObjectAdmin):
return form
def save_model(self, request, obj, form, change):
if obj.related_user is None:
obj.related_user = request.user
super().save_model(request, obj, form, change)
def get_event_display(self, obj):
return str(obj)
get_event_display.short_description = 'Activity'

@ -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),
),
]

@ -31,6 +31,8 @@ class DeclinationReason(models.TextChoices):
TOO_EXPENSIVE = 'TOO_EXPENSIVE', 'Too expensive'
USE_OTHER_PRODUCT = 'USE_OTHER_PRODUCT', 'Use other product'
USE_ANDROID = 'USE_ANDROID', 'Use Android'
TOO_FEW_TOURNAMENTS = 'TOO_FEW_TOURNAMENTS', 'Too few tournaments'
NOT_INTERESTED = 'NOT_INTERESTED', 'Not interested'
UNKNOWN = 'UNKNOWN', 'Unknown'
class ActivityType(models.TextChoices):
@ -39,6 +41,7 @@ class ActivityType(models.TextChoices):
CALL = 'CALL', 'Call'
PRESS = 'PRESS', 'Press Release'
WORD_OF_MOUTH = 'WORD_OF_MOUTH', 'Word of mouth'
WHATS_APP = 'WHATS_APP', 'WhatsApp'
class Entity(BaseModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
@ -86,6 +89,12 @@ class Prospect(BaseModel):
return last_activity.status
return Status.NONE
def current_activity_type(self):
last_activity = self.activities.exclude(type=None).order_by('-creation_date').first()
if last_activity:
return last_activity.type
return None
def current_text(self):
last_activity = self.activities.exclude(status=None).order_by('-creation_date').first()
if last_activity:
@ -135,6 +144,11 @@ class Activity(BaseModel):
def delete_dependencies(self):
pass
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# Update last_update for all related prospects when activity is saved
self.prospects.update(last_update=timezone.now())
class Meta:
verbose_name_plural = "Activities"
ordering = ['-creation_date']

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

@ -18,7 +18,7 @@ from django.urls import include, path
from django.conf import settings
from django.conf.urls.static import static
from tournaments.admin_utils import download_french_padel_rankings, debug_tools_page, test_player_details_apis, explore_fft_api_endpoints, get_player_license_info, bulk_license_lookup, search_player_by_name, enrich_rankings_with_licenses
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 = [
@ -38,6 +38,11 @@ urlpatterns = [
path('kingdom/', admin.site.urls),
path('api-auth/', include('rest_framework.urls')),
path('dj-auth/', include('django.contrib.auth.urls')),
path(
"kingdom/debug/gather-monthly-umpires/",
gather_monthly_tournaments_and_umpires,
name="gather_monthly_umpires",
),
]
@ -47,4 +52,5 @@ def email_users_view(request):
})
# Serve media files in development
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

@ -11,7 +11,7 @@ class SyncedObjectAdmin(admin.ModelAdmin):
def save_model(self, request, obj, form, change):
if isinstance(obj, BaseModel):
obj.last_updated_by = request.user
obj.last_update = timezone.now()
# obj.last_update = timezone.now()
super().save_model(request, obj, form, change)
def delete_model(self, request, obj):
@ -24,7 +24,7 @@ class SyncedObjectAdmin(admin.ModelAdmin):
queryset.delete()
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 = ['operation', 'model_name', 'user']
ordering = ['-date']
search_fields = ['model_id']

@ -22,6 +22,7 @@ class BaseModel(models.Model):
abstract = True
def save(self, *args, **kwargs):
self.last_update = now()
if self.related_user is None:
self.related_user = self.find_related_user()
if self._state.adding:
@ -132,9 +133,8 @@ class BaseModel(models.Model):
children_by_model = self.get_children_by_model()
for queryset in children_by_model.values():
for child in queryset:
children.append(child)
# Recursively get children of children
if isinstance(child, BaseModel):
children.append(child)
children.extend(child.get_recursive_children(processed_objects))
return children
@ -191,7 +191,7 @@ class BaseModel(models.Model):
for parent in parents_by_model.values():
if isinstance(parent, BaseModel):
if parent.related_user:
print(f'related_user found in {parent}')
print(f'*** related_user found in {parent}')
return parent.related_user
else:
return parent.find_related_user(processed_objects)
@ -227,7 +227,7 @@ class BaseModel(models.Model):
def get_shared_children_from_relationships(self, relationships, processed_objects):
print(f'>>> {self.__class__.__name__} : relationships = {relationships}')
# print(f'>>> {self.__class__.__name__} : relationships = {relationships}')
current = [self]
for relationship in relationships:
# print(f'> relationship = {relationship}')

@ -74,7 +74,7 @@ def synchronization_notifications(sender, instance, created=False, **kwargs):
def notify_impacted_users(instance):
device_id = device_registry.get_device_id(instance.id)
users = related_users_registry.get_users(instance.id)
# logger.info(f'>>> notify_impacted_users: {users} for {instance.id}')
logger.info(f'>>> notify_impacted_users: {users} for {instance.id}')
if users:
user_ids = [user.id for user in users]
@ -126,19 +126,18 @@ def save_model_log(users, model_operation, model_name, model_id, store_id):
with transaction.atomic():
created_logs = []
for user in users:
if user.can_synchronize:
# logger.info(f'Creating ModelLog for user {user.id} - user exists: {User.objects.filter(id=user.id).exists()}')
model_log = ModelLog(
user=user,
operation=model_operation,
model_name=model_name,
model_id=model_id,
store_id=store_id,
device_id=device_id
)
model_log.save()
# logger.info(f'ModelLog saved with ID: {model_log.id}')
created_logs.append(model_log.id)
# logger.info(f'Creating ModelLog for user {user.id} - user exists: {User.objects.filter(id=user.id).exists()}')
model_log = ModelLog(
user=user,
operation=model_operation,
model_name=model_name,
model_id=model_id,
store_id=store_id,
device_id=device_id
)
model_log.save()
# logger.info(f'ModelLog saved with ID: {model_log.id}')
created_logs.append(model_log.id)
# Immediate verification within transaction
# immediate_count = ModelLog.objects.filter(id__in=created_logs).count()

@ -287,7 +287,6 @@ class LogProcessingResult:
def process_logs(self, logs):
"""Process logs to collect basic operations and handle grant/revoke efficiently."""
for log in logs:
self.last_log_date = log.date
try:
if log.operation in ['POST', 'PUT', 'RELATIONSHIP_SET']:
data = get_serialized_data_by_id(log.model_name, log.model_id)
@ -324,7 +323,10 @@ class LogProcessingResult:
self.shared_relationship_sets[log.model_name][log.model_id] = data
elif log.operation == 'SHARED_RELATIONSHIP_REMOVED':
self.shared_relationship_removals[log.model_name].append(log.data_identifier_dict())
self.last_log_date = log.date # set dates after having retrieved informations
except ObjectDoesNotExist:
logger.warning(f'log processing failed, unable to find {log.model_name} : {log.model_id}')
pass
# Convert updates dict to list for each model

@ -63,16 +63,19 @@ class WebSocketSender:
return
with self._debounce_lock:
timer_device_id = device_id
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()
if old_device_id != device_id: # we want to notify all devices if there all multiple ones
timer_device_id = None
new_timer = Timer(
self._buffer_timeout,
self._handle_debounced_action,
args=[user_ids_key, 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()
def _handle_debounced_action(self, user_ids_key, device_id):

@ -6,19 +6,18 @@ from django.utils.html import escape
from django.urls import reverse, path
from django.utils.safestring import mark_safe
from django.shortcuts import render
from django.db.models import Avg
from django.db.models import Avg, Count
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 .forms import CustomUserCreationForm, CustomUserChangeForm
from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter, StartDateRangeFilter, UserWithEventsFilter, UserWithPurchasesFilter, UserWithProspectFilter
from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter, StartDateRangeFilter, UserWithEventsFilter, UserWithPurchasesFilter, UserWithProspectFilter, TeamScoreRoundIndexFilter
from sync.admin import SyncedObjectAdmin
import logging
logger = logging.getLogger(__name__)
class CustomUserAdmin(UserAdmin):
@ -32,7 +31,7 @@ class CustomUserAdmin(UserAdmin):
list_display = ['email', 'first_name', 'last_name', 'username', 'date_joined', 'latest_event_club_name', 'is_active', 'event_count', 'origin', 'registration_payment_mode', 'licence_id']
list_filter = ['is_active', 'origin', UserWithEventsFilter, UserWithPurchasesFilter, UserWithProspectFilter]
ordering = ['-date_joined']
raw_id_fields = ['supervisors', 'organizers']
autocomplete_fields = ['supervisors', 'organizers']
fieldsets = [
(None, {'fields': ['id', 'username', 'email', 'password', 'first_name', 'last_name', 'is_active', 'date_joined']}),
('Permissions', {'fields': ['is_staff', 'is_superuser', 'groups', 'user_permissions']}),
@ -118,6 +117,7 @@ class EventAdmin(SyncedObjectAdmin):
raw_id_fields = ['related_user', 'creator', 'club']
ordering = ['-creation_date']
readonly_fields = ['display_images_preview']
actions = ['set_club_action']
fieldsets = [
(None, {'fields': ['last_update', 'related_user', 'name', 'club', 'creator', 'creation_date', 'tenup_id']}),
@ -147,6 +147,74 @@ class EventAdmin(SyncedObjectAdmin):
return mark_safe(html)
display_images_preview.short_description = 'Images Preview'
def set_club_action(self, request, queryset):
"""Action to set club for selected events"""
from django import forms
from django.contrib.admin.widgets import ForeignKeyRawIdWidget
from django.contrib.admin import helpers
from django.core.exceptions import ValidationError
class ClubSelectionForm(forms.Form):
_selected_action = forms.CharField(widget=forms.MultipleHiddenInput)
action = forms.CharField(widget=forms.HiddenInput)
club_id = forms.CharField(
label='Club',
required=True,
help_text='Enter Club ID or use the search icon to find a club',
widget=ForeignKeyRawIdWidget(
Event._meta.get_field('club').remote_field,
self.admin_site
)
)
def clean_club_id(self):
club_id = self.cleaned_data['club_id']
try:
club = Club.objects.get(pk=club_id)
return club
except Club.DoesNotExist:
raise ValidationError(f'Club with ID {club_id} does not exist.')
except (ValueError, TypeError) as e:
raise ValidationError(f'Invalid Club ID format: {club_id}')
if 'apply' in request.POST:
form = ClubSelectionForm(request.POST)
if form.is_valid():
club = form.cleaned_data['club_id'] # This is now a Club instance
updated_count = queryset.update(club=club)
self.message_user(
request,
f'Successfully updated {updated_count} event(s) with club: {club.name}',
messages.SUCCESS
)
return None
else:
# Show form errors
self.message_user(
request,
f'Form validation failed. Errors: {form.errors}',
messages.ERROR
)
# Initial form display
form = ClubSelectionForm(initial={
'_selected_action': request.POST.getlist(helpers.ACTION_CHECKBOX_NAME),
'action': 'set_club_action',
})
context = {
'form': form,
'events': queryset,
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
'action_name': 'set_club_action',
'title': 'Set Club for Events',
'media': form.media,
'has_change_permission': True,
}
return render(request, 'admin/tournaments/set_club_action.html', context)
set_club_action.short_description = "Set club for selected events"
class TournamentAdmin(SyncedObjectAdmin):
list_display = ['display_name', 'event', 'is_private', 'start_date', 'payment', 'creator', 'is_canceled']
list_filter = [StartDateRangeFilter, 'is_deleted', 'event__creator']
@ -241,6 +309,10 @@ class TournamentAdmin(SyncedObjectAdmin):
avg_teams=Avg('tournament__team_count')
)['avg_teams'] or 0
email_count = PlayerRegistration.objects.aggregate(
total=Count('email', distinct=True)
)['total']
avg_entry_fee = Tournament.objects.exclude(is_deleted=True).aggregate(
avg_fee=Avg('entry_fee')
)['avg_fee'] or 0
@ -325,6 +397,7 @@ class TournamentAdmin(SyncedObjectAdmin):
'tournaments_with_payment': tournaments_with_payment,
'avg_teams_per_tournament': round(avg_teams_per_tournament, 1),
'avg_entry_fee': round(avg_entry_fee, 2),
'email_count': email_count,
# User statistics
'total_users': total_users,
@ -357,12 +430,13 @@ class TeamRegistrationAdmin(SyncedObjectAdmin):
list_display = ['player_names', 'group_stage', 'name', 'tournament', 'registration_date']
list_filter = [SimpleTournamentListFilter]
search_fields = ['id']
raw_id_fields = ['related_user', 'tournament']
class TeamScoreAdmin(SyncedObjectAdmin):
list_display = ['team_registration', 'score', 'walk_out', 'match']
list_filter = [TeamScoreTournamentListFilter]
list_filter = [TeamScoreRoundIndexFilter, TeamScoreTournamentListFilter]
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
def get_queryset(self, request):
@ -419,6 +493,7 @@ class PurchaseAdmin(SyncedObjectAdmin):
list_display = ['id', 'user', 'product_id', 'quantity', 'purchase_date', 'revocation_date', 'expiration_date']
list_filter = ['user']
ordering = ['-purchase_date']
raw_id_fields = ['user']
class CourtAdmin(SyncedObjectAdmin):
list_display = ['index', 'name', 'club']

File diff suppressed because it is too large Load Diff

@ -1,5 +1,5 @@
from django.contrib import admin
from .models import Tournament, Match
from .models import Tournament, Match, Round
from django.db.models import Q, Count
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
@ -189,3 +189,28 @@ class UserWithProspectFilter(admin.SimpleListFilter):
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

@ -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),
),
]

@ -89,7 +89,7 @@ class PlayerRegistration(TournamentSubModel):
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 or forced:
if len(name) > 20:
name_parts = self.last_name.split(" ")
if len(name_parts) > 0 and self.first_name and len(self.first_name) > 0:
name = f"{self.first_name[0]}. {name_parts[0]}"
@ -138,17 +138,18 @@ class PlayerRegistration(TournamentSubModel):
return None
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 tournament.is_custom_animation():
header = "Inscriptions"
else:
if tournament.is_team_tournament():
header = "Équipes"
else:
header = "Inscriptions"
if tournament.is_canceled():
return {
'header': header,
'position': tournament_status_team_count,
'is_team_tournament': tournament.is_team_tournament(),
'display_box': True,
'box_class': 'light-red',
'short_label': 'annulé'
@ -157,6 +158,7 @@ class PlayerRegistration(TournamentSubModel):
status = {
'header': header,
'position': tournament_status_team_count,
'is_team_tournament': True,
'display_box': True,
'box_class': 'gray',
'short_label': 'inscrit'
@ -233,3 +235,8 @@ class PlayerRegistration(TournamentSubModel):
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)
loser_bracket_mode = models.IntegerField(default=0)
# Debug flag - set to False to disable all debug prints
DEBUG_PREPARE_MATCH_GROUP = False
@staticmethod
def debug_print(*args, **kwargs):
"""Print debug messages only if DEBUG_PREPARE_MATCH_GROUP is True"""
if Round.DEBUG_PREPARE_MATCH_GROUP:
print(*args, **kwargs)
def delete_dependencies(self):
for round in self.children.all():
round.delete_dependencies()
@ -112,19 +121,35 @@ class Round(TournamentSubModel):
return True
def prepare_match_group(self, next_round, parent_round, loser_final, double_butterfly_mode, secondHalf):
Round.debug_print(f"\n[{self.name()}] === START prepare_match_group ===")
Round.debug_print(f"[{self.name()}] index={self.index}, nextRound={next_round.name() if next_round else None}, parentRound={parent_round.name() if parent_round else None}")
Round.debug_print(f"[{self.name()}] loserFinal={loser_final.name() if loser_final else None}, doubleButterfly={double_butterfly_mode}, secondHalf={secondHalf}")
short_names = double_butterfly_mode
if double_butterfly_mode and self.tournament.rounds.filter(parent=None).count() < 3:
short_names = False
Round.debug_print(f"[{self.name()}] Short names disabled (rounds < 3)")
matches = self.matches.filter(disabled=False).order_by('index')
Round.debug_print(f"[{self.name()}] Initial enabled matches: {len(matches)} - indices: {[m.index for m in matches]}")
if len(matches) == 0:
Round.debug_print(f"[{self.name()}] No matches, returning None")
return None
if next_round:
next_round_matches = next_round.matches.filter(disabled=False).order_by('index')
Round.debug_print(f"[{self.name()}] Next round matches: {len(next_round_matches)} - indices: {[m.index for m in next_round_matches]}")
else:
next_round_matches = []
Round.debug_print(f"[{self.name()}] No next round")
if len(matches) < len(next_round_matches):
Round.debug_print(f"[{self.name()}] FILTERING: matches({len(matches)}) < nextRoundMatches({len(next_round_matches)})")
all_matches = self.matches.order_by('index')
Round.debug_print(f"[{self.name()}] All matches (including disabled): {len(all_matches)} - indices: {[(m.index, m.disabled) for m in all_matches]}")
filtered_matches = []
# Process matches in pairs
@ -134,30 +159,43 @@ class Round(TournamentSubModel):
current_match = all_matches[i]
pair_match = all_matches[i+1] if i+1 < len(all_matches) else None
Round.debug_print(f"[{self.name()}] Pair {i//2}: current={current_match.index}(disabled={current_match.disabled}), pair={pair_match.index if pair_match else None}(disabled={pair_match.disabled if pair_match else None})")
# Only filter out the pair if both matches are disabled
if current_match.disabled and pair_match and pair_match.disabled:
Round.debug_print(f"[{self.name()}] Both disabled, checking next_round for index {current_match.index // 2}")
# Skip one of the matches in the pair
if next_round_matches.filter(index=current_match.index // 2).exists():
filtered_matches.append(current_match)
filtered_matches.append(pair_match)
pass
# filtered_matches.append(pair_match)
# Keeping two was bugging the bracket
Round.debug_print(f"[{self.name()}] Next round match exists, keeping one")
else:
Round.debug_print(f"[{self.name()}] No next round match, skipping both")
else:
# Keep the current match
if current_match.disabled == False:
filtered_matches.append(current_match)
Round.debug_print(f"[{self.name()}] Keeping current match {current_match.index}")
# If there's a pair match, keep it too
if pair_match and pair_match.disabled == False:
filtered_matches.append(pair_match)
Round.debug_print(f"[{self.name()}] Keeping pair match {pair_match.index}")
# Move to the next pair
i += 2
# Replace the matches list with our filtered list
matches = filtered_matches
Round.debug_print(f"[{self.name()}] After filtering: {len(matches)} matches - indices: {[m.index for m in matches]}")
if matches:
if len(matches) > 1 and double_butterfly_mode:
Round.debug_print(f"[{self.name()}] SPLITTING: doubleButterfly with {len(matches)} matches")
if len(matches) % 2 == 1:
Round.debug_print(f"[{self.name()}] ODD number of matches - using smart split logic")
# Calculate expected index range for this round
if self.index == 0:
# Final: only index 0
@ -168,47 +206,71 @@ class Round(TournamentSubModel):
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]
if secondHalf:
first_half_matches = matches[midpoint:]
Round.debug_print(f"[{self.name()}] Using SECOND half: {len(first_half_matches)} matches - indices: {[m.index for m in first_half_matches]}")
else:
Round.debug_print(f"[{self.name()}] Using FIRST half: {len(first_half_matches)} matches - indices: {[m.index for m in first_half_matches]}")
else:
Round.debug_print(f"[{self.name()}] NO SPLITTING: singleButterfly or single match")
first_half_matches = list(matches) # Convert QuerySet to a list
Round.debug_print(f"[{self.name()}] Using all {len(first_half_matches)} matches - indices: {[m.index for m in first_half_matches]}")
if self.index == 0 and loser_final:
loser_match = loser_final.matches.first()
if loser_match:
first_half_matches.append(loser_match)
Round.debug_print(f"[{self.name()}] Added loser final match: {loser_match.index}")
if first_half_matches:
name = self.plural_name()
if parent_round and first_half_matches[0].name is not None:
name = first_half_matches[0].name
Round.debug_print(f"[{self.name()}] Using custom name from first match: '{name}'")
else:
Round.debug_print(f"[{self.name()}] Using round name: '{name}'")
Round.debug_print(f"[{self.name()}] Creating match_group: name='{name}', roundId={self.id}, roundIndex={self.index}, shortNames={short_names}")
Round.debug_print(f"[{self.name()}] Final matches in group: {[m.index for m in first_half_matches]}")
match_group = self.tournament.create_match_group(
name=name,
matches=first_half_matches,
@ -216,6 +278,8 @@ class Round(TournamentSubModel):
round_index=self.index,
short_names=short_names
)
Round.debug_print(f"[{self.name()}] === END prepare_match_group - SUCCESS ===\n")
return match_group
Round.debug_print(f"[{self.name()}] === END prepare_match_group - NO MATCHES ===\n")
return None

@ -37,6 +37,7 @@ class TeamRegistration(TournamentSubModel):
final_ranking = models.IntegerField(null=True, blank=True)
points_earned = models.IntegerField(null=True, blank=True)
unique_random_index = models.IntegerField(default=0)
user_canceled_registration = False
def delete_dependencies(self):
for player_registration in self.player_registrations.all():
@ -108,7 +109,7 @@ class TeamRegistration(TournamentSubModel):
def formatted_team_names(self):
if 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)
if joined_names:
return f"Paire {joined_names}"
@ -560,3 +561,10 @@ class TeamRegistration(TournamentSubModel):
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)
unregister_delta_in_hours = models.IntegerField(default=24)
currency_code = models.CharField(null=True, blank=True, max_length=3, default='EUR')
# parent = models.ForeignKey('self', blank=True, null=True, on_delete=models.SET_NULL, related_name='children')
# loser_index = models.IntegerField(default=0)
custom_club_name = models.CharField(null=True, blank=True, max_length=100)
def delete_dependencies(self):
for team_registration in self.team_registrations.all():
@ -232,7 +235,7 @@ class Tournament(BaseModel):
case AnimationType.CONSOLATION_BRACKET:
return "Consolante"
case AnimationType.CUSTOM:
return "Spécial"
return "Soirée"
case _:
return "Anim."
if self.federal_level_category == 1:
@ -302,8 +305,19 @@ class Tournament(BaseModel):
def get_tournament_status(self):
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()
if self.is_team_tournament() is False:
# Count players instead of teams when minimum players per team is under 2
PlayerRegistration = apps.get_model('tournaments', 'PlayerRegistration')
active_players_count = PlayerRegistration.objects.filter(
team_registration__tournament=self,
team_registration__walk_out=False
).count()
return active_players_count
return min(active_teams_count, self.team_count)
def name_and_event(self):
@ -1512,7 +1526,13 @@ class Tournament(BaseModel):
if is_woman is not None and self.federal_category == FederalCategory.WOMEN and is_woman is False:
reasons.append("Ce tournoi est réservé aux femmes")
if birth_year is None:
if birth_year is None or birth_year == 'N/A':
return reasons if reasons else None
try:
tournament_start_year = self.season_year()
user_age = tournament_start_year - int(birth_year)
except (ValueError, TypeError):
return reasons if reasons else None
tournament_start_year = self.season_year()
@ -1745,12 +1765,16 @@ class Tournament(BaseModel):
def umpire_mail(self):
if self.umpire_custom_mail is not None:
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):
if self.umpire_custom_phone is not None:
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):
"""

@ -71,11 +71,11 @@ class TournamentEmailService:
return base_subject
@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:
TournamentEmailService.notify_team(team_registration, tournament, TeamEmailType.WAITING_LIST)
TournamentEmailService.notify_team(team_registration, tournament, TeamEmailType.WAITING_LIST, force_send)
else:
TournamentEmailService.notify_team(team_registration, tournament, TeamEmailType.REGISTERED)
TournamentEmailService.notify_team(team_registration, tournament, TeamEmailType.REGISTERED, force_send)
@staticmethod
def _build_registration_confirmation_email_body(tournament, captain, tournament_details_str, other_player):
@ -515,9 +515,9 @@ class TournamentEmailService:
return f"\n\n{warning_text}{action_text}{account_info}"
@staticmethod
def notify(captain, other_player, tournament, message_type: TeamEmailType):
def notify(captain, other_player, tournament, message_type: TeamEmailType, force_send=False):
print("TournamentEmailService.notify", captain.player_contact(), captain.registered_online, tournament, message_type)
if not captain or not captain.registered_online or not captain.player_contact():
if not captain or (not captain.registered_online and not force_send) or not captain.player_contact():
return
tournament_details_str = tournament.build_tournament_details_str()
@ -600,19 +600,19 @@ class TournamentEmailService:
# print"TournamentEmailService._send_email", to, subject)
@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
players = list(team.players_sorted_by_captain)
if len(players) == 2:
# print"TournamentEmailService.notify_team 2p", team)
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.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:
# print"TournamentEmailService.notify_team 1p", team)
# If there's only one player, just send them the notification
TournamentEmailService.notify(players[0], None, tournament, message_type)
TournamentEmailService.notify(players[0], None, tournament, message_type, force_send)
@staticmethod
def notify_umpire(team, tournament, message_type):
@ -749,7 +749,7 @@ class TournamentEmailService:
for player in player_registrations:
# Check both email and contact_email fields
player_email = player.player_contact()
if not player_email or not player.registered_online:
if not player_email:
continue
if player_email in processed_emails:
continue

@ -4,8 +4,10 @@ from django.urls import reverse
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.db import transaction
import stripe
from datetime import datetime, timedelta
import traceback
from ..models import TeamRegistration, PlayerRegistration, Tournament
from ..models.player_registration import PlayerPaymentType
@ -544,47 +546,67 @@ class PaymentService:
team_registration_id = metadata.get('team_registration_id')
registration_type = metadata.get('registration_type')
if tournament_id and registration_type == 'cart':
try:
tournament = Tournament.objects.get(id=tournament_id)
tournament.reserved_spots = max(0, tournament.reserved_spots - 1)
tournament.save()
print(f"Decreased reserved spots for tournament {tournament_id} 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
# Wrap all database operations in an atomic transaction
# This ensures either all changes are saved or none are
try:
print(f"Looking for team registration with ID: {team_registration_id}")
team_registration = TeamRegistration.objects.get(id=team_registration_id)
if tournament_id and registration_type == 'cart' and team_registration.tournament is None:
try:
tournament = Tournament.objects.get(id=tournament_id)
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():
return True
team_registration.confirm_registration(checkout_session.payment_intent)
with transaction.atomic():
if tournament_id and registration_type == 'cart':
try:
tournament = Tournament.objects.get(id=tournament_id)
tournament.reserved_spots = max(0, tournament.reserved_spots - 1)
tournament.save()
print(f"Decreased reserved spots for tournament {tournament_id}")
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
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:
print(f"Team registration not found with ID: {team_registration_id}")
return False
except Exception as e:
print(f"Error in _process_direct_payment: {str(e)}")
return False
print(f"❌ Error in process_direct_payment database operations: {str(e)}")
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
@csrf_exempt
@ -593,31 +615,75 @@ class PaymentService:
payload = request.body
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')
# Check if this is a Connect account webhook
stripe_account = request.META.get('HTTP_STRIPE_ACCOUNT')
# 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"Connect Account: {stripe_account}")
print(f"Connect Account Header: {stripe_account_header}")
# First, try to construct the event with any available webhook secret to inspect the payload
webhook_secrets = []
if hasattr(settings, 'XLR_STRIPE_WEBHOOK_SECRET') and settings.XLR_STRIPE_WEBHOOK_SECRET:
webhook_secrets.append(('XLR', settings.XLR_STRIPE_WEBHOOK_SECRET))
if hasattr(settings, 'TOURNAMENT_STRIPE_WEBHOOK_SECRET') and settings.TOURNAMENT_STRIPE_WEBHOOK_SECRET:
webhook_secrets.append(('TOURNAMENT', settings.TOURNAMENT_STRIPE_WEBHOOK_SECRET))
if hasattr(settings, 'SHOP_STRIPE_WEBHOOK_SECRET') and settings.SHOP_STRIPE_WEBHOOK_SECRET:
webhook_secrets.append(('SHOP', settings.SHOP_STRIPE_WEBHOOK_SECRET))
print(f"Available webhook secrets: {[name for name, _ in webhook_secrets]}")
if stripe_account:
# This is a connected account (regular umpire tournament)
webhook_secret = settings.TOURNAMENT_STRIPE_WEBHOOK_SECRET
print(f"Using umpire webhook secret for connected account: {stripe_account}")
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:
# This is platform account (corporate tournament)
webhook_secret = settings.XLR_STRIPE_WEBHOOK_SECRET
print("Using XLR company webhook secret")
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")
try:
event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret)
print(f"Tournament webhook event type: {event['type']}")
# Debug metadata
# Process the webhook event
stripe_object = event['data']['object']
metadata = stripe_object.get('metadata', {})
print(f"is_corporate_tournament: {metadata.get('is_corporate_tournament', 'unknown')}")
print(f"payment_source: {metadata.get('payment_source', 'unknown')}")
print(f"stripe_account_type: {metadata.get('stripe_account_type', 'unknown')}")
print(f"stripe_account_id: {metadata.get('stripe_account_id', 'unknown')}")
if event['type'] == 'checkout.session.completed':
success = PaymentService.process_direct_payment(stripe_object)
@ -628,33 +694,20 @@ class PaymentService:
print(f"Failed to process completed checkout session")
return HttpResponse(status=400)
elif event['type'] == 'payment_intent.payment_failed':
success = PaymentService.process_failed_payment_intent(stripe_object)
if success:
print(f"Successfully processed failed payment intent")
return HttpResponse(status=200)
else:
print(f"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)
# Handle other event types if needed
elif event['type'] == 'payment_intent.succeeded':
print(f"Payment intent succeeded - you might want to handle this")
return HttpResponse(status=200)
else:
print(f"Unhandled event type: {event['type']}")
return HttpResponse(status=200)
except Exception as e:
print(f"Webhook error: {str(e)}")
print(f"Error processing webhook: {str(e)}")
import traceback
traceback.print_exc()
return HttpResponse(status=400)
return HttpResponse("Webhook processing failed", status=500)
@staticmethod
def create_payment_link(team_registration_id):
@ -679,7 +732,7 @@ class PaymentService:
customer_email = team_registration.team_contact()
currency_code = tournament.currency_code or 'EUR'
print(f"[PAYMENT LINK] Tournament: {tournament.name}")
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)

@ -248,8 +248,8 @@ class RegistrationCartManager:
if tournament_federal_category == FederalCategory.MIXED and len(players) == 1:
other_player_is_woman = players[0].get('is_woman', False)
if other_player_is_woman == is_woman:
is_woman = not is_woman
if players[0].get('found_in_french_federation', False):
is_woman = not other_player_is_woman
player_data.update({
'rank': fed_data['rank'],
@ -260,8 +260,9 @@ class RegistrationCartManager:
if tournament_federal_category == FederalCategory.MIXED and len(players) == 1:
is_woman = fed_data.get('is_woman', False)
other_player_is_woman = players[0].get('is_woman', False)
if 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'}."
if players[0].get('found_in_french_federation', False):
if other_player_is_woman == is_woman:
return False, f"En mixte l'équipe doit obligatoirement contenir une joueuse et un joueur. La licence {licence_id} correspond à {'une' if is_woman else 'un'} {'femme' if is_woman else 'homme'}."
player_register_check = tournament.player_register_check(licence_id)
if player_register_check:

@ -110,7 +110,9 @@ class TournamentUnregistrationService:
def _delete_registered_team(self):
team_registration = self.player_registration.team_registration
team_registration.delete()
# team_registration.delete()
team_registration.cancel_registration()
team_registration.save()
def _cleanup_session(self):
self.request.session['team_registration'] = []

@ -77,15 +77,22 @@ def unregister_team(sender, instance, **kwargs):
notify_team(instance, instance.tournament, TeamEmailType.UNREGISTERED)
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:
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)
# Check if the team being deleted is in the waiting list
team_being_deleted = next((team for team in teams if team.team_registration.id == instance.id), None)
is_team_in_waiting_list = team_being_deleted and team_being_deleted.team_registration.out_of_tournament() == True
# Only notify the first waiting list team if the deleted team is NOT in the waiting list
if not is_team_in_waiting_list:
first_waiting_list_team = instance.tournament.first_waiting_list_team(teams)
if first_waiting_list_team and first_waiting_list_team.id != instance.id:
if instance.tournament.automatic_waiting_list():
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)
def notify_players_of_tournament_cancellation(sender, instance, **kwargs):
@ -154,7 +161,7 @@ def warn_team_walkout_status_change(sender, instance, **kwargs):
try:
previous_instance = TeamRegistration.objects.get(id=instance.id)
except TeamRegistration.DoesNotExist:
print("TeamRegistration.DoesNotExist")
print("warn_team_walkout > TeamRegistration.DoesNotExist")
return
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)
elif not previous_instance.out_of_tournament() and instance.out_of_tournament():
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:
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

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

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

@ -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 %}

@ -150,11 +150,23 @@
{% endif %}
{% if add_player_form.first_tournament or add_player_form.user_without_licence or tournament.license_is_required is False %}
{% if not add_player_form.user_without_licence and tournament.license_is_required is True %}
{% if current_players|length > 0 %}
<div class="semibold">
Padel Club n'a pas trouvé votre partenaire, il se peut qu'il s'agisse de son premier tournoi. Contacter le juge-arbitre après l'inscription si ce n'est pas le cas.
</div>
<div class="semibold">
Précisez les informations du joueur :
</div>
{% elif current_players|length == 0 and not user.is_authenticated %}
<div class="semibold">
Veuillez renseigner vos informations :
</div>
{% endif %}
{% endif %}
{% if tournament.license_is_required is False and current_players|length == 0 and not user.is_authenticated %}
<div class="semibold">
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 :
Veuillez renseigner vos informations :
</div>
{% endif %}

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

@ -164,7 +164,7 @@
{% else %}
<div class="topmargin20">
<p class="minor info">
La dé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>
</div>
{% endif %}

@ -42,8 +42,12 @@
</div>
{% endif %}
{% else %}
{% if tournament.is_team_tournament %}
<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 %}
<div class="box small {{ status.box_class }}">
{{ status.short_label }}
@ -52,8 +56,12 @@
{% endif %}
{% endwith %}
{% else %}
{% if tournament.is_team_tournament %}
<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 %}
<div class="box small {{ status.box_class }}">
{{ status.short_label }}

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

@ -69,6 +69,12 @@ from .models import AnimationType
logger = logging.getLogger(__name__)
def get_object(type, id):
try:
return type.objects.get(pk=id)
except (type.DoesNotExist, ValueError, ValidationError):
raise Http404(f"{type.__name__} does not exist")
def index(request):
now = timezone.now()
thirty_days_ago = now - timedelta(days=30)
@ -78,7 +84,7 @@ def index(request):
if club_id:
tournaments = tournaments_query(Q(end_date__isnull=True), club_id, True, 50)
else:
tournaments = tournaments_query(Q(end_date__isnull=True, start_date__gte=thirty_days_ago, start_date__lte=thirty_days_future), club_id, True, 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()]
live = []
@ -118,7 +124,7 @@ def tournaments_query(query, club_id, ascending, limit=None):
club = None
if club_id:
club = get_object_or_404(Club, pk=club_id)
club = get_object(Club, club_id)
q_club = Q(event__club=club)
queries.append(q_club)
else:
@ -393,12 +399,16 @@ def event(request, event_id):
else:
name = 'Événement'
first_title = ''
if event.club:
first_title = event.club.name
return render(
request,
"tournaments/tournaments_list.html",
{
'tournaments': tournaments,
'first_title': event.club.name,
'first_title': first_title,
'second_title': name,
'head_title': name,
'first_tournament_prog_url': first_tournament_prog_url,
@ -1896,7 +1906,20 @@ def handle_add_player_request(request, tournament, cart_manager, context):
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:
messages.success(request, message)
# Refresh cart data

Loading…
Cancel
Save