sync_v2
Laurent 6 months ago
commit 52a0b5ec41
  1. 12
      api/serializers.py
  2. 5
      api/urls.py
  3. 146
      api/views.py
  4. 9
      padelclub_backend/settings.py
  5. 28
      padelclub_backend/settings_local.py.dist
  6. 1
      requirements.txt
  7. 64
      shop/admin.py
  8. 2
      shop/management/commands/create_initial_shop_data.py
  9. 21
      shop/migrations/0026_alter_order_user.py
  10. 4
      shop/models.py
  11. 46
      shop/signals.py
  12. 2
      shop/stripe_utils.py
  13. 18
      shop/templates/admin/shop/order/change_list.html
  14. 103
      shop/templates/admin/shop/order/preparation_view.html
  15. 65
      tournaments/admin.py
  16. 1
      tournaments/apps.py
  17. 8
      tournaments/custom_views.py
  18. 12
      tournaments/management/commands/check_deadlines.py
  19. 57
      tournaments/management/commands/schedule_tasks.py
  20. 38
      tournaments/middleware.py
  21. 133
      tournaments/migrations/0116_customuser_disable_ranking_federal_ruling_and_more.py
  22. 35
      tournaments/migrations/0117_playerregistration_user_teamregistration_user_and_more.py
  23. 2
      tournaments/models/__init__.py
  24. 24
      tournaments/models/custom_user.py
  25. 55
      tournaments/models/enums.py
  26. 2
      tournaments/models/group_stage.py
  27. 14
      tournaments/models/match.py
  28. 14
      tournaments/models/player_registration.py
  29. 3
      tournaments/models/round.py
  30. 224
      tournaments/models/team_registration.py
  31. 2
      tournaments/models/team_score.py
  32. 534
      tournaments/models/tournament.py
  33. 7
      tournaments/models/unregistered_player.py
  34. 3
      tournaments/models/unregistered_team.py
  35. 81
      tournaments/repositories.py
  36. 414
      tournaments/services/email_service.py
  37. 401
      tournaments/services/payment_service.py
  38. 810
      tournaments/services/tournament_registration.py
  39. 81
      tournaments/services/tournament_unregistration.py
  40. 55
      tournaments/signals.py
  41. 47
      tournaments/static/tournaments/css/style.css
  42. 15
      tournaments/static/tournaments/css/tournament_bracket.css
  43. 101
      tournaments/static/tournaments/js/tournament_bracket.js
  44. 34
      tournaments/tasks.py
  45. 440
      tournaments/templates/register_tournament.html
  46. 69
      tournaments/templates/registration/my_tournaments.html
  47. 49
      tournaments/templates/tournaments/bracket_match_cell.html
  48. 7
      tournaments/templates/tournaments/broadcast/broadcasted_bracket.html
  49. 45
      tournaments/templates/tournaments/broadcast/broadcasted_match.html
  50. 42
      tournaments/templates/tournaments/match_cell.html
  51. 2
      tournaments/templates/tournaments/team_details.html
  52. 2
      tournaments/templates/tournaments/tournament_bracket.html
  53. 145
      tournaments/templates/tournaments/tournament_info.html
  54. 5
      tournaments/templates/tournaments/tournament_row.html
  55. 7
      tournaments/templates/tournaments/tournaments.html
  56. 9
      tournaments/urls.py
  57. 2
      tournaments/utils/apns.py
  58. 2
      tournaments/utils/licence_validator.py
  59. 671
      tournaments/views.py

@ -6,7 +6,7 @@ from django.conf import settings
# email
from django.template.loader import render_to_string
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.utils.http import urlsafe_base64_encode
from django.utils.encoding import force_bytes
from django.core.mail import EmailMessage
from django.contrib.sites.shortcuts import get_current_site
@ -15,7 +15,7 @@ from api.tokens import account_activation_token
from shared.cryptography import encryption_util
from tournaments.models.draw_log import DrawLog
from tournaments.models.enums import UserOrigin
from tournaments.models.enums import UserOrigin, RegistrationPaymentMode
class EncryptedUserField(serializers.Field):
def to_representation(self, value):
@ -78,6 +78,14 @@ class UserSerializer(serializers.ModelSerializer):
loser_bracket_match_format_preference=validated_data.get('loser_bracket_match_format_preference'),
loser_bracket_mode=validated_data.get('loser_bracket_mode'),
origin=UserOrigin.APP,
user_role=None,
registration_payment_mode=validated_data.get('registration_payment_mode', RegistrationPaymentMode.DISABLED),
umpire_custom_mail=validated_data.get('umpire_custom_mail'),
umpire_custom_contact=validated_data.get('umpire_custom_contact'),
umpire_custom_phone=validated_data.get('umpire_custom_phone'),
hide_umpire_mail=validated_data.get('hide_umpire_mail', False),
hide_umpire_phone=validated_data.get('hide_umpire_phone', True),
disable_ranking_federal_ruling=validated_data.get('disable_ranking_federal_ruling', False)
)
self.send_email(self.context['request'], user)

@ -37,6 +37,11 @@ urlpatterns = [
path('api-token-auth/', obtain_auth_token, name='api_token_auth'),
path("user-by-token/", views.user_by_token, name="user_by_token"),
path('refund-tournament/<str:team_registration_id>/', views.process_refund, name='process-refund'),
path('validate-stripe-account/', views.validate_stripe_account, name='validate_stripe_account'),
path('xls-to-csv/', views.xls_to_csv, name='xls-to-csv'),
path('config/tournament/', views.get_tournament_config, name='tournament-config'),
path('config/payment/', views.get_payment_config, name='payment-config'),
# authentication
path("change-password/", ChangePasswordView.as_view(), name="change_password"),

@ -1,7 +1,7 @@
from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, DrawLogSerializer, TournamentSerializer, UserSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, PurchaseSerializer, ShortUserSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer, CustomUserSerializer, UnregisteredTeamSerializer, UnregisteredPlayerSerializer
from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer
from rest_framework import viewsets, permissions
from rest_framework import viewsets
from rest_framework.response import Response
from rest_framework.decorators import api_view
from rest_framework import status
@ -15,6 +15,21 @@ from .utils import check_version_smaller_than_1_1_12
from shared.discord import send_discord_log_message
from rest_framework.decorators import permission_classes
from rest_framework.permissions import IsAuthenticated
from django.shortcuts import get_object_or_404
from tournaments.services.payment_service import PaymentService
from django.conf import settings
import stripe
import json
import pandas as pd
from tournaments.utils.extensions import create_random_filename
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
import os
from django.http import HttpResponse
@api_view(['GET'])
def user_by_token(request):
serializer = UserSerializer(request.user)
@ -284,3 +299,132 @@ class ShortUserViewSet(viewsets.ModelViewSet):
queryset = CustomUser.objects.all()
serializer_class = ShortUserSerializer
permission_classes = [] # Users are public whereas the other requests are only for logged users
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def process_refund(request, team_registration_id):
try:
# Verify the user is the tournament umpire
team_registration = get_object_or_404(TeamRegistration, id=team_registration_id)
if request.user != team_registration.tournament.event.creator:
return Response({
'success': False,
'message': "Vous n'êtes pas autorisé à effectuer ce remboursement"
}, status=403)
payment_service = PaymentService(request)
players_serializer = PlayerRegistrationSerializer(team_registration.players_sorted_by_rank, many=True)
success, message, refund = payment_service.process_refund(team_registration_id)
return Response({
'success': success,
'message': message,
'players': players_serializer.data
})
except Exception as e:
return Response({
'success': False,
'message': str(e)
}, status=400)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def validate_stripe_account(request):
stripe.api_key = settings.STRIPE_SECRET_KEY
# Parse the request body
data = json.loads(request.body)
account_id = data.get('account_id')
if not account_id:
return Response({
'valid': False,
'error': 'Account ID is required'
}, status=400)
# Try to retrieve the account from Stripe
try:
# Basic account verification
account = stripe.Account.retrieve(account_id)
# Only check if the account can receive payments
is_valid = account.id is not None
return Response({
'valid': is_valid,
'account': {
'id': account.id
}
})
except stripe.error.PermissionError:
return Response({
'valid': False,
'error': 'No permission to access this account'
}, status=403)
except stripe.error.InvalidRequestError:
return Response({
'valid': False,
'error': 'Invalid account ID'
}, status=400)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def xls_to_csv(request):
# Check if the request has a file
if 'file' in request.FILES:
uploaded_file = request.FILES['file']
# Save the uploaded file
directory = 'tmp/csv/'
file_path = os.path.join(directory, uploaded_file.name)
file_name = default_storage.save(file_path, ContentFile(uploaded_file.read()))
# Check available sheets and look for 'inscriptions'
xls = pd.ExcelFile(file_name)
sheet_names = xls.sheet_names
# Determine which sheet to use
target_sheet = 0 # Default to first sheet
if 'inscriptions' in [name.lower() for name in sheet_names]:
for i, name in enumerate(sheet_names):
if name.lower() == 'inscriptions':
target_sheet = i # or use the name directly: target_sheet = name
break
# Convert to csv and save
data_xls = pd.read_excel(file_name, sheet_name=target_sheet, index_col=None)
csv_file_name = create_random_filename('players', 'csv')
output_path = os.path.join(directory, csv_file_name)
data_xls.to_csv(output_path, sep=';', index=False, encoding='utf-8')
# Send the processed file back
with default_storage.open(output_path, 'rb') as file:
response = HttpResponse(file.read(), content_type='application/octet-stream')
response['Content-Disposition'] = f'attachment; filename="players.csv"'
# Clean up: delete both files
default_storage.delete(file_path)
default_storage.delete(output_path)
return response
else:
return HttpResponse("No file was uploaded", status=400)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_tournament_config(request):
"""Return tournament-related configuration settings"""
config = settings.TOURNAMENT_SETTINGS
return Response({
'time_proximity_rules': config['TIME_PROXIMITY_RULES'],
'waiting_list_rules': config['WAITING_LIST_RULES'],
'business_rules': config['BUSINESS_RULES'],
'minimum_response_time': config['MINIMUM_RESPONSE_TIME']
})
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_payment_config(request):
"""Return payment-related configuration settings"""
return Response({
'stripe_fee': getattr(settings, 'STRIPE_FEE', 0)
})

@ -49,6 +49,8 @@ INSTALLED_APPS = [
'qr_code',
'channels_redis',
'django_filters',
'background_task',
]
AUTH_USER_MODEL = "tournaments.CustomUser"
@ -62,6 +64,7 @@ MIDDLEWARE = [
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'tournaments.middleware.ReferrerMiddleware', # Add this line
'tournaments.middleware.RegistrationCartCleanupMiddleware',
]
@ -159,11 +162,7 @@ AUTHENTICATION_BACKENDS = [
]
CSRF_COOKIE_SECURE = True # if using HTTPS
if DEBUG: # Development environment
SESSION_COOKIE_SECURE = False
else: # Production environment
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
LOGGING = {
'version': 1,

@ -40,4 +40,30 @@ DATABASES = {
STRIPE_MODE = 'test'
STRIPE_PUBLISHABLE_KEY = ''
STRIPE_SECRET_KEY = ''
STRIPE_WEBHOOK_SECRET = ''
SHOP_STRIPE_WEBHOOK_SECRET = 'whsec_...' # Your existing webhook secret
TOURNAMENT_STRIPE_WEBHOOK_SECRET = 'whsec_...' # New webhook secret for tournaments
STRIPE_FEE = 0.0075
TOURNAMENT_SETTINGS = {
'TIME_PROXIMITY_RULES': {
24: 30, # within 24h → 30 min
48: 60, # within 48h → 60 min
72: 120, # within 72h → 120 min
'default': 240
},
'WAITING_LIST_RULES': {
30: 30, # 30+ teams → 30 min
20: 60, # 20+ teams → 60 min
10: 120, # 10+ teams → 120 min
'default': 240
},
'BUSINESS_RULES': {
'hours': {
'start': 8, # 8:00
'end': 21, # 21:00
}
},
'MINIMUM_RESPONSE_TIME': 30, # requires to be like the BACKGROUND_SCHEDULED_TASK_INTERVAL
}
BACKGROUND_SCHEDULED_TASK_INTERVAL = 30 # minutes
LIVE_TESTING = False

@ -16,3 +16,4 @@ openpyxl==3.1.5
django-filter==24.3
cryptography==41.0.7
stripe==11.6.0
django-background-tasks==1.2.8

@ -1,5 +1,6 @@
from django.contrib import admin
from .models import Product, Color, Size, Order, OrderItem, GuestUser, Coupon, CouponUsage
from django.shortcuts import render
from .models import Product, Color, Size, Order, OrderItem, GuestUser, Coupon, CouponUsage, OrderStatus
from django.utils.html import format_html
@admin.register(Product)
@ -40,6 +41,67 @@ class OrderItemInline(admin.TabularInline):
class OrderAdmin(admin.ModelAdmin):
list_display = ('id', 'date_ordered', 'status', 'total_price')
inlines = [OrderItemInline]
list_filter = ('status', 'payment_status')
def changelist_view(self, request, extra_context=None):
# If 'show_preparation' parameter is in the request, show the preparation view
if 'show_preparation' in request.GET:
return self.preparation_view(request)
# Otherwise show the normal change list
extra_context = extra_context or {}
paid_orders_count = Order.objects.filter(status=OrderStatus.PAID).count()
extra_context['paid_orders_count'] = paid_orders_count
return super().changelist_view(request, extra_context=extra_context)
def preparation_view(self, request):
"""View for items that need to be prepared"""
# Get paid orders
orders = Order.objects.filter(status=OrderStatus.PAID).order_by('-date_ordered')
# Group items by product, color, size
items_by_variant = {}
all_items = OrderItem.objects.filter(order__status=OrderStatus.PAID)
for item in all_items:
# Create a key for grouping items
key = (
str(item.product.id),
str(item.color.id) if item.color else 'none',
str(item.size.id) if item.size else 'none'
)
if key not in items_by_variant:
items_by_variant[key] = {
'product': item.product,
'color': item.color,
'size': item.size,
'quantity': 0,
'orders': set()
}
items_by_variant[key]['quantity'] += item.quantity
items_by_variant[key]['orders'].add(item.order.id)
# Convert to list and sort
items_list = list(items_by_variant.values())
items_list.sort(key=lambda x: x['product'].title)
context = {
'title': 'Orders to Prepare',
'app_label': 'shop',
'opts': Order._meta,
'orders': orders,
'items': items_list,
'total_orders': orders.count(),
'total_items': sum(i['quantity'] for i in items_list)
}
return render(
request,
'admin/shop/order/preparation_view.html',
context
)
class GuestUserOrderInline(admin.TabularInline):
model = Order

@ -122,7 +122,7 @@ class Command(BaseCommand):
'price': 25.00,
'ordering_value': 40,
'cut': 2, # Men
'colors': ['Blanc / Gris Clair', 'Bleu Sport / Blanc', 'Bleu Sport / Bleu Sport Chine', 'Noir', 'Noir / Gris Foncé Chiné'],
'colors': ['Blanc / Gris Clair', 'Bleu Sport / Blanc', 'Bleu Sport / Bleu Sport Chiné', 'Noir', 'Noir / Gris Foncé Chiné'],
'sizes': ['S', 'M', 'L', 'XL', 'XXL', '3XL'],
'image_filename': 'tshirt_h.png'
},

@ -0,0 +1,21 @@
# Generated by Django 5.1 on 2025-05-01 05:59
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0025_alter_product_cut'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='order',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
),
]

@ -1,5 +1,6 @@
from django.db import models
from django.conf import settings
from django.utils import timezone
class OrderStatus(models.TextChoices):
PENDING = 'PENDING', 'Pending'
@ -94,7 +95,6 @@ class Coupon(models.Model):
return self.code
def is_valid(self):
from django.utils import timezone
now = timezone.now()
if not self.is_active:
return False
@ -111,7 +111,7 @@ class Coupon(models.Model):
return self.discount_amount
class Order(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True)
date_ordered = models.DateTimeField(auto_now_add=True)
status = models.CharField(max_length=20, choices=OrderStatus.choices, default=OrderStatus.PENDING)
total_price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00)

@ -1,4 +1,4 @@
from django.db.models.signals import post_save, post_delete
from django.db.models.signals import pre_save, post_delete
from django.dispatch import receiver
from django.core.mail import send_mail
from django.conf import settings
@ -8,18 +8,38 @@ from django.db import transaction
from django.contrib.auth.signals import user_logged_in
from .cart import transfer_cart
@receiver([post_save, post_delete], sender=Order)
@receiver([pre_save, post_delete], sender=Order)
def send_order_notification(sender, instance, **kwargs):
"""Send an email notification when an order is created, updated, or deleted."""
transaction.on_commit(lambda: _send_order_email(instance, **kwargs))
# For pre_save, we need to check if the instance exists in the database
if kwargs.get('signal', None) == pre_save:
try:
# Get the current instance from the database
old_instance = Order.objects.get(pk=instance.pk)
# Only send notification if status has changed
if old_instance.status != instance.status:
# Execute on commit to ensure DB consistency
transaction.on_commit(lambda: _send_order_email(instance, old_status=old_instance.status, **kwargs))
except Order.DoesNotExist:
# This is a new instance (creation)
# You might want to handle creation differently or just pass
pass
else:
# Handle post_delete
transaction.on_commit(lambda: _send_order_email(instance, **kwargs))
def _send_order_email(instance, **kwargs):
def _send_order_email(instance, old_status=None, **kwargs):
# Skip processing for PENDING orders
if instance.status == OrderStatus.PENDING:
return
# Determine action type
action = _determine_action_type(kwargs)
if 'signal' in kwargs and kwargs['signal'] == post_delete:
action = "DELETED"
elif old_status is None:
action = "CREATED"
else:
action = "UPDATED"
if action in ["DELETED", "CREATED"]:
return # No emails for these actions
@ -34,15 +54,6 @@ def _send_order_email(instance, **kwargs):
if order_details['customer_email']:
_send_customer_notification(instance, order_details, items_list)
def _determine_action_type(kwargs):
"""Determine the action type from signal kwargs."""
if 'signal' in kwargs and kwargs['signal'] == post_delete:
return "DELETED"
elif kwargs.get('created', False):
return "CREATED"
else:
return "UPDATED"
def _get_order_details(instance):
"""Extract and build order details dictionary."""
# Get customer info
@ -122,14 +133,17 @@ def _send_internal_notification(instance, action, order_details, items_list):
# Build price information with coupon details if applicable
price_info = f"Prix total: {order_details['total_price']}"
server = ""
if settings.DEBUG:
server = "DEBUG: "
if order_details['has_coupon']:
price_info = f"""
Prix total: {order_details['total_price']}
{order_details['coupon_info']}
Réduction: -{order_details['discount_amount']}
Montant payé: {order_details['final_price']}"""
subject = f"Commande #{order_details['order_id']} {action_fr}: {order_details['status_fr']}"
subject = f"{server}Commande #{order_details['order_id']} {action_fr}: {order_details['status_fr']}"
message = f"""
La commande #{order_details['order_id']} a été {action_fr.lower()}

@ -15,7 +15,7 @@ class StripeService:
# Get appropriate keys based on mode
self.api_key = settings.STRIPE_SECRET_KEY
self.publishable_key = settings.STRIPE_PUBLISHABLE_KEY
self.webhook_secret = settings.STRIPE_WEBHOOK_SECRET
self.webhook_secret = settings.SHOP_STRIPE_WEBHOOK_SECRET
self.currency = getattr(settings, 'STRIPE_CURRENCY', 'eur')
# Configure Stripe library

@ -0,0 +1,18 @@
{% extends "admin/change_list.html" %}
{% block object-tools %}
<ul class="object-tools">
<li>
<a href="?show_preparation=1" class="viewlink">
Orders to Prepare ({{ paid_orders_count }})
</a>
</li>
{% if has_add_permission %}
<li>
<a href="{% url 'admin:shop_order_add' %}" class="addlink">
Add Order
</a>
</li>
{% endif %}
</ul>
{% endblock %}

@ -0,0 +1,103 @@
{% extends "admin/base_site.html" %}
{% block content %}
<div id="content-main">
<p>Total orders with status PAID: {{ total_orders }}</p>
<p>Total items to prepare: {{ total_items }}</p>
<button onclick="window.print()" style="margin-bottom: 20px">Print This Page</button>
<a href="?" class="button" style="margin-left: 10px">Back to Orders</a>
<h2>Items Summary</h2>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr>
<th style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">Product</th>
<th style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">Color</th>
<th style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">Size</th>
<th style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">Quantity</th>
<th style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">Orders</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td style="padding: 8px; border-bottom: 1px solid #ddd;">{{ item.product.title }}</td>
<td style="padding: 8px; border-bottom: 1px solid #ddd;">
{% if item.color %}
<span style="display: inline-block; width: 15px; height: 15px; border-radius: 50%; background-color: {{ item.color.colorHex }}; margin-right: 5px;"></span>
{{ item.color.name }}
{% else %}
-
{% endif %}
</td>
<td style="padding: 8px; border-bottom: 1px solid #ddd;">{{ item.size.name|default:"-" }}</td>
<td style="padding: 8px; border-bottom: 1px solid #ddd; font-weight: bold;">{{ item.quantity }}</td>
<td style="padding: 8px; border-bottom: 1px solid #ddd;">
{% for order_id in item.orders %}
<a href="../{{ order_id }}/change/">Order #{{ order_id }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" style="padding: 8px; text-align: center;">No items to prepare</td>
</tr>
{% endfor %}
</tbody>
</table>
<h2 style="margin-top: 30px;">Order Details</h2>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr>
<th style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">Order #</th>
<th style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">Date</th>
<th style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">Customer</th>
<th style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">Items</th>
</tr>
</thead>
<tbody>
{% for order in orders %}
<tr>
<td style="padding: 8px; border-bottom: 1px solid #ddd;"><a href="../{{ order.id }}/change/">Order #{{ order.id }}</a></td>
<td style="padding: 8px; border-bottom: 1px solid #ddd;">{{ order.date_ordered|date:"Y-m-d H:i" }}</td>
<td style="padding: 8px; border-bottom: 1px solid #ddd;">
{% if order.user %}
{{ order.user.email }}
{% elif order.guest_user %}
{{ order.guest_user.email }} (Guest)
{% else %}
Unknown
{% endif %}
</td>
<td style="padding: 8px; border-bottom: 1px solid #ddd;">
{% for item in order.items.all %}
{{ item.quantity }}x {{ item.product.title }}
{% if item.color %} ({{ item.color.name }}){% endif %}
{% if item.size %} [{{ item.size.name }}]{% endif %}
<br>
{% endfor %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" style="padding: 8px; text-align: center;">No orders found</td>
</tr>
{% endfor %}
</tbody>
</table>
<style>
@media print {
button, .button, #header, .breadcrumbs {
display: none !important;
}
body {
padding: 0;
margin: 0;
}
}
</style>
</div>
{% endblock %}

@ -1,7 +1,10 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.utils import timezone
from django.contrib.admin.models import LogEntry, ADDITION, CHANGE, DELETION
from django.utils.html import escape
from django.urls import reverse
from django.utils.safestring import mark_safe
from .models import Club, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, Court, DateInterval, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer
from .forms import CustomUserCreationForm, CustomUserChangeForm
@ -13,13 +16,13 @@ class CustomUserAdmin(UserAdmin):
form = CustomUserChangeForm
add_form = CustomUserCreationForm
model = CustomUser
search_fields = ('username', 'email', 'phone', 'first_name', 'last_name', 'licence_id')
search_fields = ['username', 'email', 'phone', 'first_name', 'last_name', 'licence_id']
list_display = ['email', 'first_name', 'last_name', 'username', 'licence_id', 'date_joined', 'latest_event_club_name', 'is_active', 'event_count', 'origin']
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']
ordering = ['-date_joined']
fieldsets = [
(None, {'fields': ['id', 'username', 'email', 'password', 'first_name', 'last_name', 'is_active',
(None, {'fields': ['id', 'username', 'email', 'password', 'first_name', 'last_name', 'is_active', 'registration_payment_mode',
'clubs', 'country', 'phone', 'licence_id', 'umpire_code',
'summons_message_body', 'summons_message_signature', 'summons_available_payment_methods',
'summons_display_format', 'summons_display_entry_fee', 'summons_use_full_custom_message',
@ -42,7 +45,7 @@ class CustomUserAdmin(UserAdmin):
super().save_model(request, obj, form, change)
class EventAdmin(SyncedObjectAdmin):
list_display = ['creation_date', 'name', 'club', 'creator', 'tenup_id']
list_display = ['creation_date', 'name', 'club', 'creator', 'creator_full_name', 'tenup_id']
list_filter = ['creator', 'tenup_id']
raw_id_fields = ['creator']
ordering = ['-creation_date']
@ -83,7 +86,7 @@ class RoundAdmin(SyncedObjectAdmin):
class PlayerRegistrationAdmin(SyncedObjectAdmin):
list_display = ['first_name', 'last_name', 'licence_id', 'rank']
search_fields = ('id', 'first_name', 'last_name', 'licence_id__icontains')
search_fields = ['id', 'first_name', 'last_name', 'licence_id__icontains']
list_filter = ['registered_online', TeamScoreTournamentListFilter]
ordering = ['last_name', 'first_name']
raw_id_fields = ['team_registration'] # Add this line
@ -111,9 +114,9 @@ class GroupStageAdmin(SyncedObjectAdmin):
class ClubAdmin(SyncedObjectAdmin):
list_display = ['name', 'acronym', 'city', 'creator', 'events_count', 'broadcast_code']
search_fields = ('name', 'acronym', 'city')
search_fields = ['name', 'acronym', 'city']
ordering = ['creator']
raw_id_fields = ['creator']
raw_id_fields = ['creator', 'related_user']
class PurchaseAdmin(SyncedObjectAdmin):
list_display = ['id', 'user', 'product_id', 'quantity', 'purchase_date', 'revocation_date', 'expiration_date']
@ -150,10 +153,54 @@ class UnregisteredTeamAdmin(admin.ModelAdmin):
class UnregisteredPlayerAdmin(admin.ModelAdmin):
list_display = ['first_name', 'last_name', 'licence_id']
search_fields = ('first_name', 'last_name')
search_fields = ['first_name', 'last_name']
list_filter = []
ordering = ['last_name', 'first_name']
action_flags = {
ADDITION: 'Addition',
CHANGE: 'Change',
DELETION: 'Deletion',
}
@admin.register(LogEntry)
class LogEntryAdmin(admin.ModelAdmin):
date_hierarchy = 'action_time'
list_filter = ['user', 'content_type', 'action_flag']
search_fields = ['object_repr', 'change_message']
list_display = ['action_time', 'user', 'content_type', 'object_link', 'action_flag_display', 'change_message']
readonly_fields = [field.name for field in LogEntry._meta.get_fields()]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
def object_link(self, obj):
if obj.action_flag == DELETION:
link = escape(obj.object_repr)
else:
ct = obj.content_type
try:
link = '<a href="%s">%s</a>' % (
reverse('admin:%s_%s_change' % (ct.app_label, ct.model),
args=[obj.object_id]),
escape(obj.object_repr),
)
except:
link = escape(obj.object_repr)
return mark_safe(link)
object_link.short_description = 'Object'
def action_flag_display(self, obj):
return action_flags.get(obj.action_flag, '')
action_flag_display.short_description = 'Action'
admin.site.register(CustomUser, CustomUserAdmin)
admin.site.register(Club, ClubAdmin)
admin.site.register(Event, EventAdmin)

@ -1,4 +1,5 @@
from django.apps import AppConfig
import logging
class TournamentsConfig(AppConfig):
name = 'tournaments'

@ -38,5 +38,11 @@ class CustomLoginView(auth_views.LoginView):
for key in keys_to_clear:
del request.session[key]
messages.get_messages(request).used = True
storage = messages.get_messages(request)
for _ in storage:
pass
if len(storage._loaded_messages) == 1:
del storage._loaded_messages[0]
return super().get(request, *args, **kwargs)

@ -0,0 +1,12 @@
from django.core.management.base import BaseCommand
from tournaments.tasks import check_confirmation_deadlines
from background_task.models import Task
class Command(BaseCommand):
help = 'Run confirmation deadline check immediately'
def handle(self, *args, **options):
# Run the function directly (not through the task queue)
Task.objects.filter(task_name='tournaments.tasks.check_confirmation_deadlines').delete()
check_confirmation_deadlines()
self.stdout.write(self.style.SUCCESS('Successfully checked confirmation deadlines'))

@ -0,0 +1,57 @@
from django.core.management.base import BaseCommand
from tournaments.tasks import background_task_check_confirmation_deadlines
from django.utils import timezone
import datetime
from background_task.models import Task
from django.conf import settings
class Command(BaseCommand):
help = 'Schedule background tasks to run at :00 and :30 of every hour'
def handle(self, *args, **options):
# Clear existing tasks first to avoid duplicates
Task.objects.filter(task_name='tournaments.tasks.check_confirmation_deadlines').delete()
# Get the current timezone-aware time
now = timezone.now()
# Get local timezone for display purposes
local_timezone = timezone.get_current_timezone()
local_now = now.astimezone(local_timezone)
# Calculate time until next interval
current_minute = local_now.minute
minutes_until_next = settings.BACKGROUND_SCHEDULED_TASK_INTERVAL - (current_minute % settings.BACKGROUND_SCHEDULED_TASK_INTERVAL)
next_minute = (current_minute + minutes_until_next) % 60
next_hour = local_now.hour + ((current_minute + minutes_until_next) // 60)
# Create a datetime with the next run time in local time
first_run_local = local_now.replace(
hour=next_hour,
minute=next_minute + 1, #let the expiration time be off first
second=0,
microsecond=0
)
# Handle day rollover if needed
if first_run_local < local_now: # This would happen if we crossed midnight
first_run_local += datetime.timedelta(days=1)
# Calculate seconds from now until the first run
seconds_until_first_run = (first_run_local - local_now).total_seconds()
if seconds_until_first_run < 0:
seconds_until_first_run = 0 # If somehow negative, run immediately
# Schedule with seconds delay instead of a specific datetime
background_task_check_confirmation_deadlines(
schedule=int(seconds_until_first_run), # Delay in seconds before first run
repeat=settings.BACKGROUND_SCHEDULED_TASK_INTERVAL * 60 # 30 minutes in seconds
)
# Show the message with proper timezone info
local_timezone_name = local_timezone.tzname(local_now)
self.stdout.write(self.style.SUCCESS(
f'Task scheduled to first run at {first_run_local.strftime("%H:%M:%S")} {local_timezone_name} '
f'(in {int(seconds_until_first_run)} seconds) '
f'and then every {settings.BACKGROUND_SCHEDULED_TASK_INTERVAL} minutes'
))

@ -1,5 +1,6 @@
from django.conf import settings
from django.urls import resolve, reverse
from django.urls import reverse
from django.utils import timezone
import datetime
class ReferrerMiddleware:
def __init__(self, get_response):
@ -17,3 +18,36 @@ class ReferrerMiddleware:
response = self.get_response(request)
return response
class RegistrationCartCleanupMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
self._check_and_clean_expired_cart(request)
response = self.get_response(request)
return response
def _check_and_clean_expired_cart(self, request):
if 'registration_cart_expiry' in request.session:
try:
expiry_str = request.session['registration_cart_expiry']
expiry = datetime.datetime.fromisoformat(expiry_str)
if timezone.now() > expiry:
# Clear expired cart
keys_to_delete = [
'registration_cart_id',
'registration_tournament_id',
'registration_cart_players',
'registration_cart_expiry',
'registration_mobile_number'
]
for key in keys_to_delete:
if key in request.session:
del request.session[key]
request.session.modified = True
except (ValueError, TypeError):
# Invalid expiry format, clear it
if 'registration_cart_expiry' in request.session:
del request.session['registration_cart_expiry']
request.session.modified = True

@ -0,0 +1,133 @@
# Generated by Django 5.1 on 2025-04-14 09:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0115_auto_20250403_1503'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='disable_ranking_federal_ruling',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='customuser',
name='hide_umpire_mail',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='customuser',
name='hide_umpire_phone',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='customuser',
name='registration_payment_mode',
field=models.IntegerField(choices=[(0, 'Disabled'), (1, 'Corporate'), (2, 'No Service Fee'), (3, 'Stripe')], default=0),
),
migrations.AddField(
model_name='customuser',
name='umpire_custom_contact',
field=models.CharField(blank=True, max_length=200, null=True),
),
migrations.AddField(
model_name='customuser',
name='umpire_custom_mail',
field=models.EmailField(blank=True, max_length=254, null=True),
),
migrations.AddField(
model_name='customuser',
name='umpire_custom_phone',
field=models.CharField(blank=True, max_length=15, null=True),
),
migrations.AddField(
model_name='customuser',
name='user_role',
field=models.IntegerField(blank=True, choices=[(0, 'Juge-Arbitre'), (1, 'Club Owner'), (2, 'Player')], null=True),
),
migrations.AddField(
model_name='playerregistration',
name='payment_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='playerregistration',
name='registration_status',
field=models.IntegerField(choices=[(0, 'Waiting'), (1, 'Pending'), (2, 'Confirmed'), (3, 'Canceled')], default=0),
),
migrations.AddField(
model_name='playerregistration',
name='time_to_confirm',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='tournament',
name='enable_online_payment',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='tournament',
name='enable_online_payment_refund',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='tournament',
name='enable_time_to_confirm',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='tournament',
name='is_corporate_tournament',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='tournament',
name='is_template',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='tournament',
name='online_payment_is_mandatory',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='tournament',
name='refund_date_limit',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='tournament',
name='reserved_spots',
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name='tournament',
name='stripe_account_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='unregisteredplayer',
name='payment_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='unregisteredplayer',
name='payment_type',
field=models.IntegerField(blank=True, choices=[(0, 'Cash'), (1, 'Lydia'), (2, 'Gift'), (3, 'Check'), (4, 'Paylib'), (5, 'Bank Wire'), (6, 'Club House'), (7, 'Credit Card'), (8, 'Forfeit')], null=True),
),
migrations.AddField(
model_name='unregisteredplayer',
name='registered_online',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='tournament',
name='federal_level_category',
field=models.IntegerField(choices=[(0, 'Animation'), (25, 'P25'), (100, 'P100'), (250, 'P250'), (500, 'P500'), (1000, 'P1000'), (1500, 'P1500'), (2000, 'P2000'), (1, 'Championnat')], default=100),
),
]

@ -0,0 +1,35 @@
# Generated by Django 5.1 on 2025-04-25 07:48
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0116_customuser_disable_ranking_federal_ruling_and_more'),
]
operations = [
migrations.AddField(
model_name='playerregistration',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='player_registrations', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='teamregistration',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='team_registrations', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='unregisteredplayer',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='unregistered_players', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='unregisteredteam',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='unregistered_teams', to=settings.AUTH_USER_MODEL),
),
]

@ -5,7 +5,7 @@ from .custom_user import CustomUser
from .club import Club
from .court import Court
from .date_interval import DateInterval
from .enums import UserOrigin, TournamentPayment, FederalCategory, FederalLevelCategory, FederalAgeCategory, FederalMatchCategory, OnlineRegistrationStatus, ModelOperation
from .enums import UserOrigin, TournamentPayment, FederalCategory, FederalLevelCategory, FederalAgeCategory, FederalMatchCategory, OnlineRegistrationStatus, RegistrationStatus, ModelOperation
from .player_enums import PlayerSexType, PlayerDataSource, PlayerPaymentType
from .event import Event
from .tournament import Tournament, TeamSummon, TeamSortingType, TeamItem

@ -3,7 +3,9 @@ from django.apps import apps
from django.contrib.auth.models import AbstractUser
from django.utils.timezone import now
from django.conf import settings
from . import club, enums
from .enums import RegistrationPaymentMode
import uuid
class CustomUser(AbstractUser):
@ -40,6 +42,15 @@ class CustomUser(AbstractUser):
origin = models.IntegerField(default=enums.UserOrigin.ADMIN, choices=enums.UserOrigin.choices, null=True, blank=True)
should_synchronize = models.BooleanField(default=False)
user_role = models.IntegerField(choices=enums.UserRole.choices, null=True, blank=True)
registration_payment_mode = models.IntegerField(default=RegistrationPaymentMode.DISABLED, choices=RegistrationPaymentMode.choices)
umpire_custom_mail = models.EmailField(null=True, blank=True)
umpire_custom_contact = models.CharField(max_length=200, null=True, blank=True)
umpire_custom_phone = models.CharField(max_length=15, null=True, blank=True)
hide_umpire_mail = models.BooleanField(default=False)
hide_umpire_phone = models.BooleanField(default=True)
disable_ranking_federal_ruling = models.BooleanField(default=False)
### ### ### ### ### ### ### ### ### ### ### WARNING ### ### ### ### ### ### ### ### ### ###
### WARNING : Any added field MUST be inserted in the method below: fields_for_update() ###
### ### ### ### ### ### ### ### ### ### ### WARNING ### ### ### ### ### ### ### ### ### ###
@ -51,7 +62,10 @@ class CustomUser(AbstractUser):
'summons_message_body', 'summons_message_signature', 'summons_available_payment_methods',
'summons_display_format', 'summons_display_entry_fee',
'summons_use_full_custom_message', 'match_formats_default_duration', 'bracket_match_format_preference',
'group_stage_match_format_preference', 'loser_bracket_match_format_preference', 'device_id', 'loser_bracket_mode', 'origin', 'agents', 'should_synchronize']
'group_stage_match_format_preference', 'loser_bracket_match_format_preference', 'device_id', 'loser_bracket_mode',
'origin', 'agents', 'should_synchronize', 'user_role', 'registration_payment_mode',
'umpire_custom_mail', 'umpire_custom_contact', 'umpire_custom_phone', 'hide_umpire_mail', 'hide_umpire_phone',
'disable_ranking_federal_ruling']
def __str__(self):
return self.username
@ -78,3 +92,11 @@ class CustomUser(AbstractUser):
return None
return None
def effective_commission_rate(self):
if self.registration_payment_mode == RegistrationPaymentMode.STRIPE:
return settings.STRIPE_FEE
if self.registration_payment_mode == RegistrationPaymentMode.NO_FEE:
return 0.0000
if self.registration_payment_mode == RegistrationPaymentMode.CORPORATE:
return 0.0000

@ -49,6 +49,44 @@ class FederalLevelCategory(models.IntegerChoices):
P2000 = 2000, 'P2000'
CHPT = 1, 'Championnat'
def localized_word(self):
if self == FederalLevelCategory.UNLISTED:
return "animation"
elif self == FederalLevelCategory.CHPT:
return "championnat"
else:
return "tournoi"
def is_feminine_word(self):
if self == FederalLevelCategory.UNLISTED:
return True
else:
return False
def localized_prefix_at(self):
if self == FederalLevelCategory.UNLISTED:
return "à l'"
else:
return "au "
def localized_prefix_of(self):
if self == FederalLevelCategory.UNLISTED:
return "de l'"
else:
return "du "
def localized_prefix_this(self):
if self == FederalLevelCategory.UNLISTED:
return "cette"
else:
return "ce "
def localized_prefix_that(self):
if self == FederalLevelCategory.UNLISTED:
return "l'"
else:
return "le "
@staticmethod
def min_player_rank(level=None, category=None, age_category=None) -> int:
if level == FederalLevelCategory.P25:
@ -248,3 +286,20 @@ class UserOrigin(models.IntegerChoices):
ADMIN = 0, 'Admin'
SITE = 1, 'Site'
APP = 2, 'App'
class UserRole(models.IntegerChoices):
JAP = 0, 'Juge-Arbitre'
CLUB_OWNER = 1, 'Club Owner'
PLAYER = 2, 'Player'
class RegistrationStatus(models.IntegerChoices):
WAITING = 0, 'Waiting'
PENDING = 1, 'Pending'
CONFIRMED = 2, 'Confirmed'
CANCELED = 3, 'Canceled'
class RegistrationPaymentMode(models.IntegerChoices):
DISABLED = 0, 'Disabled'
CORPORATE = 1, 'Corporate'
NO_FEE = 2, 'No Service Fee'
STRIPE = 3, 'Stripe'

@ -223,7 +223,7 @@ class GroupStageTeam:
self.set_diff = 0
self.game_diff = 0
self.display_set_difference = False
if team_registration.player_registrations.count() == 0:
if team_registration.players_sorted_by_rank.count() == 0:
weight = None
else:
weight = team_registration.weight

@ -433,7 +433,7 @@ class Match(TournamentSubModel):
ended = self.end_date is not None
live_format = "Format " + FederalMatchCategory(self.format).format_label_short
livematch = LiveMatch(self.index, title, date, time_indication, court, self.started(), ended, group_stage_name, live_format, self.start_date, self.court_index, self.disabled, bracket_name)
livematch = LiveMatch(self.index, title, date, time_indication, court, self.started(), ended, group_stage_name, live_format, self.start_date, self.court_index, self.disabled, bracket_name, self.should_show_lucky_loser_status())
for team in self.live_teams():
livematch.add_team(team)
@ -446,6 +446,12 @@ class Match(TournamentSubModel):
else:
return self.team_scores.order_by('team_registration__bracket_position')
def should_show_lucky_loser_status(self):
if self.group_stage is not None:
return False
if self.round and self.round.parent is None and self.round.group_stage_loser_bracket is False:
return True
return False
# def non_null_start_date(self):
# if self.start_date:
# return self.start_date
@ -494,7 +500,7 @@ class Team:
}
class LiveMatch:
def __init__(self, index, title, date, time_indication, court, started, ended, group_stage_name, format, start_date, court_index, disabled, bracket_name):
def __init__(self, index, title, date, time_indication, court, started, ended, group_stage_name, format, start_date, court_index, disabled, bracket_name, should_show_lucky_loser_status):
self.index = index
self.title = title
self.date = date
@ -510,6 +516,7 @@ class LiveMatch:
self.start_date = start_date
self.court_index = court_index
self.bracket_name = bracket_name
self.should_show_lucky_loser_status = should_show_lucky_loser_status
def add_team(self, team):
self.teams.append(team)
@ -531,7 +538,8 @@ class LiveMatch:
"format": self.format,
"disabled": self.disabled,
"court_index": self.court_index,
"bracket_name": self.bracket_name
"bracket_name": self.bracket_name,
"should_show_lucky_loser_status": self.should_show_lucky_loser_status,
}
def show_time_indication(self):

@ -1,11 +1,15 @@
from django.db import models
from . import TournamentSubModel, TeamRegistration, PlayerSexType, PlayerDataSource, PlayerPaymentType, OnlineRegistrationStatus
import uuid
from django.utils import timezone
from . import TournamentSubModel, TeamRegistration, PlayerSexType, PlayerDataSource, PlayerPaymentType, OnlineRegistrationStatus, CustomUser
from .enums import RegistrationStatus
import uuid
class PlayerRegistration(TournamentSubModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
team_registration = models.ForeignKey(TeamRegistration, on_delete=models.SET_NULL, related_name='player_registrations', null=True)
user = models.ForeignKey(CustomUser, null=True, blank=True, on_delete=models.SET_NULL, related_name='player_registrations')
first_name = models.CharField(max_length=50, blank=True)
last_name = models.CharField(max_length=50, blank=True)
licence_id = models.CharField(max_length=50, null=True, blank=True)
@ -37,6 +41,9 @@ class PlayerRegistration(TournamentSubModel):
captain = models.BooleanField(default=False)
coach = models.BooleanField(default=False)
registered_online = models.BooleanField(default=False)
time_to_confirm = models.DateTimeField(null=True, blank=True)
registration_status = models.IntegerField(choices=RegistrationStatus.choices, default=RegistrationStatus.WAITING)
payment_id = models.CharField(max_length=255, blank=True, null=True)
def delete_dependencies(self):
pass
@ -163,3 +170,6 @@ class PlayerRegistration(TournamentSubModel):
status['short_label'] = 'inscrit'
return status
def has_paid(self):
return self.payment_type is not None

@ -170,7 +170,8 @@ class Round(TournamentSubModel):
match_group = self.tournament.create_match_group(
name=name,
matches=first_half_matches,
round_id=self.id
round_id=self.id,
round_index=self.index
)
return match_group

@ -1,12 +1,18 @@
from django.db import models
from . import TournamentSubModel, Tournament, GroupStage, Match
import uuid
from django.utils import timezone
from . import TournamentSubModel, Tournament, GroupStage, Match, CustomUser
from .enums import RegistrationStatus
from .player_enums import PlayerPaymentType
from ..services.email_service import TournamentEmailService, TeamEmailType
import uuid
class TeamRegistration(TournamentSubModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
tournament = models.ForeignKey(Tournament, on_delete=models.SET_NULL, related_name='team_registrations', null=True)
group_stage = models.ForeignKey(GroupStage, null=True, blank=True, on_delete=models.SET_NULL, related_name='team_registrations')
user = models.ForeignKey(CustomUser, null=True, blank=True, on_delete=models.SET_NULL, related_name='team_registrations')
registration_date = models.DateTimeField(null=True, blank=True)
call_date = models.DateTimeField(null=True, blank=True)
bracket_position = models.IntegerField(null=True, blank=True)
@ -47,7 +53,7 @@ class TeamRegistration(TournamentSubModel):
return self.tournament
def player_names_as_list(self):
players = list(self.player_registrations.all())
players = list(self.players_sorted_by_rank)
if len(players) == 0:
return []
elif len(players) == 1:
@ -65,7 +71,7 @@ class TeamRegistration(TournamentSubModel):
if self.name:
return [self.name] #add an empty line if it's a team name
else:
players = list(self.player_registrations.all())
players = list(self.players_sorted_by_rank)
if len(players) == 0:
if self.wild_card_bracket:
return ['Place réservée wildcard']
@ -79,7 +85,7 @@ class TeamRegistration(TournamentSubModel):
return [pr.shortened_name() for pr in players]
@property
def players(self):
def players_sorted_by_rank(self):
# Fetch related PlayerRegistration objects
return self.player_registrations.all().order_by('rank')
@ -94,7 +100,7 @@ class TeamRegistration(TournamentSubModel):
def formatted_team_names(self):
if self.name:
return self.name
names = [pr.last_name for pr in self.player_registrations.all()][:2] # Take max first 2
names = [pr.last_name for pr in self.players_sorted_by_rank][:2] # Take max first 2
joined_names = " / ".join(names)
if joined_names:
return f"Paire {joined_names}"
@ -125,12 +131,8 @@ class TeamRegistration(TournamentSubModel):
else:
return "--"
def set_weight(self):
self.weight = self.player_registrations.aggregate(total_weight=models.Sum('computed_rank'))['total_weight'] or 0
self.save() # Save the updated weight if necessary
def is_valid_for_summon(self):
return self.player_registrations.count() > 0 or self.name is not None
return self.players_sorted_by_rank.count() > 0 or self.name is not None
def initial_weight(self):
if self.locked_weight is None:
@ -158,7 +160,7 @@ class TeamRegistration(TournamentSubModel):
return self.walk_out
def get_other_player(self, player):
for p in self.player_registrations.all():
for p in self.players_sorted_by_rank:
if p != player:
return p
return None
@ -174,6 +176,9 @@ class TeamRegistration(TournamentSubModel):
return matches
def get_upcoming_matches(self):
if self.tournament and self.tournament.display_matches() is False:
return []
matches = self.get_matches()
upcoming = matches.filter(end_date__isnull=True).order_by('start_date')
print(f"Upcoming matches count: {upcoming.count()}")
@ -286,7 +291,7 @@ class TeamRegistration(TournamentSubModel):
return None
def has_registered_online(self):
for p in self.player_registrations.all():
for p in self.players_sorted_by_rank:
if p.registered_online:
return True
return False
@ -297,3 +302,196 @@ class TeamRegistration(TournamentSubModel):
if self.wild_card_group_stage:
return "(wildcard poule)"
return ""
def set_time_to_confirm(self, ttc):
for p in self.players_sorted_by_rank:
if p.registered_online:
p.time_to_confirm = ttc
p.registration_status = RegistrationStatus.PENDING
p.save()
def cancel_time_to_confirm(self):
for p in self.players_sorted_by_rank:
if p.registered_online:
save = False
if p.time_to_confirm is not None:
save = True
p.time_to_confirm = None
if p.registration_status == RegistrationStatus.PENDING:
save = True
p.registration_status = RegistrationStatus.WAITING
if save:
p.save()
def needs_confirmation(self):
"""Check if this team needs to confirm their registration"""
# Check if any player has status PENDING and is registered online
return any(p.registration_status == RegistrationStatus.PENDING and p.registered_online
for p in self.players_sorted_by_rank)
def get_confirmation_deadline(self):
"""Get the confirmation deadline for this team"""
deadlines = [p.time_to_confirm for p in self.players_sorted_by_rank if p.time_to_confirm is not None]
return max(deadlines) if deadlines else None
def confirm_registration(self, payment_intent_id=None):
"""Confirm the team's registration after being moved from waiting list"""
# Update all players in the team
for player in self.players_sorted_by_rank:
player.time_to_confirm = None
player.payment_id = payment_intent_id
if payment_intent_id is not None:
player.payment_type = PlayerPaymentType.CREDIT_CARD
player.registration_status = RegistrationStatus.CONFIRMED
player.save()
def confirm_if_placed(self):
if self.needs_confirmation() is False:
return
if self.group_stage or self.bracket_position or self.confirmation_date is not None:
for player in self.players_sorted_by_rank:
if player.registration_status is not RegistrationStatus.CONFIRMED:
player.time_to_confirm = None
player.registration_status = RegistrationStatus.CONFIRMED
player.save()
# Add to TeamRegistration class in team_registration.py
def get_payment_status(self):
"""
Gets the payment status for this team.
Returns:
- 'PAID': If all players in the team have paid
- 'UNPAID': If no player has paid
- 'MIXED': If some players have paid and others haven't (unusual case)
"""
# Get all player registrations for this team
player_registrations = self.players_sorted_by_rank
# If we have no players, return None
if not player_registrations.exists():
return None
# Check payment status for each player
payment_statuses = [player.has_paid() for player in player_registrations]
print(f"Payment statuses: {payment_statuses}")
# If all players have paid
if all(payment_statuses):
return 'PAID'
# If no players have paid
if not any(payment_statuses):
return 'UNPAID'
# If some players have paid and others haven't (unusual case)
return 'MIXED'
def is_payment_required(self):
"""Check if payment is required for this team"""
return self.tournament.should_request_payment() and self.is_in_waiting_list() < 0
def is_paid(self):
"""Check if this team has paid"""
status = self.get_payment_status()
return status == 'PAID'
def get_remaining_fee(self):
"""Get the remaining fee for this team"""
status = self.get_payment_status()
if status == 'PAID':
return 0
elif status == 'UNPAID':
return self.tournament.team_fee()
elif status == 'MIXED':
return self.tournament.player_fee()
def is_confirmation_expired(self):
"""
Check if the confirmation deadline has expired.
Returns:
bool: True if expired, False if still valid or no deadline exists
"""
deadline = self.get_confirmation_deadline()
if not deadline:
return False
current_time = timezone.now()
return deadline < current_time
def format_confirmation_deadline(self):
"""
Format the confirmation deadline in a human-readable format.
Returns:
str: Formatted deadline, or None if no deadline exists
"""
deadline = self.get_confirmation_deadline()
if not deadline:
return None
if self.tournament and self.tournament.timezone():
deadline = deadline.astimezone(self.tournament.timezone())
return deadline.strftime("%d/%m/%Y à %H:%M")
def check_confirmation_deadline(self, tournament_context=None):
"""
Check if the confirmation deadline for this team has expired and perform necessary actions.
Args:
tournament_context (dict, optional): Pre-calculated tournament context to avoid redundant calls.
If None, will calculate on-demand.
"""
now = timezone.now()
tournament = self.tournament
if not tournament:
return
# Use provided context or calculate if not provided
if tournament_context is None:
teams = tournament.teams(True)
waiting_list_teams = tournament.waiting_list_teams(teams)
ttc = tournament.calculate_time_to_confirm(len(waiting_list_teams)) if waiting_list_teams is not None else None
first_waiting_list_team = tournament.first_waiting_list_team(teams)
is_online_registration_irrevelant = tournament.is_online_registration_irrevelant()
else:
ttc = tournament_context.get('ttc')
first_waiting_list_team = tournament_context.get('first_waiting_list_team')
is_online_registration_irrevelant = tournament_context.get('is_online_registration_irrevelant', False)
# Get all players in this team
team_players = self.player_registrations.filter(registered_online=True)
should_update_team = False
should_send_mail = False
for team_player in team_players:
if is_online_registration_irrevelant:
team_player.registration_status = RegistrationStatus.CANCELED
team_player.save()
elif team_player.time_to_confirm is None and first_waiting_list_team is not None:
self.set_time_to_confirm(ttc)
should_send_mail = True
print(team_player, "team_player.time_to_confirm is None and", ttc)
elif team_player.time_to_confirm is not None and now > team_player.time_to_confirm:
if first_waiting_list_team is not None:
team_player.registration_status = RegistrationStatus.CANCELED
self.registration_date = now
should_update_team = True
team_player.time_to_confirm = None
team_player.save()
print(team_player, "time_to_confirm = ", team_player.time_to_confirm)
if should_update_team:
self.save()
print(f"Team {self} confirmation expired in tournament {tournament.id}")
if should_send_mail:
TournamentEmailService.notify_team(
self,
tournament,
TeamEmailType.REQUIRES_TIME_CONFIRMATION
)

@ -122,7 +122,7 @@ class TeamScore(TournamentSubModel):
id = self.team_registration.id
image = self.team_registration.logo
is_winner = self.team_registration.id == match.winning_team_id
if self.team_registration.player_registrations.count() == 0:
if self.team_registration.players_sorted_by_rank.count() == 0:
weight = None
else:
weight = self.team_registration.weight

@ -1,6 +1,7 @@
from zoneinfo import ZoneInfo
from django.db import models
from . import BaseModel, Event, TournamentPayment, FederalMatchCategory, FederalCategory, FederalLevelCategory, FederalAgeCategory, OnlineRegistrationStatus
from . import BaseModel, Event, TournamentPayment, FederalMatchCategory, FederalCategory, FederalLevelCategory, FederalAgeCategory, OnlineRegistrationStatus, RegistrationStatus
import uuid
from django.utils import timezone, formats
from datetime import datetime, timedelta, time
@ -10,6 +11,7 @@ from ..utils.extensions import plural_format
from django.utils.formats import date_format
from ..utils.licence_validator import LicenseValidator
from django.apps import apps
from django.conf import settings
class TeamSortingType(models.IntegerChoices):
RANK = 1, 'Rank'
@ -78,6 +80,15 @@ class Tournament(BaseModel):
hide_umpire_mail = models.BooleanField(default=False)
hide_umpire_phone = models.BooleanField(default=True)
disable_ranking_federal_ruling = models.BooleanField(default=False)
reserved_spots = models.IntegerField(default=0)
enable_online_payment = models.BooleanField(default=False)
online_payment_is_mandatory = models.BooleanField(default=False)
enable_online_payment_refund = models.BooleanField(default=False)
refund_date_limit = models.DateTimeField(null=True, blank=True) # Equivalent to Date? = nil
stripe_account_id = models.CharField(max_length=255, blank=True, null=True)
enable_time_to_confirm = models.BooleanField(default=False)
is_corporate_tournament = models.BooleanField(default=False)
is_template = models.BooleanField(default=False)
def delete_dependencies(self):
for team_registration in self.team_registrations.all():
@ -120,16 +131,12 @@ class Tournament(BaseModel):
def display_name(self):
if self.name:
if self.federal_level_category == FederalLevelCategory.UNLISTED:
return self.name
return self.base_name() + " " + self.name
return self.short_base_name() + " " + self.name
else:
return self.base_name()
def broadcast_display_name(self):
if self.name:
if self.federal_level_category == FederalLevelCategory.UNLISTED:
return self.name
return self.short_base_name() + " " + self.name
else:
return self.base_name()
@ -143,12 +150,21 @@ class Tournament(BaseModel):
def base_name(self):
return f"{self.level()} {self.category()}"
def full_name(self):
age = self.age()
str = f"{self.level()} {self.category()}"
if self.name:
str = f"{self.level()} {self.name} {self.category()}"
if age is not None:
str = f"{str} {age}"
return str
def short_base_name(self):
category = self.category()
if len(category) > 0:
return f"{self.level()}{category[0]}"
if len(category) > 0 and self.federal_level_category > 1:
return f"{self.short_level()}{category[0]}"
else:
return self.level()
return self.short_level()
def filter_name(self):
components = [self.formatted_start_date(), self.short_base_name()]
@ -174,6 +190,9 @@ class Tournament(BaseModel):
return formats.date_format(self.local_start_date(), format='l j F Y H:i').capitalize()
def level(self):
return self.get_federal_level_category_display()
def short_level(self):
if self.federal_level_category == 0:
return "Anim."
if self.federal_level_category == 1:
@ -181,6 +200,11 @@ class Tournament(BaseModel):
return self.get_federal_level_category_display()
def category(self):
if self.federal_age_category > 100 and self.federal_age_category < 200:
if self.federal_category == 0:
return "Garçon"
if self.federal_category == 1:
return "Fille"
return self.get_federal_category_display()
def age(self):
@ -298,15 +322,22 @@ class Tournament(BaseModel):
def get_team_waiting_list_position(self, team_registration):
# Use the teams method to get sorted list of teams
all_teams = self.teams(True)
index = -1
# Find position of team in all teams list
for i, team in enumerate(all_teams):
if team.team_registration.id == team_registration.id:
index = i
now_utc = timezone.now()
current_time = now_utc.astimezone(self.timezone())
local_registration_federal_limit = self.local_registration_federal_limit()
if self.team_sorting == TeamSortingType.RANK and local_registration_federal_limit is not None:
if current_time < local_registration_federal_limit:
return -1
# Check if team_count exists
if self.team_count_limit == True:
all_teams = self.teams(True)
index = -1
# Find position of team in all teams list
for i, team in enumerate(all_teams):
if team.team_registration.id == team_registration.id:
index = i
# Team is not in list
if index < 0:
print("Team is not in list", index, self.team_count)
@ -530,7 +561,7 @@ class Tournament(BaseModel):
return groups
def create_match_group(self, name, matches, round_id=None):
def create_match_group(self, name, matches, round_id=None, round_index=None):
matches = list(matches)
live_matches = [match.live_match() for match in matches]
# Filter out matches that have a start_date of None
@ -547,7 +578,7 @@ class Tournament(BaseModel):
time_format = 'l d M'
formatted_schedule = f" - {formats.date_format(local_start, format=time_format)}"
return MatchGroup(name, live_matches, formatted_schedule, round_id)
return MatchGroup(name, live_matches, formatted_schedule, round_id, round_index)
def live_group_stages(self):
group_stages = self.sorted_group_stages()
@ -598,7 +629,7 @@ class Tournament(BaseModel):
# if now is before the first match, we want to show the summons + group stage or first matches
# change timezone to datetime to avoid the bug RuntimeWarning: DateTimeField Tournament.start_date received a naive datetime (2024-05-16 00:00:00) while time zone support is active.
current_time = timezone.now()
current_time = timezone.localtime()
tournament_start = self.local_start_date()
one_hour_before_start = tournament_start - timedelta(hours=1)
@ -834,24 +865,24 @@ class Tournament(BaseModel):
return False
def display_tournament(self):
if self.publish_tournament:
if self.publish_tournament or self.enable_online_registration:
return True
is_build_and_not_empty = self.is_build_and_not_empty()
if self.end_date is not None:
return is_build_and_not_empty
if timezone.now() >= self.local_start_date():
if self.has_started():
return is_build_and_not_empty
minimum_publish_date = self.creation_date.replace(hour=9, minute=0) + timedelta(days=1)
return timezone.now() >= timezone.localtime(minimum_publish_date)
minimum_publish_date = self.creation_date.replace(hour=7, minute=0) + timedelta(days=1)
return timezone.now() >= minimum_publish_date
def display_teams(self):
if self.end_date is not None:
return self.has_team_registrations()
if self.publish_teams:
return self.has_team_registrations()
if timezone.now() >= self.local_start_date():
if self.has_started():
return self.has_team_registrations()
return False
@ -863,7 +894,7 @@ class Tournament(BaseModel):
return False
if self.publish_summons:
return self.has_summons()
if timezone.now() >= self.local_start_date():
if self.has_started():
return self.has_summons()
return False
@ -877,7 +908,7 @@ class Tournament(BaseModel):
first_group_stage_start_date = self.group_stage_start_date()
if first_group_stage_start_date is None:
return timezone.now() >= self.local_start_date()
return self.has_started()
else:
return timezone.now() >= first_group_stage_start_date
@ -885,9 +916,7 @@ class Tournament(BaseModel):
group_stages = [gs for gs in self.group_stages.all() if gs.start_date is not None]
if len(group_stages) == 0:
return None
timezone = self.timezone()
return min(group_stages, key=lambda gs: gs.start_date).start_date.astimezone(timezone)
return min(group_stages, key=lambda gs: gs.start_date).start_date
def display_matches(self):
if self.end_date is not None:
@ -900,19 +929,22 @@ class Tournament(BaseModel):
first_match_start_date = self.first_match_start_date(bracket_matches)
if first_match_start_date is None:
return timezone.now() >= self.local_start_date()
return self.has_started()
bracket_start_date = self.getEightAm(first_match_start_date)
if bracket_start_date < self.local_start_date():
bracket_start_date = self.local_start_date()
if bracket_start_date < self.start_date:
bracket_start_date = self.start_date
group_stage_start_date = self.group_stage_start_date()
now = timezone.now()
if group_stage_start_date is not None:
if bracket_start_date < group_stage_start_date:
return timezone.now() >=first_match_start_date
return now >=first_match_start_date
if timezone.now() >= bracket_start_date:
if now >= bracket_start_date:
return True
return False
@ -928,19 +960,30 @@ class Tournament(BaseModel):
matches = [m for m in bracket_matches if m.start_date is not None]
if len(matches) == 0:
return None
return min(matches, key=lambda m: m.start_date).local_start_date()
return min(matches, key=lambda m: m.start_date).start_date
def getEightAm(self, date):
return date.replace(hour=8, minute=0, second=0, microsecond=0, tzinfo=date.tzinfo)
def has_started(self, hour_delta=None):
timezoned_datetime = self.local_start_date()
now_utc = timezone.now()
now = now_utc.astimezone(self.timezone())
if hour_delta is not None:
timezoned_datetime -= timedelta(hours=hour_delta)
return now >= timezoned_datetime
def will_start_soon(self):
return self.has_started(hour_delta=2)
def supposedly_in_progress(self):
# end = self.start_date + timedelta(days=self.day_duration + 1)
# return self.start_date.replace(hour=0, minute=0) <= timezone.now() <= end
timezoned_datetime = self.local_start_date()
end = timezoned_datetime + timedelta(days=self.day_duration + 1)
now = timezone.now()
now_utc = timezone.now()
now = now_utc.astimezone(self.timezone())
start = timezoned_datetime.replace(hour=0, minute=0)
# print(f"timezoned_datetime: {timezoned_datetime}")
@ -953,20 +996,23 @@ class Tournament(BaseModel):
def starts_in_the_future(self):
# tomorrow = datetime.now().date() + timedelta(days=1)
timezoned_datetime = self.local_start_date()
start = timezoned_datetime.replace(hour=0, minute=0)
now = timezone.now()
now_utc = timezone.now()
now = now_utc.astimezone(self.timezone())
return start >= now
def has_ended(self):
return self.end_date is not None
def should_be_over(self):
if self.end_date is not None:
if self.has_ended():
return True
timezoned_datetime = self.local_start_date()
end = timezoned_datetime + timedelta(days=self.day_duration + 1)
now = timezone.now()
now_utc = timezone.now()
now = now_utc.astimezone(self.timezone())
return now >= end and self.is_build_and_not_empty() and self.nearly_over()
def nearly_over(self):
@ -1054,6 +1100,21 @@ class Tournament(BaseModel):
if self.license_is_required:
options.append("Licence requise")
# Options de paiement en ligne
if self.enable_online_payment:
if self.online_payment_is_mandatory:
options.append("Paiement en ligne obligatoire")
else:
options.append("Paiement en ligne disponible")
if self.enable_online_payment_refund and self.refund_date_limit:
date = formats.date_format(self.refund_date_limit.astimezone(timezone), format='j F Y H:i')
options.append(f"Remboursement possible jusqu'au {date}")
elif self.enable_online_payment_refund:
options.append("Remboursement possible")
else:
options.append("Remboursement impossible")
# Joueurs par équipe
min_players = self.minimum_player_per_team
max_players = self.maximum_player_per_team
@ -1070,6 +1131,32 @@ class Tournament(BaseModel):
else:
return "La sélection se fait par date d'inscription"
def automatic_waiting_list(self):
"""
Determines if automatic waiting list processing should be applied based on the tournament's registration status.
Returns True if automatic waiting list processing should be applied, False otherwise.
"""
if self.enable_time_to_confirm is False:
return False
# Get the current registration status
status = self.get_online_registration_status()
# Define which status values should allow automatic waiting list
status_map = {
OnlineRegistrationStatus.OPEN: True,
OnlineRegistrationStatus.NOT_ENABLED: False,
OnlineRegistrationStatus.NOT_STARTED: False,
OnlineRegistrationStatus.ENDED: False,
OnlineRegistrationStatus.WAITING_LIST_POSSIBLE: True,
OnlineRegistrationStatus.WAITING_LIST_FULL: True, # Still manage in case spots open up
OnlineRegistrationStatus.IN_PROGRESS: False, # Allow for last-minute changes
OnlineRegistrationStatus.ENDED_WITH_RESULTS: False,
OnlineRegistrationStatus.CANCELED: False
}
# Return the mapped value or False as default for any unmapped status
return status_map.get(status, False)
def get_online_registration_status(self):
if self.is_canceled():
return OnlineRegistrationStatus.CANCELED
@ -1077,7 +1164,7 @@ class Tournament(BaseModel):
return OnlineRegistrationStatus.ENDED_WITH_RESULTS
if self.enable_online_registration is False:
return OnlineRegistrationStatus.NOT_ENABLED
if self.supposedly_in_progress():
if self.has_started():
return OnlineRegistrationStatus.ENDED
if self.closed_registration_date is not None:
return OnlineRegistrationStatus.WAITING_LIST_POSSIBLE
@ -1085,13 +1172,11 @@ class Tournament(BaseModel):
now = timezone.now()
if self.opening_registration_date is not None:
timezoned_datetime = timezone.localtime(self.opening_registration_date)
if now < timezoned_datetime:
if now < self.opening_registration_date:
return OnlineRegistrationStatus.NOT_STARTED
if self.registration_date_limit is not None:
timezoned_datetime = timezone.localtime(self.registration_date_limit)
if now > timezoned_datetime:
if now > self.registration_date_limit:
return OnlineRegistrationStatus.WAITING_LIST_POSSIBLE
if self.team_sorting == TeamSortingType.RANK:
@ -1137,14 +1222,15 @@ class Tournament(BaseModel):
# Check if registration is closed
if self.registration_date_limit is not None:
if timezone.now() > timezone.localtime(self.registration_date_limit):
if timezone.now() > self.registration_date_limit:
return False
# Otherwise unregistration is allowed
return True
def get_waiting_list_position(self):
current_time = timezone.now()
now_utc = timezone.now()
current_time = now_utc.astimezone(self.timezone())
local_registration_federal_limit = self.local_registration_federal_limit()
if self.team_sorting == TeamSortingType.RANK and local_registration_federal_limit is not None:
if current_time < local_registration_federal_limit:
@ -1154,8 +1240,12 @@ class Tournament(BaseModel):
if self.team_count_limit is False:
return -1
self.reserved_spots = max(0, self.reserved_spots - 1)
self.reserved_spots += 1
self.save()
# Get count of active teams (not walked out)
current_team_count = self.team_registrations.exclude(walk_out=True).count()
current_team_count = self.team_registrations.exclude(walk_out=True).count() + self.reserved_spots
# If current count is less than target count, next team is not in waiting list
if current_team_count < self.team_count:
@ -1173,24 +1263,25 @@ class Tournament(BaseModel):
# In waiting list with no limit
return current_team_count - self.team_count
def build_tournament_type_array(self):
def build_tournament_type_str(self):
tournament_details = []
if self.federal_level_category > 0:
tournament_details.append(self.level())
tournament_details.append(self.level())
if self.category():
tournament_details.append(self.category())
if self.age() and self.federal_age_category != FederalAgeCategory.SENIOR:
tournament_details.append(self.age())
return tournament_details
def build_tournament_type_str(self):
tournament_details = self.build_tournament_type_array()
return " ".join(filter(None, tournament_details))
def build_tournament_details_str(self):
tournament_details = self.build_tournament_type_array()
name_str = self.build_name_details_str()
tournament_details = []
if self.federal_level_category > 0:
tournament_details.append(self.level())
if self.category():
tournament_details.append(self.category())
if self.age() and self.federal_age_category != FederalAgeCategory.SENIOR:
tournament_details.append(self.age())
if len(name_str) > 0:
tournament_details.append(name_str)
@ -1236,13 +1327,13 @@ class Tournament(BaseModel):
# Check age category restrictions
if self.federal_age_category == FederalAgeCategory.A11_12 and user_age > 12:
reasons.append("Ce tournoi est réservé aux -12 ans")
reasons.append("Ce tournoi est réservé aux 12 ans et moins")
if self.federal_age_category == FederalAgeCategory.A13_14 and user_age > 14:
reasons.append("Ce tournoi est réservé aux -14 ans")
reasons.append("Ce tournoi est réservé aux 14 ans et moins")
if self.federal_age_category == FederalAgeCategory.A15_16 and user_age > 16:
reasons.append("Ce tournoi est réservé aux -16 ans")
reasons.append("Ce tournoi est réservé aux 16 ans et moins")
if self.federal_age_category == FederalAgeCategory.A17_18 and user_age > 18:
reasons.append("Ce tournoi est réservé aux -18 ans")
reasons.append("Ce tournoi est réservé aux 18 ans et moins")
if self.federal_age_category == FederalAgeCategory.SENIOR and user_age < 11:
reasons.append("Ce tournoi est réservé aux 11 ans et plus")
if self.federal_age_category == FederalAgeCategory.A45 and user_age < 45:
@ -1289,7 +1380,8 @@ class Tournament(BaseModel):
return None
def waiting_list_teams(self, teams):
current_time = timezone.now()
now_utc = timezone.now()
current_time = now_utc.astimezone(self.timezone())
local_registration_federal_limit = self.local_registration_federal_limit()
if self.team_sorting == TeamSortingType.RANK and local_registration_federal_limit is not None:
if current_time < local_registration_federal_limit:
@ -1323,6 +1415,7 @@ class Tournament(BaseModel):
and m.court_index is not None
]
now = timezone.now()
# Group matches by court
matches_by_court = {}
courts = set()
@ -1336,7 +1429,7 @@ class Tournament(BaseModel):
for court in matches_by_court:
matches_by_court[court].sort(key=lambda m: (
m.start_date is None, # None dates come last
m.start_date if m.start_date else timezone.now()
m.start_date if m.start_date else now
))
# Sort courts and organize them into groups of 4
@ -1381,10 +1474,17 @@ class Tournament(BaseModel):
main_rounds_reversed = []
# Get main bracket rounds (excluding children/ranking matches)
main_rounds = self.rounds.filter(
parent=parent_round,
group_stage_loser_bracket=False
).order_by('-index')
if double_butterfly_mode:
main_rounds = self.rounds.filter(
parent=parent_round,
group_stage_loser_bracket=False,
index__lte=3
).order_by('-index')
else:
main_rounds = self.rounds.filter(
parent=parent_round,
group_stage_loser_bracket=False
).order_by('-index')
count = main_rounds.count()
if display_loser_final and count > 1:
@ -1446,7 +1546,148 @@ class Tournament(BaseModel):
return self.umpire_custom_phone
return self.event.creator.phone
def calculate_time_to_confirm(self, waiting_list_count):
"""
Calculate the time a team has to confirm their registration
based on tournament proximity, waiting list pressure, and business hours.
Args:
tournament: The Tournament instance
waiting_list_count: Waiting List count
Returns:
datetime: The confirmation deadline datetime
"""
# Skip if feature not enabled
if self.automatic_waiting_list() is False:
return None
config = settings.TOURNAMENT_SETTINGS
TIME_PROXIMITY_RULES = config['TIME_PROXIMITY_RULES']
WAITING_LIST_RULES = config['WAITING_LIST_RULES']
BUSINESS_RULES = config['BUSINESS_RULES']
# 1. Get current time in tournament's timezone
current_time = timezone.now()
current_time = current_time.astimezone(self.timezone())
tournament_start_date = self.local_start_date()
# 2. Calculate tournament proximity (hours until tournament starts)
hours_until_tournament = (tournament_start_date - current_time).total_seconds() / 3600
# 3. Calculate waiting list pressure
# teams = self.teams(True)
# waiting_teams = self.waiting_list_team(teams)
# if waiting_teams is None:
# return None
# waiting_list_count = len(waiting_teams)
# 4. Determine base minutes to confirm based on time proximity
time_based_minutes = TIME_PROXIMITY_RULES["default"]
for hours_threshold, minutes in TIME_PROXIMITY_RULES.items():
if hours_threshold != "default" and hours_until_tournament <= hours_threshold:
time_based_minutes = minutes
break
# 5. Determine waiting list based minutes
waitlist_based_minutes = WAITING_LIST_RULES["default"]
for teams_threshold, minutes in WAITING_LIST_RULES.items():
if teams_threshold != "default" and waiting_list_count >= teams_threshold:
waitlist_based_minutes = minutes
break
# 6. Use the more restrictive rule (smaller time window)
minutes_to_confirm = min(time_based_minutes, waitlist_based_minutes)
# 7. Check urgency overrides
apply_business_rules = True
# Default business hours
business_start_hour = BUSINESS_RULES["hours"]["start"]
business_end_hour = BUSINESS_RULES["hours"]["end"]
# for hours_threshold, override in URGENCY_OVERRIDE["thresholds"].items():
# if hours_until_tournament <= hours_threshold:
# apply_business_rules = False
# # Ensure minimum response time
# minutes_to_confirm = max(minutes_to_confirm,
# URGENCY_OVERRIDE["minimum_response_time"] / 10 if getattr(settings, 'LIVE_TESTING', False)
# else URGENCY_OVERRIDE["minimum_response_time"])
# break
# Adjust business hours based on tournament proximity
if hours_until_tournament <= 24:
# 24 hours before tournament: 7am - 10pm
business_start_hour = 7
business_end_hour = 22
minutes_to_confirm = config['MINIMUM_RESPONSE_TIME']
if hours_until_tournament <= 12:
# 12 hours before tournament: 6am - 1am (next day)
business_start_hour = 6
business_end_hour = 25 # 1am next day (25 in 24-hour format)
minutes_to_confirm = config['MINIMUM_RESPONSE_TIME']
live_testing = getattr(settings, 'LIVE_TESTING', False)
# Divide by 10 if LIVE_TESTING is enabled
if live_testing:
minutes_to_confirm = minutes_to_confirm / 10
# 8. Calculate raw deadline
raw_deadline = current_time + timezone.timedelta(minutes=minutes_to_confirm)
# 9. Round up to next interval mark based on BACKGROUND_SCHEDULED_TASK_INTERVAL
interval = settings.BACKGROUND_SCHEDULED_TASK_INTERVAL
minute = raw_deadline.minute
if minute % interval != 0:
# Minutes to next interval mark
minutes_to_add = interval - (minute % interval)
raw_deadline += timezone.timedelta(minutes=minutes_to_add)
# 10. Apply business hours rules if needed
if apply_business_rules and live_testing is False:
# Check if deadline falls outside business hours
before_hours = raw_deadline.hour < business_start_hour
after_hours = raw_deadline.hour >= business_end_hour
if before_hours or after_hours:
# Extend to next business day
if after_hours:
# Move to next day
days_to_add = 1
raw_deadline += timezone.timedelta(days=days_to_add)
# Set to business start hour
raw_deadline = raw_deadline.replace(
hour=business_start_hour,
minute=0,
second=0,
microsecond=0
)
print(f"Before hours: {before_hours}, After hours: {after_hours}")
tournament_start_date_minus_five = tournament_start_date - timedelta(minutes=5)
if raw_deadline >= tournament_start_date_minus_five:
print(f"Raw Deadline is after tournament_start_date_minus_five: {raw_deadline}, {tournament_start_date_minus_five}")
raw_deadline = tournament_start_date_minus_five
raw_deadline = raw_deadline.replace(
second=0,
microsecond=0
)
print(f"Live testing: {live_testing}")
print(f"Current time: {current_time}")
print(f"Minutes to confirm: {minutes_to_confirm}")
print(f"Raw deadline before rounding: {current_time + timezone.timedelta(minutes=minutes_to_confirm)}")
print(f"Raw deadline after rounding: {raw_deadline}")
print(f"Apply business rules: {apply_business_rules}")
return raw_deadline
def is_online_registration_irrevelant(self):
return self.enable_time_to_confirm is False or self.has_started() or self.has_ended() or self.is_canceled() or self.is_deleted
@property
def week_day(self):
@ -1508,31 +1749,157 @@ class Tournament(BaseModel):
return "journée"
def get_player_registration_status_by_licence(self, user):
licence_id = user.licence_id
if not licence_id:
user_player = self.get_user_registered(user)
if user_player:
return user_player.get_registration_status()
return None
def get_user_registered(self, user):
if not user.is_authenticated:
return None
validator = LicenseValidator(licence_id)
if validator.validate_license():
stripped_license = validator.stripped_license
# Check if there is a PlayerRegistration for this user in this tournament
PlayerRegistration = apps.get_model('tournaments', 'PlayerRegistration')
user_player = PlayerRegistration.objects.filter(
licence_id__icontains=stripped_license,
team_registration__tournament=self,
).first()
if user_player:
return user_player.get_registration_status()
PlayerRegistration = apps.get_model('tournaments', 'PlayerRegistration')
# First, try to find a registration directly linked to the user
direct_registration = PlayerRegistration.objects.filter(
team_registration__tournament=self,
user=user,
team_registration__walk_out=False
).first()
if direct_registration:
return direct_registration
# If no direct registration found and user has no license, return None
if not user.licence_id:
return None
# Validate the license format
validator = LicenseValidator(user.licence_id)
if not validator.validate_license():
return None
# Get the stripped license (without check letter)
stripped_license = validator.stripped_license
# Fall back to checking by license ID
return PlayerRegistration.objects.filter(
team_registration__tournament=self,
licence_id__icontains=stripped_license,
team_registration__walk_out=False
).first()
def is_user_registered(self, user):
return self.get_user_registered(user) is not None
def get_user_team_registration(self, user):
user_registered = self.get_user_registered(user)
if user_registered:
return user_registered.team_registration
else:
return None
def should_request_payment(self):
if self.enable_online_payment:
return True
else:
return False
def is_refund_possible(self):
if self.enable_online_payment_refund:
time = timezone.now()
if self.refund_date_limit:
if time <= self.refund_date_limit:
return True
else:
return False
else:
return True
else:
return False
def player_fee(self):
if self.entry_fee is not None and self.entry_fee > 0 and self.enable_online_payment:
return self.entry_fee
else:
return 0
def team_fee(self):
entry_fee = self.entry_fee
if entry_fee is not None and entry_fee > 0 and self.enable_online_payment:
return self.entry_fee * self.minimum_player_per_team
else:
return 0
def is_free(self):
if self.entry_fee is not None and self.entry_fee == 0:
return True
elif self.entry_fee is None:
return True
else:
return False
def effective_commission_rate(self):
"""Get the commission rate for this tournament, falling back to the umpire default if not set"""
return 1.00 # Fallback default
def check_all_confirmation_deadlines(self):
"""
Check all confirmation deadlines for teams in this tournament.
Send notification emails as needed.
Returns:
int: Number of teams processed
"""
# Calculate these values once for the tournament
teams = self.teams(True)
waiting_list_teams = self.waiting_list_teams(teams)
ttc = self.calculate_time_to_confirm(len(waiting_list_teams)) if waiting_list_teams is not None else None
first_waiting_list_team = self.first_waiting_list_team(teams)
# Tournament context dict to pass to each team check
tournament_context = {
'ttc': ttc,
'first_waiting_list_team': first_waiting_list_team,
'is_online_registration_irrevelant': self.is_online_registration_irrevelant()
}
# Find players with expired confirmation deadlines in this tournament
PlayerRegistration = apps.get_model('tournaments', 'PlayerRegistration')
expired_confirmations = PlayerRegistration.objects.filter(
registration_status=RegistrationStatus.PENDING,
registered_online=True,
team_registration__tournament=self
).select_related('team_registration')
processed_teams = set() # To avoid processing the same team multiple times
teams_processed = 0
for player in expired_confirmations:
team_registration = player.team_registration
# Skip if we've already processed this team
if team_registration.id in processed_teams:
continue
processed_teams.add(team_registration.id)
teams_processed += 1
# Process in a transaction to ensure atomic operations
team_registration.check_confirmation_deadline(tournament_context)
return teams_processed
return None
class MatchGroup:
def __init__(self, name, matches, formatted_schedule, round_id=None):
def __init__(self, name, matches, formatted_schedule, round_id=None, round_index=None):
self.name = name
self.matches = matches
self.formatted_schedule = formatted_schedule
self.round_id = round_id
self.round_index = round_index
def add_match(self, match):
self.matches.append(match)
@ -1544,6 +1911,7 @@ class MatchGroup:
return {
'name': self.name,
'round_id': self.round_id,
'round_index': self.round_index,
'matches': [match.to_dict() for match in self.matches]
}
@ -1588,7 +1956,7 @@ class TeamItem:
self.names = team_registration.team_names()
self.date = team_registration.local_call_date()
self.registration_date = team_registration.registration_date
if team_registration.player_registrations.count() == 0:
if team_registration.players_sorted_by_rank.count() == 0:
weight = None
else:
weight = team_registration.weight

@ -1,13 +1,18 @@
from django.db import models
from . import UnregisteredTeam
from . import UnregisteredTeam, CustomUser
from .player_enums import PlayerPaymentType
import uuid
class UnregisteredPlayer(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
unregistered_team = models.ForeignKey(UnregisteredTeam, on_delete=models.SET_NULL, related_name='unregistered_players', null=True, blank=True)
user = models.ForeignKey(CustomUser, null=True, blank=True, on_delete=models.SET_NULL, related_name='unregistered_players')
first_name = models.CharField(max_length=50, blank=True)
last_name = models.CharField(max_length=50, blank=True)
licence_id = models.CharField(max_length=50, null=True, blank=True)
payment_type = models.IntegerField(choices=PlayerPaymentType.choices, null=True, blank=True)
payment_id = models.CharField(max_length=255, blank=True, null=True)
registered_online = models.BooleanField(default=False)
def __str__(self):
return self.name()

@ -1,9 +1,10 @@
from django.db import models
from . import Tournament
from . import Tournament, CustomUser
import uuid
class UnregisteredTeam(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True)
user = models.ForeignKey(CustomUser, null=True, blank=True, on_delete=models.SET_NULL, related_name='unregistered_teams')
tournament = models.ForeignKey(Tournament, on_delete=models.SET_NULL, related_name='unregistered_teams', null=True, blank=True)
unregistration_date = models.DateTimeField(null=True, blank=True)

@ -1,81 +0,0 @@
from .models import TeamRegistration, PlayerRegistration
from .models.player_enums import PlayerSexType, PlayerDataSource
from .models.enums import FederalCategory
from tournaments.utils.licence_validator import LicenseValidator
class TournamentRegistrationRepository:
@staticmethod
def create_team_registration(tournament, registration_date):
team_registration = TeamRegistration.objects.create(
tournament=tournament,
registration_date=registration_date
)
return team_registration
@staticmethod
def create_player_registrations(request, team_registration, players_data, team_form_data):
stripped_license = None
if request.user.is_authenticated and request.user.licence_id:
stripped_license = LicenseValidator(request.user.licence_id).stripped_license
for player_data in players_data:
is_captain = False
player_licence_id = player_data['licence_id']
if player_licence_id and stripped_license:
if stripped_license.lower() in player_licence_id.lower():
is_captain = True
sex, rank, computed_rank = TournamentRegistrationRepository._compute_rank_and_sex(
team_registration.tournament,
player_data
)
print("create_player_registrations", player_data.get('last_name'), sex, rank, computed_rank)
data_source = None
if player_data.get('found_in_french_federation', False) == True:
data_source = PlayerDataSource.FRENCH_FEDERATION
player_registration = PlayerRegistration.objects.create(
team_registration=team_registration,
captain=is_captain,
source=data_source,
registered_online=True,
first_name=player_data.get('first_name'),
last_name=player_data.get('last_name'),
points=player_data.get('points'),
assimilation=player_data.get('assimilation'),
tournament_played=player_data.get('tournament_count'),
ligue_name=player_data.get('ligue_name'),
club_name=player_data.get('club_name'),
birthdate=player_data.get('birth_year'),
sex=sex,
rank=rank,
computed_rank=computed_rank,
licence_id=player_data['licence_id'],
email=player_data.get('email'),
phone_number=player_data.get('mobile_number'),
)
player_registration.save()
team_registration.set_weight()
team_registration.save()
@staticmethod
def _compute_rank_and_sex(tournament, player_data):
is_woman = player_data.get('is_woman', False)
rank = player_data.get('rank', None)
if rank is None:
computed_rank = 100000
else:
computed_rank = rank
sex = PlayerSexType.MALE
if is_woman:
sex = PlayerSexType.FEMALE
if tournament.federal_category == FederalCategory.MEN:
computed_rank = str(int(computed_rank) +
FederalCategory.female_in_male_assimilation_addition(int(rank)))
print("_compute_rank_and_sex", sex, rank, computed_rank)
return sex, rank, computed_rank

@ -1,6 +1,8 @@
from django.core.mail import EmailMessage
from enum import Enum
from ..models.enums import RegistrationStatus, FederalLevelCategory
from ..models.tournament import TeamSortingType
from django.utils import timezone
class TeamEmailType(Enum):
REGISTERED = "registered"
@ -14,14 +16,33 @@ class TeamEmailType(Enum):
OUT_OF_WALKOUT_WAITING_LIST = "out_of_walkout_waiting_list"
WALKOUT = "walkout"
UNEXPECTED_OUT_OF_TOURNAMENT = 'unexpected_out_of_tournament'
def email_subject(self) -> str:
REQUIRES_TIME_CONFIRMATION = 'requires_time_confirmation'
def email_topic(self, category=None, time_to_confirm=None) -> str:
confirmation_types = [
self.REGISTERED,
self.OUT_OF_WAITING_LIST,
self.IN_TOURNAMENT_STRUCTURE,
self.OUT_OF_WALKOUT_IS_IN,
self.REQUIRES_TIME_CONFIRMATION,
]
word = "Tournoi"
grammar = ''
if category is not None:
federal_category = FederalLevelCategory(category)
word = federal_category.localized_word().capitalize()
grammar = 'e' if federal_category.is_feminine_word() else ''
if time_to_confirm and self in confirmation_types:
return "Participation en attente de confirmation"
else:
subjects = {
self.REGISTERED: "Participation confirmée",
self.WAITING_LIST: "Liste d'attente",
self.UNREGISTERED: "Désistement",
self.OUT_OF_WAITING_LIST: "Participation confirmée",
self.TOURNAMENT_CANCELED: "Tournoi annulé",
self.REQUIRES_TIME_CONFIRMATION: "Participation en attente de confirmation",
self.TOURNAMENT_CANCELED: f"{word} annulé{grammar}",
self.IN_TOURNAMENT_STRUCTURE: "Participation confirmée",
self.OUT_OF_TOURNAMENT_STRUCTURE: "Participation annulée",
self.OUT_OF_WALKOUT_IS_IN: "Participation confirmée",
@ -66,30 +87,43 @@ class TournamentEmailService:
@staticmethod
def _build_registration_email_body(tournament, captain, tournament_details_str, other_player, waiting_list):
inscription_date = captain.team_registration.local_registration_date().strftime("%d/%m/%Y à %H:%M")
federal_level_category = FederalLevelCategory(tournament.federal_level_category)
tournament_word = federal_level_category.localized_word()
tournament_prefix_at = federal_level_category.localized_prefix_at()
tournament_prefix_of = federal_level_category.localized_prefix_of()
tournament_prefix_that = federal_level_category.localized_prefix_that()
body_parts = []
body_parts.append("Bonjour,\n")
if waiting_list:
body_parts.append(f"Votre inscription en liste d'attente du tournoi {tournament_details_str} est confirmée.")
body_parts.append(f"Votre inscription en liste d'attente {tournament_prefix_of}{tournament_word} {tournament_details_str} prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name} est confirmée.")
else:
body_parts.append(f"Votre inscription au tournoi {tournament_details_str} est confirmée.")
body_parts.append(f"Votre inscription {tournament_prefix_at}{tournament_word} {tournament_details_str} prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name} est confirmée.")
if tournament.team_sorting == TeamSortingType.RANK:
cloture_date = tournament.local_registration_federal_limit().strftime("%d/%m/%Y à %H:%M")
cloture_date = tournament.local_registration_federal_limit()
loc = ""
if cloture_date is not None:
loc = f", prévu le {cloture_date}"
loc = f", prévu le {cloture_date.strftime("%d/%m/%Y à %H:%M")}"
body_parts.append(f"Attention, la sélection définitive se fera par poids d'équipe à la clôture des inscriptions{loc}.")
absolute_url = f"https://padelclub.app/tournament/{tournament.id}/info"
link_text = "informations sur le tournoi"
link_text = f"informations sur {tournament_prefix_that}{tournament_word}"
absolute_url = f'<a href="{absolute_url}">{link_text}</a>'
body_parts.extend([
f"\nDate d'inscription: {inscription_date}",
f"\nÉquipe inscrite: {captain.name()} et {other_player.name()}",
f"\nLe tournoi commencera le {tournament.formatted_start_date()} au club {tournament.event.club.name}",
f"\nVoir les {absolute_url}",
f"\nVoir les {absolute_url}"
])
# Add payment information if applicable
if tournament.should_request_payment():
payment_info = TournamentEmailService._build_payment_info(tournament, captain.team_registration)
body_parts.append(payment_info)
body_parts.extend([
"\nPour toute question, veuillez contacter votre juge-arbitre. Si vous n'êtes pas à l'origine de cette inscription, merci de le contacter rapidement.",
f"\n{TournamentEmailService._format_umpire_contact(tournament)}",
"\nCeci est un e-mail automatique, veuillez ne pas y répondre.",
@ -100,9 +134,14 @@ class TournamentEmailService:
@staticmethod
def _build_unregistration_email_body(tournament, captain, tournament_details_str, other_player):
federal_level_category = FederalLevelCategory(tournament.federal_level_category)
tournament_word = federal_level_category.localized_word()
tournament_prefix_at = federal_level_category.localized_prefix_at()
tournament_prefix_that = federal_level_category.localized_prefix_that()
body_parts = [
"Bonjour,\n\n",
f"Votre inscription au tournoi {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name} a été annulée"
f"Votre inscription {tournament_prefix_at}{tournament_word} {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name} a été annulée"
]
if other_player is not None:
@ -111,7 +150,7 @@ class TournamentEmailService:
)
absolute_url = f"https://padelclub.app/tournament/{tournament.id}/info"
link_text = "informations sur le tournoi"
link_text = f"informations sur {tournament_prefix_that}{tournament_word}"
absolute_url = f'<a href="{absolute_url}">{link_text}</a>'
body_parts.append(
@ -129,13 +168,17 @@ class TournamentEmailService:
@staticmethod
def _build_out_of_waiting_list_email_body(tournament, captain, tournament_details_str, other_player):
federal_level_category = FederalLevelCategory(tournament.federal_level_category)
tournament_word = federal_level_category.localized_word()
tournament_prefix_at = federal_level_category.localized_prefix_at()
body_parts = [
"Bonjour,\n\n",
f"Suite au désistement d'une paire, vous êtes maintenant inscrit au tournoi {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name}"
f"Suite au désistement d'une paire, vous êtes maintenant inscrit {tournament_prefix_at}{tournament_word} {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name}"
]
absolute_url = f"https://padelclub.app/tournament/{tournament.id}/info"
link_text = "accéder au tournoi"
link_text = f"accéder {tournament_prefix_at}{tournament_word}"
absolute_url = f'<a href="{absolute_url}">{link_text}</a>'
if other_player is not None:
@ -143,11 +186,38 @@ class TournamentEmailService:
f"\nVoici le partenaire indiqué dans l'inscription : {other_player.name()}, n'oubliez pas de le prévenir."
)
body_parts.append(
"\n\nSi vous n'êtes plus disponible pour participer à ce tournoi, cliquez sur ce lien ou contactez rapidement le juge-arbitre."
f"\n{absolute_url}"
"\nPour vous désinscrire en ligne vous devez avoir un compte Padel Club."
)
confirmation_message = TournamentEmailService._build_confirmation_message(captain, tournament, absolute_url)
body_parts.append(confirmation_message)
body_parts.extend([
f"\n\n{TournamentEmailService._format_umpire_contact(tournament)}",
"\n\nCeci est un e-mail automatique, veuillez ne pas y répondre."
])
return "".join(body_parts)
@staticmethod
def _build_requires_confirmation_email_body(tournament, captain, tournament_details_str, other_player):
federal_level_category = FederalLevelCategory(tournament.federal_level_category)
tournament_word = federal_level_category.localized_word()
tournament_prefix_at = federal_level_category.localized_prefix_at()
body_parts = [
"Bonjour,\n\n",
f"Vous n'avez toujours pas confirmé votre participation {tournament_prefix_at}{tournament_word} {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name}"
]
absolute_url = f"https://padelclub.app/tournament/{tournament.id}/info"
link_text = f"accéder {tournament_prefix_at}{tournament_word}"
absolute_url = f'<a href="{absolute_url}">{link_text}</a>'
if other_player is not None:
body_parts.append(
f"\nVoici le partenaire indiqué dans l'inscription : {other_player.name()}, n'oubliez pas de le prévenir."
)
confirmation_message = TournamentEmailService._build_confirmation_message(captain, tournament, absolute_url)
body_parts.append(confirmation_message)
body_parts.extend([
f"\n\n{TournamentEmailService._format_umpire_contact(tournament)}",
@ -158,9 +228,13 @@ class TournamentEmailService:
@staticmethod
def _build_tournament_cancellation_email_body(tournament, player, tournament_details_str, other_player):
federal_level_category = FederalLevelCategory(tournament.federal_level_category)
tournament_word = federal_level_category.localized_word()
tournament_prefix_that = federal_level_category.localized_prefix_that()
body_parts = [
"Bonjour,\n\n",
f"Le tournoi {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name} a été annulé par le juge-arbitre."
f"{tournament_prefix_that.capitalize()}{tournament_word} {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name} a été annulé par le juge-arbitre."
]
if other_player is not None:
@ -178,13 +252,18 @@ class TournamentEmailService:
@staticmethod
def _build_in_tournament_email_body(tournament, captain, tournament_details_str, other_player):
federal_level_category = FederalLevelCategory(tournament.federal_level_category)
tournament_word = federal_level_category.localized_word()
tournament_prefix_at = federal_level_category.localized_prefix_at()
tournament_prefix_of = federal_level_category.localized_prefix_of()
body_parts = [
"Bonjour,\n\n",
f"Suite à une modification de la taille du tournoi, vous pouvez participer au tournoi {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name}"
f"Suite à une modification de la taille {tournament_prefix_of}{tournament_word}, vous pouvez participer {tournament_prefix_at}{tournament_word} {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name}"
]
absolute_url = f"https://padelclub.app/tournament/{tournament.id}/info"
link_text = "accéder au tournoi"
link_text = f"accéder {tournament_prefix_at}{tournament_word}"
absolute_url = f'<a href="{absolute_url}">{link_text}</a>'
if other_player is not None:
@ -192,11 +271,8 @@ class TournamentEmailService:
f"\nVoici le partenaire indiqué dans l'inscription : {other_player.name()}, n'oubliez pas de le prévenir."
)
body_parts.append(
"\n\nSi vous n'êtes plus disponible pour participer à ce tournoi, cliquez sur ce lien ou contactez rapidement le juge-arbitre."
f"\n{absolute_url}"
"\nPour vous désinscrire en ligne vous devez avoir un compte Padel Club."
)
confirmation_message = TournamentEmailService._build_confirmation_message(captain, tournament, absolute_url)
body_parts.append(confirmation_message)
body_parts.extend([
f"\n\n{TournamentEmailService._format_umpire_contact(tournament)}",
@ -207,13 +283,19 @@ class TournamentEmailService:
@staticmethod
def _build_out_of_tournament_email_body(tournament, captain, tournament_details_str, other_player):
federal_level_category = FederalLevelCategory(tournament.federal_level_category)
tournament_word = federal_level_category.localized_word()
tournament_prefix_at = federal_level_category.localized_prefix_at()
tournament_prefix_of = federal_level_category.localized_prefix_of()
tournament_prefix_that = federal_level_category.localized_prefix_that()
body_parts = [
"Bonjour,\n\n",
f"Suite à une modification de la taille du tournoi, vous ne faites plus partie des équipes participant au tournoi {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name}"
f"Suite à une modification de la taille {tournament_prefix_of}{tournament_word}, vous avez été placé en liste d'attente. Votre participation {tournament_prefix_at}{tournament_word} {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name} n'est plus confirmée."
]
absolute_url = f"https://padelclub.app/tournament/{tournament.id}/info"
link_text = "informations sur le tournoi"
link_text = f"informations sur {tournament_prefix_that}{tournament_word}"
absolute_url = f'<a href="{absolute_url}">{link_text}</a>'
body_parts.append(f"\n\nVoir les {absolute_url}")
@ -233,13 +315,17 @@ class TournamentEmailService:
@staticmethod
def _build_walk_out_email_body(tournament, captain, tournament_details_str, other_player):
federal_level_category = FederalLevelCategory(tournament.federal_level_category)
tournament_word = federal_level_category.localized_word()
tournament_prefix_at = federal_level_category.localized_prefix_at()
body_parts = [
"Bonjour,\n\n",
f"Le juge-arbitre a annulé votre participation au tournoi {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name}"
f"Le juge-arbitre a annulé votre participation {tournament_prefix_at}{tournament_word} {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name}"
]
absolute_url = f"https://padelclub.app/tournament/{tournament.id}/info"
link_text = "accéder au tournoi"
link_text = f"accéder {tournament_prefix_at}{tournament_word}"
absolute_url = f'<a href="{absolute_url}">{link_text}</a>'
body_parts.append(f"\n\nVoir les {absolute_url}")
@ -260,13 +346,18 @@ class TournamentEmailService:
@staticmethod
def _build_out_of_walkout_is_in_email_body(tournament, captain, tournament_details_str, other_player):
federal_level_category = FederalLevelCategory(tournament.federal_level_category)
tournament_word = federal_level_category.localized_word()
tournament_prefix_at = federal_level_category.localized_prefix_at()
tournament_prefix_that = federal_level_category.localized_prefix_that()
body_parts = [
"Bonjour,\n\n",
f"Le juge-arbitre vous a ré-intégré au tournoi {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name}"
f"Le juge-arbitre vous a ré-intégré {tournament_prefix_at}{tournament_word} {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name}"
]
absolute_url = f"https://padelclub.app/tournament/{tournament.id}/info"
link_text = "informations sur le tournoi"
link_text = f"informations sur {tournament_prefix_that}{tournament_word}"
absolute_url = f'<a href="{absolute_url}">{link_text}</a>'
body_parts.append(f"\n\nVoir les {absolute_url}")
@ -276,6 +367,9 @@ class TournamentEmailService:
f"\nVous étiez inscrit avec {other_player.name()}, n'oubliez pas de prévenir votre partenaire."
)
confirmation_message = TournamentEmailService._build_confirmation_message(captain, tournament, absolute_url)
body_parts.append(confirmation_message)
body_parts.extend([
"\n\nPour toute question, veuillez contacter votre juge-arbitre : ",
f"\n{TournamentEmailService._format_umpire_contact(tournament)}",
@ -286,13 +380,18 @@ class TournamentEmailService:
@staticmethod
def _build_unexpected_out_of_tournament_email_body(tournament, captain, tournament_details_str, other_player):
federal_level_category = FederalLevelCategory(tournament.federal_level_category)
tournament_word = federal_level_category.localized_word()
tournament_prefix_at = federal_level_category.localized_prefix_at()
tournament_prefix_that = federal_level_category.localized_prefix_that()
body_parts = [
"Bonjour,\n\n",
f"En raison d'une décision du juge-arbitre, vous ne faites plus partie des équipes participant au tournoi {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name}"
f"En raison d'une décision du juge-arbitre, vous avez été placé en liste d'attente. Votre participation {tournament_prefix_at}{tournament_word} {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name} n'est plus confirmée."
]
absolute_url = f"https://padelclub.app/tournament/{tournament.id}/info"
link_text = "informations sur le tournoi"
link_text = f"informations sur {tournament_prefix_that}{tournament_word}"
absolute_url = f'<a href="{absolute_url}">{link_text}</a>'
body_parts.append(f"\n\nVoir les {absolute_url}")
@ -312,13 +411,22 @@ class TournamentEmailService:
@staticmethod
def _build_out_of_walkout_waiting_list_email_body(tournament, captain, tournament_details_str, other_player):
federal_level_category = FederalLevelCategory(tournament.federal_level_category)
tournament_word = federal_level_category.localized_word()
tournament_prefix_of = federal_level_category.localized_prefix_of()
tournament_prefix_that = federal_level_category.localized_prefix_that()
body_parts = [
"Bonjour,\n\n",
f"Le juge-arbitre vous a ré-intégré au tournoi en liste d'attente {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name}"
]
if captain.registration_status == RegistrationStatus.CANCELED:
body_parts.append("Le temps accordé pour confirmer votre inscription s'est écoulé.")
body_parts.append(f"Vous avez été replacé en liste d'attente {tournament_prefix_of}{tournament_word} {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name}")
else:
body_parts.append(f"Le juge-arbitre vous a placé en liste d'attente {tournament_prefix_of}{tournament_word} {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name}")
absolute_url = f"https://padelclub.app/tournament/{tournament.id}/info"
link_text = "informations sur le tournoi"
link_text = f"informations sur {tournament_prefix_that}{tournament_word}"
absolute_url = f'<a href="{absolute_url}">{link_text}</a>'
body_parts.append(f"\n\nVoir les {absolute_url}")
@ -355,6 +463,49 @@ class TournamentEmailService:
return "\n".join(contact_parts)
@staticmethod
def _build_confirmation_message(captain, tournament, absolute_url):
"""
Build a standardized confirmation message for emails.
Args:
captain: The player (captain) receiving the email
tournament: The tournament
absolute_url: The URL for confirmation/unregistration
Returns:
str: Formatted confirmation message
"""
time_to_confirm = getattr(captain, 'time_to_confirm', None)
# Common URL and account info text
account_info = "\nVous devez avoir un compte Padel Club."
url_info = f"\n{absolute_url}"
federal_level_category = FederalLevelCategory(tournament.federal_level_category)
tournament_word = federal_level_category.localized_word()
tournament_prefix_at = federal_level_category.localized_prefix_at()
tournament_prefix_this = federal_level_category.localized_prefix_this()
# Base message varies based on whether confirmation is needed
if time_to_confirm is not None:
# Format the deadline time with proper timezone
deadline_str = time_to_confirm.astimezone(tournament.timezone()).strftime("%d/%m/%Y à %H:%M (%Z)")
# Confirmation required message
action_text = f"Pour confirmer votre participation {tournament_prefix_at}{tournament_word}, cliquez sur ce lien pour {url_info} ou contactez rapidement le juge-arbitre."
warning_text = f" ATTENTION : Vous avez jusqu'au {deadline_str} pour confirmer votre participation. Passé ce délai, votre place sera automatiquement proposée à l'équipe suivante sur liste d'attente.\n\n"
elif captain.registration_status == RegistrationStatus.PENDING:
action_text = f"Pour confirmer votre participation {tournament_prefix_at}{tournament_word}, cliquez sur ce lien pour {url_info} ou contactez rapidement le juge-arbitre."
warning_text = f" ATTENTION : Actuellement, il n'y a pas de liste d'attente pour {tournament_prefix_this}{tournament_word}. Dès qu'une liste d'attente se formera, vous recevrez un email avec un délai précis pour confirmer votre participation.\n\n"
else:
# Standard message for teams already confirmed
action_text = f"Si vous n'êtes plus disponible pour participer à {tournament_prefix_this}{tournament_word}, cliquez sur ce lien pour {url_info} ou contactez rapidement le juge-arbitre."
warning_text = ""
# Construct the complete message
return f"\n\n{warning_text}{action_text}{account_info}"
@staticmethod
def notify(captain, other_player, tournament, message_type: TeamEmailType):
print("TournamentEmailService.notify", captain.email, captain.registered_online, tournament, message_type)
@ -370,7 +521,7 @@ class TournamentEmailService:
if email_body is None:
return
topic = message_type.email_subject()
topic = message_type.email_topic(tournament.federal_level_category, captain.time_to_confirm)
email_subject = TournamentEmailService.email_subject(tournament, topic)
TournamentEmailService._send_email(captain.email, email_subject, email_body)
@ -408,6 +559,10 @@ class TournamentEmailService:
body = TournamentEmailService._build_out_of_walkout_waiting_list_email_body(
tournament, recipient, tournament_details_str, other_player
)
elif message_type == TeamEmailType.REQUIRES_TIME_CONFIRMATION:
body = TournamentEmailService._build_requires_confirmation_email_body(
tournament, recipient, tournament_details_str, other_player
)
elif message_type == TeamEmailType.UNEXPECTED_OUT_OF_TOURNAMENT:
body = TournamentEmailService._build_unexpected_out_of_tournament_email_body(
tournament, recipient, tournament_details_str, other_player
@ -439,13 +594,192 @@ class TournamentEmailService:
@staticmethod
def notify_team(team, tournament, message_type: TeamEmailType):
# Notify both players separately if there is no captain or the captain is unavailable
players = list(team.player_registrations.all())
players = list(team.players_sorted_by_rank)
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(second_player, first_player, tournament, message_type)
if first_player.email != second_player.email:
TournamentEmailService.notify(second_player, first_player, tournament, message_type)
elif len(players) == 1:
print("TournamentEmailService.notify_team 1p", team)
# If there's only one player, just send them the notification
TournamentEmailService.notify(players[0], None, tournament, message_type)
@staticmethod
def _build_payment_info(tournament, team_registration):
"""
Build payment information section for emails
"""
if not tournament.should_request_payment():
return ""
if tournament.is_free():
return ""
# Check payment status
payment_status = team_registration.get_payment_status()
if payment_status == 'PAID':
return "\n\n✅ Le paiement de votre inscription a bien été reçu."
# If the team is on the waiting list, don't mention payment
if team_registration.is_in_waiting_list() >= 0:
return ""
# For unpaid teams, add payment instructions
payment_info = [
"\n\n Paiement des frais d'inscription requis",
f"Les frais d'inscription de {tournament.entry_fee:.2f}€ par joueur doivent être payés pour confirmer votre participation.",
"Vous pouvez effectuer le paiement en vous connectant à votre compte Padel Club.",
f"Lien pour payer: https://padelclub.app/tournament/{tournament.id}/info"
]
return "\n".join(payment_info)
@staticmethod
def send_payment_confirmation(team_registration, payment):
"""
Send a payment confirmation email to team members
Args:
team_registration: The team registration
payment: The payment details from Stripe
"""
tournament = team_registration.tournament
player_registrations = team_registration.players_sorted_by_rank
# Calculate payment amount
payment_amount = None
if payment and 'amount' in payment:
# Convert cents to euros
payment_amount = payment['amount'] / 100
if payment_amount is None:
payment_amount = tournament.team_fee()
federal_level_category = FederalLevelCategory(tournament.federal_level_category)
tournament_word = federal_level_category.localized_word()
tournament_prefix_that = federal_level_category.localized_prefix_that()
for player in player_registrations:
if not player.email or not player.registered_online:
continue
tournament_details_str = tournament.build_tournament_details_str()
other_player = team_registration.get_other_player(player) if len(player_registrations) > 1 else None
body_parts = [
"Bonjour,\n\n",
f"Votre paiement pour {tournament_prefix_that}{tournament_word} {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name} a été reçu avec succès."
]
# Add information about the other player if available
if other_player:
body_parts.append(
f"\n\nVous êtes inscrit avec {other_player.name()}, n'oubliez pas de prévenir votre partenaire de la confirmation du paiement."
)
# Add payment details
body_parts.append(
f"\n\nMontant payé : {payment_amount:.2f}"
)
payment_date = timezone.now().strftime("%d/%m/%Y")
body_parts.append(
f"\nDate du paiement : {payment_date}"
)
absolute_url = f"https://padelclub.app/tournament/{tournament.id}/info"
link_text = f"informations sur {tournament_prefix_that}{tournament_word}"
absolute_url = f'<a href="{absolute_url}">{link_text}</a>'
body_parts.append(f"\n\nVoir les {absolute_url}")
if tournament.team_sorting == TeamSortingType.RANK:
cloture_date = tournament.local_registration_federal_limit().strftime("%d/%m/%Y à %H:%M")
loc = ""
if cloture_date is not None:
loc = f", prévu le {cloture_date}"
body_parts.append(f"\n\nAttention, la sélection définitive se fera par poids d'équipe à la clôture des inscriptions{loc}.")
body_parts.extend([
f"\n\n{TournamentEmailService._format_umpire_contact(tournament)}",
"\n\nCeci est un e-mail automatique, veuillez ne pas y répondre."
])
email_body = "".join(body_parts)
email_subject = TournamentEmailService.email_subject(tournament, "Confirmation de paiement")
TournamentEmailService._send_email(player.email, email_subject, email_body)
@staticmethod
def send_refund_confirmation(tournament, team_registration, refund_details):
"""
Send a refund confirmation email to team members
Args:
tournament: The tournament
team_registration: The team registration
refund_details: The refund details from Stripe
"""
player_registrations = team_registration.players_sorted_by_rank
refund_amount = None
if refund_details and 'amount' in refund_details:
# Convert cents to euros
refund_amount = refund_details['amount'] / 100
if refund_amount is None:
refund_amount = tournament.team_fee()
federal_level_category = FederalLevelCategory(tournament.federal_level_category)
tournament_word = federal_level_category.localized_word()
tournament_prefix_that = federal_level_category.localized_prefix_that()
processed_emails = set()
for player in player_registrations:
if not player.email or not player.registered_online:
continue
if player.email in processed_emails:
continue
processed_emails.add(player.email)
tournament_details_str = tournament.build_tournament_details_str()
other_player = team_registration.get_other_player(player) if len(player_registrations) > 1 else None
body_parts = [
"Bonjour,\n\n",
f"Votre remboursement pour {tournament_prefix_that}{tournament_word} {tournament_details_str}, prévu le {tournament.formatted_start_date()} au club {tournament.event.club.name} a été traité avec succès."
]
# Add information about the other player if available
if other_player:
body_parts.append(
f"\n\nVous étiez inscrit avec {other_player.name()}, n'oubliez pas de prévenir votre partenaire du remboursement."
)
# Add refund details
body_parts.append(
f"\n\nMontant remboursé : {refund_amount:.2f}"
)
refund_date = timezone.now().strftime("%d/%m/%Y")
body_parts.append(
f"\nDate du remboursement : {refund_date}"
)
absolute_url = f"https://padelclub.app/tournament/{tournament.id}/info"
link_text = f"informations sur {tournament_prefix_that}{tournament_word}"
absolute_url = f'<a href="{absolute_url}">{link_text}</a>'
body_parts.append(f"\n\nVoir les {absolute_url}")
body_parts.extend([
f"\n\n{TournamentEmailService._format_umpire_contact(tournament)}",
"\n\nCeci est un e-mail automatique, veuillez ne pas y répondre."
])
email_body = "".join(body_parts)
email_subject = TournamentEmailService.email_subject(tournament, "Confirmation de remboursement")
TournamentEmailService._send_email(player.email, email_subject, email_body)

@ -0,0 +1,401 @@
from django.shortcuts import get_object_or_404
from django.conf import settings
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
import stripe
from ..models import TeamRegistration, PlayerRegistration, Tournament
from ..models.player_registration import PlayerPaymentType
from .email_service import TournamentEmailService
from .tournament_registration import RegistrationCartManager
from ..utils.extensions import is_not_sqlite_backend
class PaymentService:
"""
Service for handling payment processing for tournament registrations
"""
def __init__(self, request):
self.request = request
self.stripe_api_key = settings.STRIPE_SECRET_KEY
def create_checkout_session(self, tournament_id, team_fee, cart_data=None, team_registration_id=None):
"""
Create a Stripe checkout session for tournament payment
"""
stripe.api_key = self.stripe_api_key
tournament = get_object_or_404(Tournament, id=tournament_id)
# Check if payments are enabled for this tournament
if not tournament.should_request_payment():
raise Exception("Les paiements ne sont pas activés pour ce tournoi.")
# Get user email if authenticated
customer_email = self.request.user.email if self.request.user.is_authenticated else None
# Determine the appropriate cancel URL based on the context
if team_registration_id:
# If we're paying for an existing registration, go back to tournament info
cancel_url = self.request.build_absolute_uri(
reverse('tournament-info', kwargs={'tournament_id': tournament_id})
)
else:
# If we're in the registration process, go back to registration form
cancel_url = self.request.build_absolute_uri(
reverse('register_tournament', kwargs={'tournament_id': tournament_id})
)
base_metadata = {
'tournament_id': str(tournament_id),
'user_id': str(self.request.user.id) if self.request.user.is_authenticated else None,
'payment_source': 'tournament', # Identify payment source
'source_page': 'tournament_info' if team_registration_id else 'register_tournament',
}
if tournament.is_corporate_tournament:
# Corporate tournament metadata
metadata = {
**base_metadata,
'is_corporate_tournament': 'true',
'stripe_account_type': 'direct'
}
else:
# Regular tournament metadata
metadata = {
**base_metadata,
'is_corporate_tournament': 'false',
'stripe_account_type': 'connect',
'stripe_account_id': tournament.stripe_account_id
}
if cart_data:
metadata.update({
'registration_cart_id': str(cart_data['cart_id']),
'registration_type': 'cart',
'player_count': str(cart_data.get('player_count', 0)),
'waiting_list_position': str(cart_data.get('waiting_list_position', -1))
})
elif team_registration_id:
metadata.update({
'team_registration_id': str(team_registration_id),
'registration_type': 'direct'
})
self.request.session['team_registration_id'] = str(team_registration_id)
metadata.update({
'tournament_name': tournament.broadcast_display_name(),
'tournament_date': tournament.formatted_start_date(),
'tournament_club': tournament.event.club.name,
'tournament_fee': str(team_fee)
})
# Common checkout session parameters
if tournament.is_corporate_tournament:
# Direct charge without transfers when umpire is platform owner
checkout_session_params = {
'payment_method_types': ['card'],
'line_items': [{
'price_data': {
'currency': 'eur',
'product_data': {
'name': f'{tournament.broadcast_display_name()} du {tournament.formatted_start_date()}',
'description': f'Lieu {tournament.event.club.name}',
},
'unit_amount': int(team_fee * 100), # Amount in cents
},
'quantity': 1,
}],
'mode': 'payment',
'success_url': self.request.build_absolute_uri(
reverse('tournament-payment-success', kwargs={'tournament_id': tournament_id})
),
'cancel_url': cancel_url,
'metadata': metadata
}
else:
# Get the umpire's Stripe account ID
stripe_account_id = tournament.stripe_account_id
if not stripe_account_id:
raise Exception("L'arbitre n'a pas configuré son compte Stripe.")
# Calculate commission
commission_rate = tournament.event.creator.effective_commission_rate()
platform_amount = int((team_fee * commission_rate) * 100) # Commission in cents
checkout_session_params = {
'payment_method_types': ['card'],
'line_items': [{
'price_data': {
'currency': 'eur',
'product_data': {
'name': f'{tournament.broadcast_display_name()} du {tournament.formatted_start_date()}',
'description': f'Lieu {tournament.event.club.name}',
},
'unit_amount': int(team_fee * 100), # Amount in cents
},
'quantity': 1,
}],
'mode': 'payment',
'success_url': self.request.build_absolute_uri(
reverse('tournament-payment-success', kwargs={'tournament_id': tournament_id})
),
'cancel_url': cancel_url,
'payment_intent_data': {
'application_fee_amount': platform_amount,
'transfer_data': {
'destination': stripe_account_id,
},
},
'metadata': metadata
}
# # Add cart or team data to metadata based on payment context
# if cart_data:
# checkout_session_params['metadata']['registration_cart_id'] = str(cart_data['cart_id']) # Convert to string
# elif team_registration_id:
# checkout_session_params['metadata']['team_registration_id'] = str(team_registration_id) # Convert to string
# self.request.session['team_registration_id'] = str(team_registration_id) # Convert to string
# Add customer_email if available
if customer_email:
checkout_session_params['customer_email'] = customer_email
# Create the checkout session
try:
checkout_session = stripe.checkout.Session.create(**checkout_session_params)
# Store checkout session ID and source page in session
self.request.session['stripe_checkout_session_id'] = checkout_session.id
self.request.session['payment_source_page'] = 'tournament_info' if team_registration_id else 'register_tournament'
self.request.session.modified = True
return checkout_session
except stripe.error.StripeError as e:
# Handle specific Stripe errors more gracefully
if 'destination' in str(e):
raise Exception("Erreur avec le compte Stripe de l'arbitre. Contactez l'administrateur.")
else:
raise Exception(f"Erreur Stripe: {str(e)}")
def process_successful_payment(self, tournament_id, checkout_session):
"""
Process a successful Stripe payment
Returns a tuple (success, redirect_response)
"""
print(f"Processing payment for tournament {tournament_id}")
tournament = get_object_or_404(Tournament, id=tournament_id)
# Check if this is a payment for an existing team registration
team_registration_id = self.request.session.get('team_registration_id')
print(f"Team registration ID from session: {team_registration_id}")
# Track payment statuses for debugging
payment_statuses = []
if team_registration_id:
success = self._process_direct_payment(checkout_session)
payment_statuses.append(success)
print(f"Direct payment processing result: {success}")
else:
# This is a payment during registration process
success = self._process_registration_payment(tournament, checkout_session)
payment_statuses.append(success)
print(f"Registration payment processing result: {success}")
# Print combined payment status
print(f"Payment statuses: {payment_statuses}")
print(any(payment_statuses))
# Clear checkout session ID
if 'stripe_checkout_session_id' in self.request.session:
del self.request.session['stripe_checkout_session_id']
return any(payment_statuses)
def _process_direct_payment(self, checkout_session):
"""Process payment for an existing team registration"""
team_registration_id = self.request.session.get('team_registration_id')
if not team_registration_id:
print("No team registration ID found in session")
return False
try:
print(f"Looking for team registration with ID: {team_registration_id}")
team_registration = TeamRegistration.objects.get(id=team_registration_id)
success = self._update_registration_payment_info(
team_registration,
checkout_session.payment_intent
)
# Clean up session
if 'team_registration_id' in self.request.session:
del self.request.session['team_registration_id']
if success:
TournamentEmailService.send_payment_confirmation(team_registration, checkout_session.payment_intent)
return success
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
def _process_registration_payment(self, tournament, checkout_session):
"""Process payment made during registration"""
cart_manager = RegistrationCartManager(self.request)
cart_data = cart_manager.get_cart_data()
# Checkout and create registration
success, result = cart_manager.checkout()
if not success:
return False
# Process payment for the new registration
team_registration = result # result is team_registration object
self._update_registration_payment_info(
team_registration,
checkout_session.payment_intent
)
# Send confirmation email if appropriate
waiting_list_position = cart_data.get('waiting_list_position', -1)
if is_not_sqlite_backend():
email_service = TournamentEmailService()
email_service.send_registration_confirmation(
self.request,
tournament,
team_registration,
waiting_list_position
)
return True
def _update_registration_payment_info(self, team_registration, payment_intent_id):
"""Update player registrations with payment information"""
team_registration.confirm_registration(payment_intent_id)
return True
def process_refund(self, team_registration_id):
"""
Process a refund for a tournament registration as part of unregistration
Returns a tuple (success, message)
"""
stripe.api_key = self.stripe_api_key
try:
# Get the team registration
team_registration = get_object_or_404(TeamRegistration, id=team_registration_id)
tournament = team_registration.tournament
# Check if refund is possible for this tournament
if not tournament.is_refund_possible():
return False, "Les remboursements ne sont plus possibles pour ce tournoi.", None
# Get payment ID from player registrations
player_registrations = PlayerRegistration.objects.filter(team_registration=team_registration)
payment_id = None
for player_reg in player_registrations:
# Find the first valid payment ID
if player_reg.payment_id and player_reg.payment_type == PlayerPaymentType.CREDIT_CARD:
payment_id = player_reg.payment_id
break
if not payment_id:
return False, "Aucun paiement trouvé pour cette équipe.", None
# Get the Stripe payment intent
payment_intent = stripe.PaymentIntent.retrieve(payment_id)
if payment_intent.status != 'succeeded':
return False, "Le paiement n'a pas été complété, il ne peut pas être remboursé.", None
# Process the refund - with different parameters based on tournament type
refund_params = {
'payment_intent': payment_id
}
# Only include transfer reversal for non-corporate tournaments
if not tournament.is_corporate_tournament:
refund_params.update({
'refund_application_fee': True,
'reverse_transfer': True
})
refund = stripe.Refund.create(**refund_params)
for player_reg in player_registrations:
player_reg.payment_type = None
player_reg.payment_id = None
player_reg.save()
TournamentEmailService.send_refund_confirmation(tournament, team_registration, refund)
# Return success with refund object
return True, "L'inscription a été remboursée automatiquement.", refund
except stripe.error.StripeError as e:
return False, f"Erreur de remboursement Stripe: {str(e)}", None
except Exception as e:
return False, f"Erreur lors du remboursement: {str(e)}", None
@staticmethod
@csrf_exempt
@require_POST
def stripe_webhook(request):
payload = request.body
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')
print("Received webhook call")
print(f"Signature: {sig_header}")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.TOURNAMENT_STRIPE_WEBHOOK_SECRET
)
print(f"Tournament webhook event type: {event['type']}")
if event['type'] == 'checkout.session.completed':
session = event['data']['object']
metadata = session.get('metadata', {})
tournament_id = metadata.get('tournament_id')
if not tournament_id:
print("No tournament_id in metadata")
return HttpResponse(status=400)
payment_service = PaymentService(request)
success = payment_service.process_successful_payment(tournament_id, session)
if success:
print(f"Successfully processed webhook payment for tournament {tournament_id}")
return HttpResponse(status=200)
else:
print(f"Failed to process webhook payment for tournament {tournament_id}")
return HttpResponse(status=400)
elif event['type'] == 'payment_intent.payment_failed':
intent = event['data']['object']
metadata = intent.get('metadata', {})
tournament_id = metadata.get('tournament_id')
source_page = metadata.get('source_page')
if tournament_id and source_page == 'register_tournament':
try:
tournament = Tournament.objects.get(id=tournament_id)
# Decrease reserved spots, minimum 0
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 {tournament_id} not found")
except Exception as e:
print(f"Error updating tournament reserved spots: {str(e)}")
return HttpResponse(status=200)
except Exception as e:
print(f"Tournament webhook error: {str(e)}")

@ -1,372 +1,490 @@
from django.utils import timezone
from ..forms import TournamentRegistrationForm, AddPlayerForm
from ..repositories import TournamentRegistrationRepository
from .email_service import TournamentEmailService
from django.contrib import messages
import uuid
import datetime
from ..models import PlayerRegistration, TeamRegistration, Tournament
from ..utils.licence_validator import LicenseValidator
from ..utils.player_search import get_player_name_from_csv
from tournaments.models import PlayerRegistration
from ..utils.extensions import is_not_sqlite_backend
from ..models.enums import FederalCategory, RegistrationStatus
from ..models.player_enums import PlayerSexType, PlayerDataSource
from django.contrib.auth import get_user_model
from django.contrib.messages import get_messages
from django.db import IntegrityError
from django.conf import settings
class TournamentRegistrationService:
def __init__(self, request, tournament):
class RegistrationCartManager:
"""
Manages the registration cart for tournament registrations.
Handles session-based cart operations, player additions/removals,
and checkout processes.
"""
CART_EXPIRY_SECONDS = 300
def __init__(self, request):
self.request = request
self.tournament = tournament
self.context = {}
self.repository = TournamentRegistrationRepository()
self.email_service = TournamentEmailService()
def initialize_context(self):
self.context = {
'tournament': self.tournament,
'registration_successful': False,
'team_form': None,
'add_player_form': None,
'current_players': self.request.session.get('team_registration', []),
self.session = request.session
self.first_tournament = False
def get_or_create_cart_id(self):
"""Get or create a registration cart ID in the session"""
if 'registration_cart_id' not in self.session:
self.session['registration_cart_id'] = str(uuid.uuid4()) # Ensure it's a string
self.session.modified = True
return self.session['registration_cart_id']
def get_cart_expiry(self):
"""Get the cart expiry time from the session"""
if 'registration_cart_expiry' not in self.session:
# Set default expiry to 30 minutes from now
expiry = timezone.now() + datetime.timedelta(seconds=self.CART_EXPIRY_SECONDS)
self.session['registration_cart_expiry'] = expiry.isoformat()
self.session.modified = True
return self.session['registration_cart_expiry']
def is_cart_expired(self):
"""Check if the registration cart is expired"""
if 'registration_cart_expiry' not in self.session:
return False
expiry_str = self.session['registration_cart_expiry']
try:
expiry = datetime.datetime.fromisoformat(expiry_str)
return timezone.now() > expiry
except (ValueError, TypeError):
return True
def reset_cart_expiry(self):
"""Reset the cart expiry time"""
expiry = timezone.now() + datetime.timedelta(seconds=self.CART_EXPIRY_SECONDS)
self.session['registration_cart_expiry'] = expiry.isoformat()
self.session.modified = True
def get_tournament_id(self):
"""Get the tournament ID associated with the current cart"""
return self.session.get('registration_tournament_id')
def initialize_cart(self, tournament_id):
"""Initialize a new registration cart for a tournament"""
# Clear any existing cart
self.clear_cart()
try:
tournament = Tournament.objects.get(id=tournament_id)
except Tournament.DoesNotExist:
return False, "Tournoi introuvable."
# Update tournament reserved spots
waiting_list_position = tournament.get_waiting_list_position()
# Set up the new cart
self.session['registration_cart_id'] = str(uuid.uuid4()) # Ensure it's a string
self.session['waiting_list_position'] = waiting_list_position
self.session['registration_tournament_id'] = str(tournament_id) # Ensure it's a string
self.session['registration_cart_players'] = []
self.reset_cart_expiry()
self.session.modified = True
return True, "Cart initialized successfully"
def get_cart_data(self):
"""Get the data for the current registration cart"""
# Ensure cart players array exists
if 'registration_cart_players' not in self.session:
self.session['registration_cart_players'] = []
self.session.modified = True
# Ensure tournament ID exists
if 'registration_tournament_id' not in self.session:
# If no tournament ID but we have players, this is an inconsistency
if self.session.get('registration_cart_players'):
print("WARNING: Found players but no tournament ID - clearing players")
self.session['registration_cart_players'] = []
self.session.modified = True
# Get user phone if authenticated
user_phone = ''
if hasattr(self.request.user, 'phone'):
user_phone = self.request.user.phone
# Parse the expiry time from ISO format to datetime
expiry_str = self.get_cart_expiry()
expiry_datetime = None
if expiry_str:
try:
# Parse the ISO format string to datetime
from django.utils.dateparse import parse_datetime
expiry_datetime = parse_datetime(expiry_str)
except (ValueError, TypeError):
# If parsing fails, set a new expiry
expiry_datetime = timezone.now() + datetime.timedelta(seconds=self.CART_EXPIRY_SECONDS)
cart_data = {
'cart_id': self.get_or_create_cart_id(),
'tournament_id': self.session.get('registration_tournament_id'),
'waiting_list_position': self.session.get('waiting_list_position'),
'players': self.session.get('registration_cart_players', []),
'expiry': expiry_datetime, # Now a datetime object, not a string
'is_cart_expired': self.is_cart_expired(),
'mobile_number': self.session.get('registration_mobile_number', user_phone)
}
return self.context
def handle_post_request(self):
self.context['team_form'] = TournamentRegistrationForm(self.request.POST)
self.context['add_player_form'] = AddPlayerForm(self.request.POST)
if 'add_player' in self.request.POST:
self.handle_add_player()
if 'remove_player' in self.request.POST:
self.handle_remove_player()
elif 'register_team' in self.request.POST:
self.handle_team_registration()
def handle_remove_player(self):
team_registration = self.request.session.get('team_registration', [])
if team_registration: # Check if list is not empty
team_registration.pop() # Remove last element
self.request.session['team_registration'] = team_registration
self.context['current_players'] = team_registration
def handle_add_player(self):
if not self.context['add_player_form'].is_valid():
return
# Clear existing messages if the form is valid
storage = get_messages(self.request)
# Iterate through the storage to clear it
for _ in storage:
pass
player_data = self.context['add_player_form'].cleaned_data
licence_id = player_data.get('licence_id', '').upper()
# Validate license
if not self._validate_license(licence_id):
return
# Check for duplicate players
if self._is_duplicate_player(licence_id):
return
# Check if player is already registered in tournament
if self._is_already_registered(licence_id):
return
if self.request.user.is_authenticated and self.request.user.licence_id is None and len(self.context['current_players']) == 0:
if self._update_user_license(player_data.get('licence_id')) == False:
# if no licence id for authentificated user and trying to add him as first player of the team, we check his federal data
self._handle_invalid_names(licence_id, player_data)
else:
# Handle player data
if self.context['add_player_form'].names_is_valid():
self._handle_valid_names(player_data)
else:
self._handle_invalid_names(licence_id, player_data)
def handle_team_registration(self):
if not self.context['team_form'].is_valid():
return
if self.request.user.is_authenticated:
cleaned_data = self.context['team_form'].cleaned_data
mobile_number = cleaned_data.get('mobile_number')
self.request.user.phone = mobile_number
self.request.user.save()
waiting_list_position = self.tournament.get_waiting_list_position()
# Debug: print the cart content
print(f"Cart data - Tournament ID: {cart_data['tournament_id']}")
print(f"Cart data - Players count: {len(cart_data['players'])}")
return cart_data
def add_player(self, player_data):
"""Add a player to the registration cart"""
if self.is_cart_expired():
return False, "Votre session d'inscription a expiré, veuillez réessayer."
# Get cart data
tournament_id = self.session.get('registration_tournament_id')
if not tournament_id:
return False, "Pas d'inscription active."
# Get tournament
try:
tournament = Tournament.objects.get(id=tournament_id)
except Tournament.DoesNotExist:
return False, "Tournoi introuvable."
team_registration = self.repository.create_team_registration(
self.tournament,
timezone.now().replace(microsecond=0)
# Get existing players directly from session
players = self.session.get('registration_cart_players', [])
# Check if we've reached the team limit
if len(players) >= 2: # Assuming teams of 2 for padel
return False, "Nombre maximum de joueurs déjà ajouté."
# Process player data
licence_id = player_data.get('licence_id', '').upper() if player_data.get('licence_id') else None
first_name = player_data.get('first_name', '')
last_name = player_data.get('last_name', '').upper() if player_data.get('last_name') else ''
# Handle license validation logic
result = self._process_player_license(
tournament, licence_id, first_name, last_name, players, len(players) == 0
)
if not result[0]:
return result # Return the error
tournament_federal_category = tournament.federal_category
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 False:
tournament_federal_category = FederalCategory.WOMEN
if licence_id:
# Get federation data
fed_data, found = get_player_name_from_csv(tournament_federal_category, licence_id)
if found is False and fed_data:
player_data.update({
'rank': fed_data['rank'],
'is_woman': fed_data['is_woman'],
})
if found and fed_data:
# Use federation data (including check for eligibility)
player_register_check = tournament.player_register_check(licence_id)
if player_register_check:
return False, ", ".join(player_register_check)
# Update player data from federation data
player_data.update({
'first_name': fed_data['first_name'],
'last_name': fed_data['last_name'],
'rank': fed_data['rank'],
'is_woman': fed_data['is_woman'],
'points': fed_data.get('points'),
'assimilation': fed_data.get('assimilation'),
'tournament_count': fed_data.get('tournament_count'),
'ligue_name': fed_data.get('ligue_name'),
'club_name': fed_data.get('club_name'),
'birth_year': fed_data.get('birth_year'),
'found_in_french_federation': True,
})
elif not first_name or not last_name:
# License not required or not found, but name is needed
self.first_tournament = True
return False, "Le prénom et le nom sont obligatoires pour les joueurs dont la licence n'a pas été trouvée."
elif not tournament.license_is_required:
# License not required, check if name is provided
if not first_name or not last_name:
return False, "Le prénom et le nom sont obligatoires."
else:
# License is required but not provided
return False, "Le numéro de licence est obligatoire."
self.repository.create_player_registrations(
self.request,
team_registration,
self.request.session['team_registration'],
self.context['team_form'].cleaned_data
# Create player registrations
sex, rank, computed_rank = self._compute_rank_and_sex(
tournament,
player_data
)
if is_not_sqlite_backend():
self.email_service.send_registration_confirmation(
self.request,
self.tournament,
team_registration,
waiting_list_position
)
player_data['computed_rank'] = computed_rank
self.clear_session_data()
self.context['registration_successful'] = True
def handle_get_request(self):
print("handle_get_request")
storage = get_messages(self.request)
# Iterate through the storage to clear it
for _ in storage:
pass
self.context['add_player_form'] = AddPlayerForm()
self.context['team_form'] = self.initialize_team_form()
self.initialize_session_data()
def add_player_to_session(self, player_data):
print("add_player_to_session", player_data)
if not self.request.session.get('team_registration'):
self.request.session['team_registration'] = []
self.request.session['team_registration'].append(player_data)
self.context['current_players'] = self.request.session.get('team_registration', [])
self.context['add_player_form'].first_tournament = False
self.context['add_player_form'].user_without_licence = False
self.request.session.modified = True
def clear_session_data(self):
self.request.session['team_registration'] = []
self.request.session.modified = True
def initialize_team_form(self):
initial_data = {}
if self.request.user.is_authenticated:
initial_data = {
'email': self.request.user.email,
'mobile_number': self.request.user.phone,
}
return TournamentRegistrationForm(initial=initial_data)
def initialize_session_data(self):
print("initialize_session_data")
self.request.session['team_registration'] = []
if self.request.user.is_authenticated:
self._add_authenticated_user_to_session()
def _add_authenticated_user_to_session(self):
if not self.request.user.licence_id:
self._handle_user_without_license()
return
player_data = self._get_authenticated_user_data()
if player_data:
self.request.session['team_registration'].insert(0, player_data)
self.context['current_players'] = self.request.session.get('team_registration', [])
self.request.session.modified = True
def _handle_user_without_license(self):
player_data = {
'first_name': self.request.user.first_name,
'last_name': self.request.user.last_name.upper(),
}
self.context['add_player_form'] = AddPlayerForm(initial=player_data)
self.context['add_player_form'].user_without_licence = True
self.request.session.modified = True
# Add player to cart
players.append(player_data)
self.session['registration_cart_players'] = players
self.reset_cart_expiry()
self.session.modified = True
def _get_authenticated_user_data(self):
user = self.request.user
validator = LicenseValidator(user.licence_id)
if sex == PlayerSexType.FEMALE:
return True, "Joueuse ajoutée avec succès."
else:
return True, "Joueur ajouté avec succès."
def _process_player_license(self, tournament, licence_id, first_name, last_name, players, is_first_player):
"""
Process and validate player license
Returns (True, None) if valid, (False, error_message) if invalid
"""
# Handle case where license is required
if tournament.license_is_required:
# If license is required but not provided
if not licence_id:
# First player (authentication check) or partner
user_message = "Le numéro de licence est obligatoire." if is_first_player else "Le numéro de licence de votre partenaire est obligatoire."
return False, user_message
# Validate the license format
validator = LicenseValidator(licence_id)
if not validator.validate_license():
if settings.DEBUG:
return False, f"Le numéro de licence est invalide, la lettre ne correspond pas. {validator.get_computed_license_key(validator.stripped_license)}"
else:
return False, "Le numéro de licence est invalide, la lettre ne correspond pas."
# Check if player is already registered in tournament
stripped_license = validator.stripped_license
if self._is_player_already_registered(stripped_license, tournament):
return False, "Un joueur avec ce numéro de licence est déjà inscrit dans une équipe."
# Check if this is the authenticated user trying to register as first player
if self.request.user.is_authenticated and is_first_player and self.request.user.licence_id is None:
# Try to update the user's license ID in the database
try:
self.request.user.licence_id = validator.computed_licence_id
self.request.user.save()
self.request.user.refresh_from_db()
except:
return False, "Erreur lors de la mise à jour de votre licence: cette licence est déjà utilisée par un autre joueur."
# Check for duplicate licenses in cart
existing_licenses = [p.get('licence_id') for p in players if p.get('licence_id')]
if licence_id and licence_id in existing_licenses:
return False, "Ce joueur est déjà dans l'équipe."
return True, None
def remove_player(self):
"""Remove the last player from the cart"""
if self.is_cart_expired():
return False, "Votre session d'inscription a expiré, veuillez réessayer."
players = self.session.get('registration_cart_players', [])
if not players:
return False, "Pas de joueur à supprimer."
# Remove last player
players.pop()
self.session['registration_cart_players'] = players
self.reset_cart_expiry()
self.session.modified = True
return True, "Joueur retiré."
def update_contact_info(self, mobile_number=None):
"""Update contact info for the cart"""
if self.is_cart_expired():
return False, "Votre session d'inscription a expiré, veuillez réessayer."
if mobile_number is not None:
self.session['registration_mobile_number'] = mobile_number
self.reset_cart_expiry()
self.session.modified = True
return True, "Informations de contact mises à jour."
def checkout(self):
"""Convert cart to an actual tournament registration"""
if self.is_cart_expired():
return False, "Votre session d'inscription a expiré, veuillez réessayer."
# Get cart data
cart_data = self.get_cart_data()
tournament_id = cart_data.get('tournament_id')
players = cart_data.get('players')
mobile_number = cart_data.get('mobile_number')
# Validate cart data
if not tournament_id:
return False, "Aucun tournoi sélectionné."
if not players:
return False, "Aucun joueur dans l'inscription."
# Get tournament
try:
tournament = Tournament.objects.get(id=tournament_id)
except Tournament.DoesNotExist:
return False, "Tournoi introuvable."
# Check minimum players
if len(players) < tournament.minimum_player_per_team:
return False, f"Vous avez besoin d'au moins {tournament.minimum_player_per_team} joueurs pour vous inscrire."
# Identify captain from user's license
# # Update user phone if provided
if self.request.user.is_authenticated and mobile_number:
self.request.user.phone = mobile_number
self.request.user.save(update_fields=['phone'])
stripped_license = None
if self.request.user.is_authenticated and self.request.user.licence_id:
validator = LicenseValidator(self.request.user.licence_id)
stripped_license = validator.stripped_license
weight = sum(int(player_data.get('computed_rank', 0) or 0) for player_data in players)
# Create team registration
team_registration = TeamRegistration.objects.create(
tournament=tournament,
registration_date=timezone.now(),
walk_out=False,
weight=weight,
user=self.request.user
)
player_data = {
'first_name': user.first_name,
'last_name': user.last_name.upper(),
'email': user.email,
'phone': user.phone,
'licence_id': validator.computed_licence_id
}
for player_data in players: # Compute rank and sex using the original logic
# Determine if this player is the captain
is_captain = False
player_licence_id = player_data.get('licence_id')
if player_licence_id and stripped_license:
if stripped_license.lower() in player_licence_id.lower():
is_captain = True
# Determine data source
data_source = None
if player_data.get('found_in_french_federation', False) == True:
data_source = PlayerDataSource.FRENCH_FEDERATION # Now using the enum value
User = get_user_model()
matching_user = self.request.user
if player_licence_id and (stripped_license is None or is_captain is False):
try:
# Using icontains for case-insensitive match
matching_user = User.objects.get(licence_id__icontains=player_licence_id)
if matching_user is None:
matching_user = self.request.user
except User.DoesNotExist:
pass
# Create player registration with all the original fields
PlayerRegistration.objects.create(
team_registration=team_registration,
user=matching_user,
captain=is_captain,
source=data_source,
registered_online=True,
first_name=player_data.get('first_name'),
last_name=player_data.get('last_name'),
points=player_data.get('points'),
assimilation=player_data.get('assimilation'),
tournament_played=player_data.get('tournament_count'),
ligue_name=player_data.get('ligue_name'),
club_name=player_data.get('club_name'),
birthdate=player_data.get('birth_year'),
sex=player_data.get('sex'),
rank=player_data.get('rank'),
computed_rank=player_data.get('computed_rank'),
licence_id=player_data.get('licence_id'),
email=matching_user.email if matching_user else player_data.get('email'),
phone_number=matching_user.phone if matching_user else player_data.get('mobile_number'),
registration_status=RegistrationStatus.CONFIRMED if self.session.get('waiting_list_position', 0) < 0 else RegistrationStatus.WAITING
)
data, found = get_player_name_from_csv(self.tournament.federal_category, user.licence_id)
if found and data:
player_data.update({
'rank': data['rank'],
'points': data.get('points'),
'assimilation': data.get('assimilation'),
'tournament_count': data.get('tournament_count'),
'ligue_name': data.get('ligue_name'),
'club_name': data.get('club_name'),
'birth_year': data.get('birth_year'),
'found_in_french_federation': True,
})
return player_data
def _validate_license(self, licence_id):
print("Validating license...")
validator = LicenseValidator(licence_id)
if validator.validate_license() is False and self.tournament.license_is_required:
if not licence_id:
message = ("Le numéro de licence est obligatoire."
if not self.request.session.get('team_registration', [])
else "Le numéro de licence de votre partenaire est obligatoire.")
messages.error(self.request, message)
else:
# computed_license_key = validator.computed_license_key
# messages.error(self.request, f"Le numéro de licence est invalide, la lettre ne correspond pas. {computed_license_key}")
messages.error(self.request, "Le numéro de licence est invalide, la lettre ne correspond pas.")
print("License validation failed")
return False
return True
# Clear the cart
self.clear_cart()
tournament.reserved_spots = max(0, tournament.reserved_spots - 1)
tournament.save()
def _is_duplicate_player(self, licence_id):
existing_players = [player['licence_id'] for player in self.request.session.get('team_registration', [])]
if licence_id in existing_players:
messages.error(self.request, "Ce joueur est déjà dans l'équipe.")
return True
return False
def _is_already_registered(self, licence_id):
validator = LicenseValidator(licence_id)
if (validator.validate_license() and
self._license_already_registered(validator.stripped_license) and
self.tournament.license_is_required):
messages.error(self.request, "Un joueur avec ce numéro de licence est déjà inscrit dans une équipe.")
return True
return False
def _handle_valid_names(self, player_data):
print("_handle_valid_names", player_data)
if player_data.get('rank') is None:
self._set_default_rank(player_data)
self.add_player_to_session(player_data)
self.context['add_player_form'] = AddPlayerForm()
self.context['add_player_form'].first_tournament = False
def _handle_invalid_names(self, licence_id, player_data):
data, found = get_player_name_from_csv(self.tournament.federal_category, licence_id)
print("_handle_invalid_names get_player_name_from_csv", data, found)
if found and data:
self._update_player_data_from_csv(player_data, data)
player_check = self._player_check(player_data)
if player_check == True:
self.add_player_to_session(player_data)
self.context['add_player_form'] = AddPlayerForm()
else:
return
else:
print("_handle_first_tournament_case")
self._handle_first_tournament_case(data)
def _set_default_rank(self, player_data):
if self.request.session.get('last_rank') is None:
data, found = get_player_name_from_csv(self.tournament.federal_category, None)
if data:
self.request.session['last_rank'] = data['rank']
self.request.session['is_woman'] = data['is_woman']
self.request.session.modified = True
player_data['rank'] = self.request.session.get('last_rank', None)
player_data['is_woman'] = self.request.session.get('is_woman', False)
def _update_user_license(self, licence_id):
if not self.request.user.is_authenticated or not licence_id:
return False
return True, team_registration
self.context['add_player_form'].user_without_licence = False
validator = LicenseValidator(licence_id)
def clear_cart(self):
"""Clear the registration cart"""
keys_to_clear = [
'registration_cart_id',
'team_registration_id',
'registration_tournament_id',
'registration_cart_players',
'registration_cart_expiry',
'registration_mobile_number'
]
if validator.validate_license():
computed_licence_id = validator.computed_licence_id
try:
self.request.user.licence_id = computed_licence_id
self.request.user.save()
self.request.user.refresh_from_db()
self.request.session.modified = True
return True
except IntegrityError:
# Handle the duplicate license error
error_msg = f"Ce numéro de licence ({computed_licence_id}) est déjà utilisé par un autre joueur."
messages.error(self.request, error_msg)
return False
def _update_player_data_from_csv(self, player_data, csv_data):
print("_update_player_data_from_csv", player_data, csv_data)
player_data.update({
'first_name': csv_data['first_name'],
'last_name': csv_data['last_name'],
'rank': csv_data['rank'],
'is_woman': csv_data['is_woman'],
'points': csv_data.get('points'),
'assimilation': csv_data.get('assimilation'),
'tournament_count': csv_data.get('tournament_count'),
'ligue_name': csv_data.get('ligue_name'),
'club_name': csv_data.get('club_name'),
'birth_year': csv_data.get('birth_year'),
'found_in_french_federation': True,
'email': None,
'phone': None,
})
User = get_user_model()
# Get the license ID from player_data
licence_id = player_data.get('licence_id')
validator = LicenseValidator(licence_id)
if validator.validate_license():
try:
# Try to find a user with matching license
user_with_same_license = User.objects.get(licence_id__iexact=validator.computed_licence_id)
# If found, update the email and phone
if user_with_same_license:
player_data.update({
'email': user_with_same_license.email,
'phone': user_with_same_license.phone
})
print(f"Found user with license {licence_id}, updated email and phone")
except User.DoesNotExist:
# No user found with this license, continue with None email and phone
pass
def _handle_first_tournament_case(self, data):
print("_handle_first_tournament_case", data)
if data:
self.request.session['last_rank'] = data['rank']
self.request.session['is_woman'] = data['is_woman']
self.request.session.modified = True
self.context['add_player_form'].first_tournament = True
if not self.context['add_player_form'].names_is_valid():
message = ("Pour confirmer votre inscription votre prénom et votre nom sont obligatoires."
if not self.request.session.get('team_registration', [])
else "Pour rajouter un partenaire, son prénom et son nom sont obligatoires.")
messages.error(self.request, message)
def _player_check(self, player_data):
licence_id = player_data['licence_id'].upper()
validator = LicenseValidator(licence_id)
is_license_valid = validator.validate_license()
player_register_check = self.tournament.player_register_check(licence_id)
if is_license_valid and player_register_check is not None:
for message in player_register_check:
messages.error(self.request, message)
return False
for key in keys_to_clear:
if key in self.session:
del self.session[key]
return True
self.session.modified = True
def _license_already_registered(self, stripped_license):
def _is_player_already_registered(self, stripped_license, tournament):
"""Check if a player is already registered in the tournament"""
return PlayerRegistration.objects.filter(
team_registration__tournament=self.tournament,
team_registration__tournament=tournament,
licence_id__icontains=stripped_license,
team_registration__walk_out=False
).exists()
def add_authenticated_user(self):
"""
Adds the authenticated user to the cart if they have a valid license.
Returns True if added, False otherwise.
"""
if not self.request.user.is_authenticated or not self.request.user.licence_id:
return False
# Create player data for the authenticated user
player_data = {
'first_name': self.request.user.first_name,
'last_name': self.request.user.last_name,
'licence_id': self.request.user.licence_id,
'email': self.request.user.email,
'phone': self.request.user.phone
}
# Add the user to the cart
success, _ = self.add_player(player_data)
return success
def _compute_rank_and_sex(self, tournament, player_data):
"""
Compute the player's sex, rank, and computed rank based on tournament category.
This reimplements the original logic from TournamentRegistrationRepository.
"""
is_woman = player_data.get('is_woman', False)
rank = player_data.get('rank', None)
if rank is None:
rank_int = None
computed_rank = 100000
else:
# Ensure rank is an integer for calculations
try:
rank_int = int(rank)
computed_rank = rank_int
except (ValueError, TypeError):
# If rank can't be converted to int, set a default
rank_int = None
computed_rank = 100000
# Use the integer enum values
sex = PlayerSexType.FEMALE if is_woman else PlayerSexType.MALE
# Apply assimilation for women playing in men's tournaments
if is_woman and tournament.federal_category == FederalCategory.MEN and rank_int is not None:
assimilation_addition = FederalCategory.female_in_male_assimilation_addition(rank_int)
computed_rank = computed_rank + assimilation_addition
print(f"_compute_rank_and_sex: {player_data.get('last_name')}, {sex}, {rank}, {computed_rank}")
return sex, rank, str(computed_rank)

@ -1,7 +1,9 @@
from django.contrib import messages
from django.utils import timezone
from ..models import PlayerRegistration, UnregisteredTeam, UnregisteredPlayer
from ..models import PlayerRegistration, UnregisteredTeam, UnregisteredPlayer, PlayerPaymentType
from ..utils.licence_validator import LicenseValidator
from ..services.payment_service import PaymentService
from ..services.email_service import TournamentEmailService
class TournamentUnregistrationService:
def __init__(self, request, tournament):
@ -9,16 +11,17 @@ class TournamentUnregistrationService:
self.tournament = tournament
self.player_registration = None
self.other_player = None
self.team_registration = None
def can_unregister(self):
if not self.tournament.is_unregistration_possible():
messages.error(self.request, "Le désistement n'est plus possible pour ce tournoi. Si vous souhaitez vous désinscrire, veuillez contacter le juge-arbitre.")
return False
if not self.request.user.licence_id:
messages.error(self.request,
"Vous ne pouvez pas vous désinscrire car vous n'avez pas de numéro de licence associé.")
return False
# if not self.request.user.licence_id:
# messages.error(self.request,
# "Vous ne pouvez pas vous désinscrire car vous n'avez pas de numéro de licence associé.")
# return False
return True
@ -28,46 +31,80 @@ class TournamentUnregistrationService:
"La désincription a échouée. Veuillez contacter le juge-arbitre.")
return False
# Check if refund is possible and needed
if self.tournament.is_refund_possible() and self._team_has_paid():
refund_processed, message, refund_details = self._process_refund()
if refund_processed:
# Refund successful, continue with unregistration process
messages.success(self.request, message)
else:
# Refund failed, show error but continue with normal unregistration
messages.error(self.request, message)
# Proceed with unregistration
self._unregister_team()
self._delete_registered_team()
self._cleanup_session()
messages.success(self.request, "Votre désinscription a été effectuée.")
return True
def _team_has_paid(self):
"""Check if team has paid for registration"""
if not self.team_registration:
print("Team registration not found")
return False
# Check if any player registration has a payment ID
player_registrations = PlayerRegistration.objects.filter(team_registration=self.team_registration)
for player_reg in player_registrations:
if player_reg.payment_id and player_reg.payment_type == PlayerPaymentType.CREDIT_CARD:
print("Player has paid")
return True
print("No player has paid")
return False
def _process_refund(self):
"""Process refund for paid registration"""
try:
payment_service = PaymentService(self.request)
return payment_service.process_refund(self.team_registration.id)
except Exception as e:
return False, f"Erreur lors du remboursement: {str(e)}", None
def _unregister_team(self):
# Create unregistered team record
team_registration = self.player_registration.team_registration
unregistered_team = UnregisteredTeam.objects.create(
tournament=team_registration.tournament,
user=team_registration.user,
unregistration_date=timezone.now(),
)
for player in team_registration.player_registrations.all():
for player in team_registration.players_sorted_by_rank:
UnregisteredPlayer.objects.create(
unregistered_team=unregistered_team,
user=player.user,
first_name=player.first_name,
last_name=player.last_name,
licence_id=player.licence_id,
payment_type=player.payment_type,
payment_id=player.payment_id,
registered_online=player.registered_online
)
def _find_player_registration(self):
if not self.request.user.licence_id:
return False
validator = LicenseValidator(self.request.user.licence_id)
is_license_valid = validator.validate_license()
if not is_license_valid:
return False
self.player_registration = PlayerRegistration.objects.filter(
licence_id__icontains=validator.stripped_license,
team_registration__tournament_id=self.tournament.id,
).first()
# First check if we can find the player registration directly by user
if self.request.user.is_authenticated:
self.player_registration = self.tournament.get_user_registered(self.request.user)
if self.player_registration:
team_registration = self.player_registration.team_registration
self.other_player = team_registration.get_other_player(self.player_registration)
return True
if self.player_registration:
self.team_registration = self.player_registration.team_registration
self.other_player = self.team_registration.get_other_player(self.player_registration)
return True
return False
def _delete_registered_team(self):

@ -48,17 +48,22 @@ def notify_object_creation_on_discord(created, instance):
def notify_team(team, tournament, message_type):
#print(team, message_type)
if tournament.enable_online_registration is False:
return
print(team, message_type)
# if tournament.enable_online_registration is False:
# print("returning because tournament.enable_online_registration false")
# return
if team.has_registered_online() is False:
print("returning because team.has_registered_online false")
return
if tournament.should_be_over():
if tournament.has_ended():
print("returning because tournament.has_ended")
return
if tournament.supposedly_in_progress():
if tournament.has_started():
print("returning because tournament.has_started")
return
if is_not_sqlite_backend():
print("is_not_sqlite_backend")
TournamentEmailService.notify_team(team, tournament, message_type)
@receiver(pre_delete, sender=TeamRegistration)
@ -74,6 +79,12 @@ def unregister_team(sender, instance, **kwargs):
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)
@receiver(post_save, sender=Tournament)
@ -105,6 +116,7 @@ def check_waiting_list(sender, instance, **kwargs):
teams_out_to_warn = []
teams_in_to_warn = []
previous_state_teams = previous_state.teams(True)
if previous_state.team_count > instance.team_count:
teams_that_will_be_out = instance.teams(True)[instance.team_count:]
teams_out_to_warn = [
@ -112,16 +124,24 @@ def check_waiting_list(sender, instance, **kwargs):
if team.stage != "Attente"
]
elif previous_state.team_count < instance.team_count:
teams_that_will_be_in = previous_state.teams(True)[previous_state.team_count:instance.team_count]
teams_that_will_be_in = previous_state_teams[previous_state.team_count:instance.team_count]
teams_in_to_warn = [
team for team in teams_that_will_be_in
if team.stage == "Attente"
]
waiting_list_teams = previous_state.waiting_list_teams(previous_state_teams)
automatic_waiting_list = instance.automatic_waiting_list()
ttc = None
for team in teams_in_to_warn:
if automatic_waiting_list:
if waiting_list_teams is not None:
ttc = previous_state.calculate_time_to_confirm(len(waiting_list_teams))
team.team_registration.set_time_to_confirm(ttc)
notify_team(team.team_registration, instance, TeamEmailType.IN_TOURNAMENT_STRUCTURE)
for team in teams_out_to_warn:
team.team_registration.cancel_time_to_confirm()
notify_team(team.team_registration, instance, TeamEmailType.OUT_OF_TOURNAMENT_STRUCTURE)
@receiver(pre_save, sender=TeamRegistration)
@ -129,12 +149,15 @@ def warn_team_walkout_status_change(sender, instance, **kwargs):
if instance.id is None or instance.tournament is None or instance.tournament.enable_online_registration is False:
return
print("warn_team_walkout_status_change", instance)
previous_instance = None
try:
previous_instance = TeamRegistration.objects.get(id=instance.id)
except TeamRegistration.DoesNotExist:
print("TeamRegistration.DoesNotExist")
return
ttc = None
previous_teams = instance.tournament.teams(True)
current_teams = instance.tournament.teams(True, instance)
previous_retrieved_teams = [team for team in previous_teams if team.team_registration.id == previous_instance.id]
@ -149,17 +172,37 @@ def warn_team_walkout_status_change(sender, instance, **kwargs):
print(was_out, previous_instance.out_of_tournament(), is_out, instance.out_of_tournament())
if not instance.out_of_tournament() and is_out and (previous_instance.out_of_tournament() or not was_out):
instance.cancel_time_to_confirm()
notify_team(instance, instance.tournament, TeamEmailType.OUT_OF_WALKOUT_WAITING_LIST)
elif was_out and not is_out:
if instance.tournament.automatic_waiting_list():
waiting_list_teams = instance.tournament.waiting_list_teams(current_teams)
if waiting_list_teams is not None:
ttc = instance.tournament.calculate_time_to_confirm(len(waiting_list_teams))
instance.set_time_to_confirm(ttc)
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)
if was_out and not is_out:
first_out_of_list = instance.tournament.first_waiting_list_team(current_teams)
if first_out_of_list:
first_out_of_list.cancel_time_to_confirm()
notify_team(first_out_of_list, instance.tournament, TeamEmailType.UNEXPECTED_OUT_OF_TOURNAMENT)
elif not was_out and is_out:
first_waiting_list_team = instance.tournament.first_waiting_list_team(previous_teams)
if first_waiting_list_team:
if instance.tournament.automatic_waiting_list():
waiting_list_teams = instance.tournament.waiting_list_teams(current_teams)
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=TeamRegistration)
def team_confirm_if_placed(sender, instance, **kwargs):
if instance.id is None or instance.tournament is None:
return
instance.confirm_if_placed()

@ -162,7 +162,7 @@ tr {
.rounded-button {
background-color: #fae7ce; /* Green background */
color: #505050; /* White text */
padding: 15px 32px; /* Some padding */
padding: 12px 24px; /* Some padding */
font-size: 1em;
font-weight: 800;
cursor: pointer; /* Add a mouse pointer on hover */
@ -547,12 +547,12 @@ h-margin {
}
.left-label {
align-self: flex-start;
/* align-self: flex-start; */
/* Aligns the left label to the top */
}
.right-label {
align-self: flex-end;
/* align-self: flex-end; */
/* Aligns the right label to the bottom */
}
@ -1062,32 +1062,32 @@ h-margin {
text-decoration: line-through;
}
.status-container {
margin: 0 -20px -20px -20px; /* Negative margin to counter the bubble padding, including bottom */
padding: 10px 20px 20px 20px; /* Add padding back to maintain text alignment, including bottom */
border-radius: 0 0 24px 24px; /* Match the bubble's bottom corners */
}
.status-container-bracket {
margin: 0px -20px -20px -20px; /* Negative margin to counter the bubble padding, including bottom */
padding: 10px 20px 10px 20px; /* Add padding back to maintain text alignment, including bottom */
border-radius: 0 0 24px 24px; /* Match the bubble's bottom corners */
.match-status-container {
align-items: center;
display: flex;
height: 100%;
margin-left: -20px;
margin-right: -20px;
padding-left: 20px;
padding-right: 20px;
}
.status-container-bracket-header {
height: 24px;
.match-status-container-header {
margin-top: -20px;
height: 40px;
text-align: left;
}
.status-container-bracket-header-bottom {
height: 24px;
.match-status-container-header-bottom {
margin-bottom: -20px;
height: 40px;
text-align: left;
}
.status-container-bracket.running,
.match-status-container.running,
.status-container.running {
background-color: #90ee90; /* Light green color */
background-color: #90ee90;
border-radius: 0 0 24px 24px;
}
.overlay-text {
@ -1105,3 +1105,12 @@ h-margin {
.odd-row {
background-color: #e6f2ff; /* Light blue */
}
.player-flex-row {
display: flex;
justify-content: space-between;
height: 24px;
align-items: center;
gap: 8px;
margin-right: 8px;
}

@ -44,7 +44,7 @@
text-align: center;
font-weight: bold;
width: 100%; /* Change from 100% to auto */
padding: 5px 10px;
padding: 0px 10px;
white-space: nowrap; /* Prevent text from wrapping */
display: flex;
@ -54,9 +54,18 @@
}
.round-title.broadcast-mode {
font-size: 0.9em;
width: auto; /* Change from 100% to auto */
}
.match-result.broadcast-mode {
padding: 4px;
}
.score.broadcast-mode {
font-size: 1em;
}
.round-name {
color: #505050;
font-size: 1.5em;
@ -242,3 +251,7 @@
background-color: #90ee90; /* Light green color */
border-radius: 0 0 24px 24px; /* Match the bubble's bottom corners */
}
.match-result.broadcast-mode {
padding: 0px;
}

@ -34,7 +34,6 @@ function renderBracket(options) {
let nextMatchDistance = baseDistance;
let minimumMatchDistance = 1;
const totalRounds = document.querySelectorAll(".butterfly-round").length;
const screenWidth = window.innerWidth;
let roundTotalCount = roundCount;
if (doubleButterflyMode == true && roundCount > 1) {
@ -74,6 +73,7 @@ function renderBracket(options) {
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
let nameSpan = document.createElement("div");
nameSpan.className = "round-name";
@ -105,7 +105,7 @@ function renderBracket(options) {
// Create matches container
const matchesContainer = document.createElement("div");
matchesContainer.className = "matches-container";
if (roundCount > 5 && doubleButterflyMode == true) {
if (doubleButterflyMode == true && roundCount > 3) {
if (roundIndex >= finalRoundIndex - 1) {
matchesContainer.style.transform = `translateX(-50%)`;
if (roundIndex >= finalRoundIndex + 2) {
@ -146,15 +146,19 @@ function renderBracket(options) {
}
if (roundIndex === 0) {
nextMatchDistance = 0;
if (doubleButterflyMode == false) {
nextMatchDistance = 0;
} else {
if (realRoundIndex > 1) {
nextMatchDistance = 0;
}
}
if (roundCount > 1) {
const nextMatchesCount = rounds[roundIndex + 1].length;
if (currentMatchesCount == nextMatchesCount && roundCount > 2) {
nextMatchDistance = 0;
}
} else {
nextMatchDistance = 0;
}
top = matchIndex * (matchHeight + matchSpacing) * minimumMatchDistance;
@ -163,8 +167,13 @@ function renderBracket(options) {
top = top + (matchHeight + matchSpacing) / 2;
}
} else if (roundIndex === roundCount - 1 && doubleButterflyMode == true) {
nextMatchDistance = 0;
} else if (roundIndex == finalRoundIndex) {
if (roundCount > 3) {
nextMatchDistance = 0;
} else {
nextMatchDistance = nextMatchDistance / 2;
}
} else if (roundIndex == finalRoundIndex && realRoundIndex == 0) {
//realRoundIndex 0 means final's round
const values = Object.values(matchPositions[roundIndex - 1]);
const parentPos1 = values[0];
const parentPos2 = values[1];
@ -199,7 +208,10 @@ function renderBracket(options) {
}
}
}
} else if (roundIndex < finalRoundIndex) {
} else if (
(roundIndex == finalRoundIndex && realRoundIndex != 0) ||
roundIndex < finalRoundIndex
) {
const parentIndex1 = matchRealIndex * 2 + 1;
const parentIndex2 = matchRealIndex * 2 + 2;
const parentPos1 = matchPositions[roundIndex - 1][parentIndex1];
@ -287,7 +299,7 @@ function renderBracket(options) {
}
}
if (roundCount > 5 && doubleButterflyMode == true) {
if (doubleButterflyMode == true) {
if (roundIndex >= finalRoundIndex - 2) {
if (roundIndex == finalRoundIndex - 1) {
matchDiv.classList.add("reverse-bracket");
@ -327,12 +339,11 @@ function renderBracket(options) {
// }
// Position title above the first match
titleDiv.style.top = `${-80}px`; // Adjust the 60px offset as needed
if (
roundIndex < finalRoundIndex - 1 ||
roundIndex > finalRoundIndex + 1
(roundIndex == finalRoundIndex && realRoundIndex == 0) ||
isBroadcast == true
) {
titleDiv.style.top = `${-80}px`; // Adjust the 60px offset as needed
} else {
titleDiv.style.top = `${top - 80}px`; // Adjust the 60px offset as needed
}
titleDiv.style.position = "absolute";
@ -348,6 +359,7 @@ function renderBracket(options) {
if (
roundIndex == finalRoundIndex &&
realRoundIndex == 0 &&
matchIndex === 1 &&
matchDisabled[roundIndex][matchRealIndex] == false &&
displayLoserFinal == true &&
@ -374,7 +386,7 @@ function renderBracket(options) {
<div class="outgoing-line ${isOutgoingLineIsDisabled ? "disabled" : ""}"></div>
`;
if (roundCount > 5 && doubleButterflyMode == true) {
if (doubleButterflyMode == true) {
if (roundIndex >= finalRoundIndex - 2) {
if (roundIndex === finalRoundIndex - 2) {
if (matchIndex === 0) {
@ -423,4 +435,65 @@ function renderBracket(options) {
bracket.appendChild(roundDiv);
});
if (isBroadcast == false || isBroadcast == undefined) {
setTimeout(() => {
const roundDivs = document.querySelectorAll(".butterfly-round");
// First, find the maximum bottom position across all rounds
let globalMaxBottom = 0;
roundDivs.forEach((roundDiv) => {
const matches = roundDiv.querySelectorAll(".butterfly-match");
matches.forEach((match) => {
const bottom = match.offsetTop + match.offsetHeight;
if (bottom > globalMaxBottom) {
globalMaxBottom = bottom;
}
});
});
// Now create and position footers for all rounds at the same y-position
roundDivs.forEach((roundDiv, index) => {
// Get the match templates from this round to extract data
const roundMatches = rounds[index] || [];
if (roundMatches.length > 0) {
const firstMatchTemplate = roundMatches[0].closest(".match-template");
const roundId = firstMatchTemplate.dataset.roundId;
const realRoundIndex = firstMatchTemplate.dataset.roundIndex;
if (realRoundIndex > 1) {
// Create footer div
const footerDiv = document.createElement("div");
footerDiv.className = "round-footer";
footerDiv.style.width = `${responsiveMatchWidth}px`;
footerDiv.style.paddingBottom = "40px";
footerDiv.style.textAlign = "center";
// Create footer content
let linkSpan = document.createElement("a");
linkSpan.className = "small styled-link";
linkSpan.textContent = "accès au tableau de classement";
if (roundId) {
linkSpan.href = `/tournament/${tournamentId}/round/${roundId}/bracket/`;
linkSpan.style.cursor = "pointer";
}
footerDiv.appendChild(linkSpan);
// 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.width = "100%";
footerContainer.appendChild(footerDiv);
// Add to the round div
const matchesContainer =
roundDiv.querySelector(".matches-container");
matchesContainer.appendChild(footerContainer);
}
}
});
}, 100);
}
}

@ -0,0 +1,34 @@
from background_task import background
from django.utils import timezone
from django.db import transaction
from django.conf import settings
from .models import Tournament
from .models.enums import RegistrationStatus
@background(schedule=settings.BACKGROUND_SCHEDULED_TASK_INTERVAL * 60) # Run every 30 minutes (30*60 seconds)
def background_task_check_confirmation_deadlines():
#DEBUG ONLY NOT NEEDED ON PROD
print("background_task Running confirmation deadline check...")
check_confirmation_deadlines()
def check_confirmation_deadlines():
"""
Periodic task to check for expired confirmation deadlines
and notify the next team in the waiting list.
"""
now = timezone.now()
print(f"[{now}] Running confirmation deadline check...")
# Get all tournaments with online registrations
tournaments = Tournament.objects.filter(
team_registrations__player_registrations__registration_status=RegistrationStatus.PENDING,
team_registrations__player_registrations__registered_online=True
).distinct()
total_processed = 0
for tournament in tournaments:
processed = tournament.check_all_confirmation_deadlines()
total_processed += processed
print(f"Processed confirmation deadlines for {total_processed} teams")

@ -13,171 +13,293 @@
</nav>
<div class="grid-x">
<div class="cell medium-6 large-6 padding10">
<h1 class="club padding10 topmargin20">Inscription : {{ tournament.display_name }} {{ tournament.get_federal_age_category_display}}</h1 >
<h1 class="club padding10">Inscription : {{ tournament.display_name }} {{ tournament.get_federal_age_category_display}}</h1 >
<div class="bubble">
{% if registration_successful %}
<p>Merci, l'inscription a bien été envoyée au juge-arbitre.</p>
<p style="text-align: justify;">
Un email de confirmation a été envoyé à l'adresse associée à votre compte Padel Club ({{ user.email }}). Pensez à vérifier vos spams si vous ne recevez pas l'email. En cas de problème, contactez le juge-arbitre.
</p>
{% else %}
{% if team_form.errors %}
<div>
{% for field in team_form %}
{% if field.errors %}
{% for error in field.errors %}
<div class="alert">{{ field.label }} : {{ error }}</div>
{% endfor %}
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if messages %}
<div class="messages">
{% for message in messages %}
<div class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
<form method="post">
{% csrf_token %}
<!-- Team Registration Form -->
<div>
<p>
<div class="semibold">
Informations de contact
</div>
</p>
{% if team_form.non_field_errors %}
{% for error in team_form.non_field_errors %}
<p>{{ error }}</p>
{% endfor %}
{% endif %}
{{ team_form.as_p }} <!-- Render team registration form fields here -->
</div>
<!-- Show players added to the team only if there are players added -->
{% if current_players %}
<p>
<div class="semibold">
Constitution de votre équipe
</div>
</p>
<ul>
{% for player in current_players %}
<li>
<div>
{{ player.first_name }} {{ player.last_name }}{% if player.licence_id %} ({{ player.licence_id }}){% endif %}
</div>
<div>
{{ player.club_name }}
</div>
<div>
Classement à ce jour : {{ player.rank }}
</div>
{% if not forloop.first %} <!-- Only show remove button if not the first player -->
<div>
<button type="submit" name="remove_player" class="btn small-button">
modifier
</button>
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
<!-- Add Player Form -->
{% if current_players|length < 2 %}
<div>
{% if current_players|length == 1 %}
<div class="semibold">
Inscrivez votre partenaire
</div>
{% endif %}
{% if current_players|length == 0 and add_player_form.user_without_licence and tournament.license_is_required %}
<div class="semibold">
Une licence est obligatoire pour vous inscrire :
</div>
{% endif %}
{% if tournament.license_is_required %}
{{ add_player_form.licence_id.label_tag }}
{{ add_player_form.licence_id }}
{% 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 %}
<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>
{% endif %}
{{ add_player_form.first_name.label_tag }}
{{ add_player_form.first_name }}
{{ add_player_form.last_name.label_tag }}
{{ add_player_form.last_name }}
{% if tournament.license_is_required is False %}
{{ add_player_form.licence_id.label_tag }}
{% if tournament.license_is_required is False %}(facultatif){% endif %}
{{ add_player_form.licence_id }}
<div class="semibold">{{ tournament.local_start_date_formatted }}</div>
<div class="semibold">{{ tournament.event.club.name }}</div>
{% if tournament.has_club_address %}
<div>{{ tournament.event.club.address }}</div>
<div>{{ tournament.event.club.city_zipcode }}</div>
{% endif %}
{% endif %}
<button type="submit" name="add_player" class="rounded-button">
{% if add_player_form.user_without_licence %}
Confirmer
{% else %}
{% if current_players|length == 0 %}
Confirmer
{% else %}
Ajouter un partenaire
{% endif %}
<hr/>
{% if registration_successful %}
<p class="topblock">Merci, l'inscription a bien été envoyée au juge-arbitre.</p>
{% if registration_paid %}
<p><strong>✅ Votre paiement a bien été effectué et enregistré.</strong></p>
{% endif %}
</button>
</div>
{% endif %}
<!-- Show players added to the team only if there are players added -->
{% if current_players|length >= tournament.minimum_player_per_team %}
<div class="margin10">
</div>
<div class="semibold margin10">
{% if tournament.get_waiting_list_position == 1 %}
Tournoi complet, {{ tournament.get_waiting_list_position }} équipe en liste d'attente actuellement.
{% elif tournament.get_waiting_list_position > 1 %}
Tournoi complet, {{ tournament.get_waiting_list_position }} équipes en liste d'attente actuellement.
{% elif tournament.get_waiting_list_position == 0 %}
Tournoi complet, vous seriez la première équipe en liste d'attente.
{% endif %}
</div>
<div>
<button type="submit" name="register_team" class="rounded-button">
{% if tournament.get_waiting_list_position < 0 %}
Confirmer l'inscription
<p style="text-align: justify;">
Un email de confirmation a été envoyé à l'adresse associée à votre compte Padel Club ({{ user.email }}). Pensez à vérifier vos spams si vous ne recevez pas l'email. En cas de problème, contactez le juge-arbitre.
</p>
{% else %}
Se mettre en liste d'attente
{% if not registration_successful %}
<div class="info-box topblock">
<p>Votre session d'inscription est active. Complétez le formulaire dans le délai accordé pour confirmer votre participation et garantir votre place.</p>
{% if not cart_data.is_cart_expired %}
<p class="semibold highlight">Votre session d'inscription expirera le {{ cart_data.expiry|date:"d/m/Y à H:i" }}</p>
<p>Temps restant: <span id="countdown" data-expiry="{{ cart_data.expiry|date:'Y-m-d H:i:s' }}">{{ cart_data.expiry|timeuntil }}</span></p>
{% else %}
<p class="alert alert-danger">
Votre session d'inscription a expiré. Veuillez recommencer le processus d'inscription. Votre place n'est plus garantie.
</p>
{% endif %}
</div>
{% endif %}
{% if team_form.errors %}
<div>
{% for field in team_form %}
{% if field.errors %}
{% for error in field.errors %}
<div class="alert">{{ field.label }} : {{ error }}</div>
{% endfor %}
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if messages %}
<div class="messages">
{% for message in messages %}
<div class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
<form method="post">
{% csrf_token %}
<!-- Team Registration Form -->
<div>
<p>
<div class="semibold">
Informations de contact
</div>
</p>
{% if team_form.non_field_errors %}
{% for error in team_form.non_field_errors %}
<p>{{ error }}</p>
{% endfor %}
{% endif %}
{{ team_form.as_p }} <!-- Render team registration form fields here -->
</div>
<!-- Show players added to the team only if there are players added -->
{% if current_players %}
<p>
<div class="semibold">
Constitution de votre équipe
</div>
</p>
<ul>
{% for player in current_players %}
<li>
<div>
{{ player.first_name }} {{ player.last_name }}{% if player.licence_id %} ({{ player.licence_id }}){% endif %}
</div>
<div>
{{ player.club_name }}
</div>
<div>
Classement à ce jour : {% if player.rank %}{{ player.rank }}{% if player.computed_rank and player.rank != player.computed_rank %} ({{ player.computed_rank }}){% endif %}{% else %}Non classé ({{ player.computed_rank }}){% endif %}
</div>
{% if not forloop.first %}
<div>
<button type="submit" name="remove_player" class="btn small-button">
modifier
</button>
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
<!-- Add Player Form -->
{% if current_players|length < 2 %}
<div>
{% if current_players|length == 1 %}
<div class="semibold">
Inscrivez votre partenaire
</div>
{% endif %}
{% if current_players|length == 0 and add_player_form.user_without_licence and tournament.license_is_required %}
<div class="semibold">
Une licence est obligatoire pour vous inscrire :
</div>
{% endif %}
{% if tournament.license_is_required %}
{{ add_player_form.licence_id.label_tag }}
{{ add_player_form.licence_id }}
{% 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 %}
<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>
{% endif %}
{% if not add_player_form.user_without_licence %}
{{ add_player_form.first_name.label_tag }}
{{ add_player_form.first_name }}
{{ add_player_form.last_name.label_tag }}
{{ add_player_form.last_name }}
{% endif %}
{% if tournament.license_is_required is False %}
{{ add_player_form.licence_id.label_tag }}
{% if tournament.license_is_required is False %}(facultatif){% endif %}
{{ add_player_form.licence_id }}
{% endif %}
{% endif %}
<button type="submit" name="add_player" class="rounded-button">
{% if add_player_form.user_without_licence %}
Confirmer
{% else %}
{% if current_players|length == 0 %}
Confirmer
{% else %}
Ajouter un partenaire
{% endif %}
{% endif %}
</button>
</div>
{% endif %}
<!-- Show players added to the team only if there are players added -->
{% if current_players|length >= tournament.minimum_player_per_team %}
<div class="margin10">
</div>
<div class="semibold margin10">
{% if cart_data.waiting_list_position == 1 %}
Tournoi complet, {{ cart_data.waiting_list_position }} équipe en liste d'attente actuellement.
{% elif cart_data.waiting_list_position > 1 %}
Tournoi complet, {{ cart_data.waiting_list_position }} équipes en liste d'attente actuellement.
{% elif cart_data.waiting_list_position == 0 %}
Tournoi complet, vous seriez la première équipe en liste d'attente.
{% endif %}
</div>
<div>
{% if tournament.should_request_payment and cart_data.waiting_list_position < 0 %}
<div class="semibold">
Confirmer votre inscription en payant immédiatement :
</div>
<button type="submit" name="proceed_to_payment" class="rounded-button">
Procéder au paiement ({{ tournament.team_fee }}€)
</button>
{% endif %}
{% if tournament.should_request_payment is False or tournament.online_payment_is_mandatory is False or cart_data.waiting_list_position >= 0 %}
{% if tournament.should_request_payment and cart_data.waiting_list_position < 0 %}
<div class="semibold topmargin20">
Ou confirmer votre inscription et payer sur place le jour du tournoi :
</div>
{% endif %}
<button type="submit" name="register_team" class="rounded-button">
{% if cart_data.waiting_list_position < 0 %}
Confirmer l'inscription
{% else %}
Se mettre en liste d'attente
{% endif %}
</button>
{% endif %}
</div>
{% endif %}
</form>
{% endif %}
</button>
</div>
{% endif %}
</form>
{% endif %}
</div>
</div>
</div>
</div>
<script>
// Safe countdown script with no automatic reloads
document.addEventListener('DOMContentLoaded', function() {
// Get the countdown element
const countdownElement = document.getElementById('countdown');
if (!countdownElement) return;
// Only proceed with countdown if cart is not expired according to backend
const cartExpiredDiv = document.querySelector('.alert-danger');
if (cartExpiredDiv && cartExpiredDiv.textContent.includes('expiré')) {
// Cart is already expired according to backend, don't set up countdown
return;
}
// Get the expiry date from the data attribute
const expiryDateStr = countdownElement.getAttribute('data-expiry');
if (!expiryDateStr) return;
// Parse the expiry date properly (keeping local time interpretation)
// Format received: "YYYY-MM-DD HH:MM:SS"
const [datePart, timePart] = expiryDateStr.split(' ');
const [year, month, day] = datePart.split('-').map(Number);
const [hours, minutes, seconds] = timePart.split(':').map(Number);
// Create date object using local time components (month is 0-indexed in JS)
const expiryDate = new Date(year, month-1, day, hours, minutes, seconds);
// Function to update countdown text
function updateCountdown() {
const now = new Date();
let timeRemaining = Math.max(0, Math.floor((expiryDate - now) / 1000)); // in seconds
if (timeRemaining <= 0) {
countdownElement.textContent = "Expiré";
// Set a flag in localStorage to prevent infinite reload
if (!localStorage.getItem('cartExpired')) {
localStorage.setItem('cartExpired', 'true');
// Reload once when expired
window.location.reload();
}
return;
}
// Calculate days, hours, minutes, seconds
const days = Math.floor(timeRemaining / 86400);
timeRemaining %= 86400;
const hours = Math.floor(timeRemaining / 3600);
timeRemaining %= 3600;
const minutes = Math.floor(timeRemaining / 60);
const seconds = timeRemaining % 60;
// Format the countdown text - ALWAYS include seconds
let countdownText = '';
if (days > 0) {
countdownText += `${days} jour${days > 1 ? 's' : ''}, `;
}
if (hours > 0 || days > 0) {
countdownText += `${hours} heure${hours > 1 ? 's' : ''}, `;
}
if (minutes > 0 || hours > 0 || days > 0) {
countdownText += `${minutes} minute${minutes > 1 ? 's' : ''}, `;
}
countdownText += `${seconds} seconde${seconds > 1 ? 's' : ''}`;
countdownElement.textContent = countdownText;
}
// Clear previous expiry flag when starting a new countdown
localStorage.removeItem('cartExpired');
// Update immediately
updateCountdown();
// Update every second
const countdownInterval = setInterval(updateCountdown, 1000);
// Clean up interval when page unloads
window.addEventListener('unload', function() {
clearInterval(countdownInterval);
});
});
</script>
{% endblock %}

@ -11,65 +11,46 @@
{% load tz %}
<div class="grid-x">
<div class="cell medium-12 large-6 topblock padding10">
<div>
{% if running_tournaments %}
<div class="cell medium-12 large-6 topblock padding10">
<div class="table-row-5-colums-tournament header">
<label class="title">Vos tournois en cours</label>
</div>
{% if running_tournaments %}
{% for tournament in running_tournaments %}
{% include 'tournaments/tournament_row.html' %}
{% endfor %}
{% else %}
<div>
Aucun tournoi en cours
</div>
{% endif %}
{% for tournament in running_tournaments %}
{% include 'tournaments/tournament_row.html' %}
{% endfor %}
</div>
<div>
{% endif %}
{% if upcoming_tournaments %}
<div class="cell medium-12 large-6 topblock padding10">
<div class="table-row-5-colums-tournament header">
<label class="title">Vos tournois à venir</label>
</div>
{% if upcoming_tournaments %}
{% for tournament in upcoming_tournaments %}
{% include 'tournaments/tournament_row.html' %}
{% endfor %}
{% else %}
<div>
Aucun tournoi à venir
</div>
{% endif %}
{% for tournament in upcoming_tournaments %}
{% include 'tournaments/tournament_row.html' %}
{% endfor %}
</div>
{% endif %}
</div>
<div class="cell medium-12 large-6 topblock padding10">
<div>
{% if ended_tournaments %}
<div class="cell medium-12 large-6 topblock padding10">
<div class="table-row-5-colums-tournament header">
<label class="title">Vos tournois terminés</label>
</div>
{% if ended_tournaments %}
{% for tournament in ended_tournaments %}
{% include 'tournaments/tournament_row.html' %}
{% endfor %}
<div class="table-row-5-colums-tournament footer">
{% if ended_tournaments|length >= 12 %}
<div class="small">
<a href="{% url 'all-my-ended-tournaments' %}">voir tous vos tournois terminés</a>
</div>
{% endif %}
{% for tournament in ended_tournaments %}
{% include 'tournaments/tournament_row.html' %}
{% endfor %}
<div class="table-row-5-colums-tournament footer">
{% if ended_tournaments|length >= 12 %}
<div class="small">
<a href="{% url 'all-my-ended-tournaments' %}">voir tous vos tournois terminés</a>
</div>
{% else %}
<div>
Aucun tournoi terminé
</div>
{% endif %}
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}

@ -1,9 +1,9 @@
{% load static %}
<div class="cell medium-12 large-3 padding10">
<div class="cell medium-12 large-3">
<div class="bubble">
<div class="status-container-bracket-header flex-row">
<div class="match-status-container-header flex-row">
{% if match.bracket_name %}
<label class="minor-info bold">{{ match.bracket_name }}</label>
{% endif %}
@ -17,28 +17,31 @@
{% for team in match.teams %}
<div class="match-result {% cycle 'bottom-border' '' %}">
<div class="player {% if team.names|length == 1 %}single-player{% else %}two-players{% endif %}">
{% if team.id %}
<a href="{% url 'team-details' tournament.id team.id %}" class="player-link">
{% endif %}
{% if team.is_lucky_loser or team.walk_out == 1 %}
<div class="overlay-text">
{% if team.is_lucky_loser %}(LL){% elif team.walk_out == 1 %}(WO){% endif %}
</div>
{% endif %}
<div class="player-flex-row">
{% if team.id %}
<a href="{% url 'team-details' tournament.id team.id %}" class="player-link">
{% endif %}
<div>
{% for name in team.names %}
<div class="semibold{% if team.walk_out == 1 %} strikethrough{% endif %}{% if team.is_winner %} winner{% endif %}">
{% if name|length > 0 %}
{{ name }}
{% else %}
&nbsp;
{% endif %}
</div>
{% endfor %}
</div>
{% if team.id and team.weight %}
</a>
{% endif %}
{% for name in team.names %}
<div class="semibold{% if team.walk_out == 1 %} strikethrough{% endif %}{% if team.is_winner %} winner{% endif %}">
{% if name|length > 0 %}
{{ name }}
{% else %}
&nbsp;
{% if team.is_lucky_loser or team.walk_out == 1 %}
<div class="minor-info bold" style="height: 100%; padding-right: 20px;">
{% if match.should_show_lucky_loser_status and team.is_lucky_loser %}(LL){% endif %}
</div>
{% endif %}
</div>
{% endfor %}
{% if team.id %}
</a>
{% endif %}
</div>
{% if match.has_walk_out %}
@ -64,8 +67,8 @@
{% endfor %}
</div>
<div class="status-container-bracket-header-bottom">
<div class="status-container-bracket {% if not match.ended and match.started %}running{% endif %}">
<div class="match-status-container-header-bottom">
<div class="match-status-container {% if not match.ended and match.started %}running{% endif %}">
<label class="left-label minor-info bold">
{% if match.show_time_indication %}
{{ match.time_indication }}

@ -67,10 +67,8 @@
// Generate HTML for each team
match.teams.forEach((team, teamIndex) => {
html += `
<div class="match-result ${teamIndex === 0 ? 'bottom-border' : ''}">
<div class="match-result broadcast-mode ${teamIndex === 0 ? 'bottom-border' : ''}">
<div class="player ${team.names.length === 1 ? 'single-player' : 'two-players'}">
${team.is_lucky_loser ? '<div class="overlay-text">(LL)</div>' : ''}
${team.walk_out === 1 ? '<div class="overlay-text">(WO)</div>' : ''}
${team.names.map(name => `
<div class="semibold ${team.walk_out === 1 ? 'strikethrough' : ''} ${team.is_winner ? 'winner' : ''}">
@ -95,7 +93,7 @@
` : ''}
${team.weight && !match.has_walk_out && (!team.scores || team.scores.length === 0) ? `
<span class="score ws numbers">${team.weight}</span>
<span class="score ws numbers broadcast-mode">${team.weight}</span>
` : ''}
</div>
`;
@ -127,6 +125,7 @@
template.dataset.matchTitle = match.title;
template.dataset.roundId = group.round_id;
template.dataset.matchRealIndex = match.index;
template.dataset.roundIndex = group.round_index;
// Create the match content using our HTML generator
template.innerHTML = `<div class="bubble broadcast-bracket-match ${(!match.ended && match.started) ? 'match-running' : ''}">${createMatchHTML(match)}</div>`;

@ -1,6 +1,6 @@
<div class="bubble">
<div class="flex-row">
<div class="match-status-container-header flex-row">
<label class="left-label matchtitle">
<span x-text="match.group_stage_name"></span>
<span x-show="match.group_stage_name"> :</span>
@ -20,23 +20,22 @@
'single-player': match.teams[i-1].names.length === 1,
'two-players': match.teams[i-1].names.length === 2
}">
<!-- Show lucky loser or walkout status -->
<template x-if="match.teams[i-1].is_lucky_loser">
<div class="overlay-text right-label minor-info semibold">(LL)</div>
</template>
<template x-if="match.teams[i-1].walk_out === 1">
<div class="overlay-text right-label minor-info semibold">(WO)</div>
</template>
<template x-for="name in match.teams[i-1].names">
<div :class="{
'bold': true,
'strikethrough': match.teams[i-1].walk_out === 1,
'winner': match.teams[i-1].is_winner
}">
<span x-text="name === '' ? ' ' : name"></span>
</div>
</template>
<div class="player-flex-row">
<div>
<template x-for="name in match.teams[i-1].names">
<div :class="{
'bold': true,
'strikethrough': match.teams[i-1].walk_out === 1,
'winner': match.teams[i-1].is_winner
}">
<span x-text="name === '' ? ' ' : name"></span>
</div>
</template>
</div>
<template x-if="match.should_show_lucky_loser_status && match.teams[i-1].is_lucky_loser">
<div class="minor-info semibold">(LL)</div>
</template>
</div>
</div>
<div class="scores">
<template x-for="score in match.teams[i-1].scores">
@ -82,11 +81,11 @@
</div>
</template>
<div class="status-container" :class="{'running': !match.ended && match.started}">
<div class="top-margin flex-row">
<label class="left-label minor-info bold"><span x-text="match.time_indication"></span></label>
<label class="right-label minor-info semibold"><span x-text="match.format"></span></label>
</div>
<div class="match-status-container-header-bottom">
<div class="match-status-container flex-row" :class="{'running': !match.ended && match.started}">
<label class="left-label minor-info bold"><span x-text="match.time_indication"></span></label>
<label class="right-label minor-info semibold"><span x-text="match.format"></span></label>
</div>
</div>
</div>

@ -3,7 +3,7 @@
<div class="cell medium-12 large-3 padding10">
<div class="bubble">
<div class="flex-row">
<div class="match-status-container-header flex-row">
<label class="matchtitle">{{ match.title }}</label>
{% if not match.ended %}
<label class="right-label minor-info bold">{{ match.court }}</label>
@ -15,24 +15,29 @@
{% for team in match.teams %}
<div class="match-result {% cycle 'bottom-border' '' %}">
<div class="player {% if team.names|length == 1 %}single-player{% else %}two-players{% endif %}">
{% if team.id and team.weight %}
<a href="{% url 'team-details' tournament.id team.id %}" class="player-link">
{% endif %}
{% if team.is_lucky_loser or team.walk_out == 1 %}
<div class="overlay-text">
{% if team.is_lucky_loser %}(LL){% elif team.walk_out == 1 %}(WO){% endif %}
</div>
{% endif %}
<div class="player-flex-row">
{% if team.id and team.weight %}
<a href="{% url 'team-details' tournament.id team.id %}" class="player-link">
{% endif %}
<div>
{% for name in team.names %}
<div class="semibold{% if team.walk_out == 1 %} strikethrough{% endif %}{% if team.is_winner %} winner{% endif %}">
{{ name }}
</div>
{% endfor %}
</div>
{% if team.id and team.weight %}
</a>
{% endif %}
{% for name in team.names %}
<div class="semibold{% if team.walk_out == 1 %} strikethrough{% endif %}{% if team.is_winner %} winner{% endif %}">
{{ name }}
{% if team.is_lucky_loser or team.walk_out == 1 %}
<div class="minor-info bold" style="height: 100%; padding-right: 20px;">
{% if match.should_show_lucky_loser_status and team.is_lucky_loser %}(LL){% endif %}
</div>
{% endif %}
</div>
{% endfor %}
{% if team.id and team.weight %}
</a>
{% endif %}
</div>
{% if match.has_walk_out %}
@ -58,8 +63,9 @@
{% endfor %}
</div>
<div class="status-container {% if not match.ended and match.started %}running{% endif %}">
<div class="flex-row top-margin">
<div class="match-status-container-header-bottom">
<div class="match-status-container {% if not match.ended and match.started %}running{% endif %} flex-row">
<label class="left-label minor-info bold">
{% if match.show_time_indication %}
{{ match.time_indication }}

@ -15,7 +15,7 @@
<div class="cell medium-12">
<h1 class="club padding10 topmargin20">{{ team.formatted_team_names }} {{team.formatted_special_status}}</h1>
<div class="grid-x">
{% for player in team.player_registrations.all %}
{% for player in team.players_sorted_by_rank.all %}
{% include 'tournaments/player_row.html' with player=player %}
{% endfor %}
{% include 'tournaments/team_stats.html' %}

@ -15,6 +15,7 @@
{% include 'tournaments/navigation_tournament.html' %}
<div class="butterfly-bracket" id="bracket"></div>
<div id="bracket-spacer" style="height: 100px;"></div>
<div id="match-templates" style="display: none;">
{% for match_group in match_groups %}
@ -28,6 +29,7 @@
data-match-title="{{ match.title }}"
data-match-real-index="{{ match.index }}"
data-round-id="{{ match_group.round_id }}"
data-round-index="{{ match_group.round_index }}"
class="match-template">
{% include 'tournaments/bracket_match_cell.html' %}
</div>

@ -6,19 +6,25 @@
{% block title_link %}{% url 'tournament' tournament.id %}{% endblock %}
{% block content %}
{% include 'tournaments/navigation_tournament.html' %}
{% load static %}
{% load tz %}
{% include 'tournaments/navigation_tournament.html' %}
<div class="grid-x">
<div class="cell medium-6 large-6 padding10">
{% if tournament.enable_online_registration and team %}
<h1 class="club padding10 topmargin20">Votre équipe</h1 >
{% if tournament.enable_online_registration and team %}
<div class="cell medium-12 large-6 padding10">
<h1 class="club padding10">Votre équipe</h1 >
<div class="bubble">
{% if messages %}
<div class="messages">
{% for message in messages %}
<div class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
<div class="alert">
{% if team.is_in_waiting_list >= 0 %}
Tournoi complet, vous êtes en liste d'attente.
@ -34,8 +40,8 @@
{% endif %}
</div>
<div class="semibold topmargin20">
{% for player in team.players %}
<div class="semibold">
{% for player in team.players_sorted_by_rank %}
<div>{{ player.name }}</div>
{% endfor %}
</div>
@ -44,41 +50,110 @@
<div>Inscrits le {{ team.local_registration_date }}</div>
</p>
{% if tournament.is_unregistration_possible %}
<p>
{% if team.needs_confirmation %}
<h4 class="semibold">Confirmation requise</h4>
{% if tournament.should_request_payment and tournament.online_payment_is_mandatory %}
<div>Vous devez payer votre inscription pour confirmer votre participation.</div>
{% endif %}
{% if team.get_confirmation_deadline %}
{% if team.is_confirmation_expired %}
<p class="alert alert-danger">
Le délai de confirmation est expiré. Votre place a été proposée à une autre équipe.
</p>
{% else %}
<div>Vous devez confirmer votre participation avant le : </div>
<p class="semibold alert">{{ team.format_confirmation_deadline }}</p>
{% endif %}
{% endif %}
{% endif %}
<a href="{% url 'unregister_tournament' tournament.id %}"
class="rounded-button destructive-button"
onclick="return confirm('Êtes-vous sûr de vouloir vous désinscrire ?');">
{% if team.is_in_waiting_list >= 0 %}
Se retirer de la liste d'attente
{% else %}
Se désinscrire
{% endif %}
{% if team.call_date %}
<div class="topmargin20">
<h4 class="semibold">Convocation</h4>
<div>
Vous avez été convoqué(e)s le {{ team.local_call_date|date:"d/m/Y à H:i" }}
</div>
{% if team.confirmation_date %}
<div class="success-message">
<span class="icon"></span> Convocation confirmée le {{ team.confirmation_date|date:"d/m/Y à H:i" }}
</div>
{% else %}
<div>
Merci de confirmer la réception de votre convocation.
</div>
<form method="post" action="{% url 'confirm_call' tournament.id %}">
{% csrf_token %}
<button type="submit" class="rounded-button positive-button">
Confirmer la réception
</button>
</form>
{% endif %}
</div>
{% endif %}
<!-- Payment status information -->
{% if tournament.should_request_payment and team.is_in_waiting_list < 0 %}
<div class="topmargin20">
{% if team.is_paid %}
<div class="success-message topmargin20">
<span class="icon"></span> Paiement confirmé
</div>
{% else %}
<a href="{% url 'proceed_to_payment' tournament.id %}" class="rounded-button positive-button">
{% if team.needs_confirmation %}
Confirmer en payant ({{ team.get_remaining_fee }}€)
{% else %}
Procéder au paiement ({{ team.get_remaining_fee }}€)
{% endif %}
</a>
</p>
{% endif %}
</div>
{% endif %}
{% if team.needs_confirmation and tournament.online_payment_is_mandatory is False %}
<div class="topmargin20">
<form method="post" action="{% url 'confirm_tournament_registration' tournament.id %}">
{% csrf_token %}
<button type="submit" class="rounded-button positive-button">
Confirmer ma participation
</button>
</form>
</div>
{% endif %}
<!-- {% if is_captain %}
{% else %}
<p>
<div>Vous n'êtes pas le capitaine de l'équipe, la désinscription en ligne n'est pas disponible. Veuillez contacter le JAP ou votre partenaire.</div>
{% if tournament.is_unregistration_possible %}
<div class="topmargin20">
{% if tournament.is_refund_possible and team.is_paid %}
<p class="minor info">
Si vous vous désinscrivez, votre inscription sera remboursée automatiquement sur votre carte bancaire.
{% if tournament.refund_date_limit %}
<div>Remboursement possible jusqu'au {{ tournament.refund_date_limit|date:"d/m/Y à H:i" }}</div>
{% endif %}
</p>
{% endif %}
-->
<div style="margin-top: 40px;">
<a href="{% url 'unregister_tournament' tournament.id %}"
class="rounded-button destructive-button"
onclick="return confirm('Êtes-vous sûr de vouloir vous désinscrire ?{% if tournament.is_refund_possible and team.is_paid %} Votre inscription sera remboursée automatiquement.{% endif %}');">
{% if team.is_in_waiting_list >= 0 %}
Se retirer de la liste d'attente
{% else %}
Se désinscrire
{% endif %}
</a>
</div>
{% endif %}
</div>
</div>
{% endif %}
<h1 class="club padding10 topmargin20">{{ tournament.display_name }} {{ tournament.get_federal_age_category_display}}</h1>
</div>
{% endif %}
<div class="cell medium-12 large-6 padding10">
<h1 class="club padding10">{{ tournament.full_name }}</h1>
<div class="bubble">
{% if tournament.event.name %}
<label class="semibold">{{ tournament.event.name }}</label>
{% endif %}
<div class="semibold">{{ tournament.local_start_date_formatted }}</div>
<div>{{ tournament.day_duration_formatted }}</div>
<div>{{ tournament.court_count }} terrains</div>
<p>
<div class="semibold">{{ tournament.local_start_date_formatted }}</div>
<div>{{ tournament.day_duration_formatted }}</div>
<div>{{ tournament.court_count }} terrains</div>
</p>
<hr/>

@ -8,7 +8,7 @@
<div class="small">{{ tournament.month }}</div>
</div>
<div class="table-row-element tournament-type">
<div class="very-large">{{ tournament.level }}</div>
<div class="very-large">{{ tournament.short_level }}</div>
{% if tournament.category %}
<div class="small">{{ tournament.category }}</div>
{% endif %}
@ -21,6 +21,9 @@
{% if tournament.event.name %}
<div class="small event-name">{{ tournament.event.name }}</div>
{% endif %}
{% if tournament.name %}
<div class="small event-name">{{ tournament.name }}</div>
{% endif %}
<div class="small">{{ tournament.localized_day_duration }}</div>
</div>
<div class="table-row-element tournament-status center">

@ -1,9 +1,8 @@
{% extends 'tournaments/base.html' %}
{% block head_title %}Tournois{% endblock %}
{% block first_title %}Padel Club{% endblock %}
{% block second_title %}{% if club %}{{ club.name }}{% else %}Tournois{% endif %}{% endblock %}
{% block head_title %}Tournois{% if club %} {{ club.name }}{% endif %}{% endblock %}
{% block first_title %}{% if club %}{{ club.name }}{% else %}Padel Club{% endif %}{% endblock %}
{% block second_title %}{% if club %}Les Tournois du Club{% else %}Tournois{% endif %}{% endblock %}
{% block right_header %}
{% endblock %}

@ -2,6 +2,7 @@ from django.contrib.auth import views as auth_views
from django.urls import include, path
from .custom_views import CustomLoginView
from . import views
from .services import payment_service
urlpatterns = [
path('reset/<uidb64>/<token>/', views.CustomPasswordResetConfirmView.as_view(), name='password_reset_confirm'),
@ -11,6 +12,10 @@ urlpatterns = [
path("clubs/<str:club_id>/", views.club, name="club"),
path("c/<str:broadcast_code>", views.club_broadcast, name="club-broadcast"),
path("c/<str:broadcast_code>/go", views.club_broadcast_auto, name="club-broadcast-auto"),
path('tournaments/webhook/stripe/', payment_service.PaymentService.stripe_webhook, name='stripe_webhook'),
path('tournament/<str:tournament_id>/confirm-call/', views.confirm_call, name='confirm_call'),
path('tournaments/<str:tournament_id>/payment/success/', views.tournament_payment_success, name='tournament-payment-success'),
path('tournaments/<str:tournament_id>/proceed-to-payment/', views.proceed_to_payment, name='proceed_to_payment'),
path("tournament/<str:tournament_id>/",
include([
path('', views.tournament, name='tournament'),
@ -46,15 +51,16 @@ urlpatterns = [
path('download/', views.download, name='download'),
path('apns/', views.test_apns, name='test-apns'),
path('terms-of-use/', views.terms_of_use, name='terms-of-use'),
path('utils/xls-to-csv/', views.xls_to_csv, name='xls-to-csv'),
path('mail-test/', views.simple_form_view, name='mail-test'),
path('login/', CustomLoginView.as_view(), name='custom-login'),
path('custom_password_change/', views.custom_password_change, name='custom_password_change'),
path('logout/', views.custom_logout, name='custom_logout'),
path('utils/xls-to-csv/', views.xls_to_csv, name='xls-to-csv'),
path('signup/', views.signup, name='signup'), # URL pattern for signup
# path('profile/', views.profile, name='profile'), # URL pattern for signup
path('my-tournaments/', views.my_tournaments, name='my-tournaments'), # URL pattern for signup
path('all_my_ended_tournaments/', views.all_my_ended_tournaments, name='all-my-ended-tournaments'), # URL pattern for signup
path('tournaments/<str:tournament_id>/cancel-registration/', views.cancel_registration, name='cancel_registration'),
path('tournaments/<str:tournament_id>/register/', views.register_tournament, name='register_tournament'),
path('tournaments/<str:tournament_id>/unregister/', views.unregister_tournament, name='unregister_tournament'),
path('password_reset/', auth_views.PasswordResetView.as_view(), name='password_reset'),
@ -68,4 +74,5 @@ urlpatterns = [
path('admin/users-export/', views.UserListExportView.as_view(), name='users_export'),
path('activation-success/', views.activation_success, name='activation_success'),
path('activation-failed/', views.activation_failed, name='activation_failed'),
path('tournaments/<str:tournament_id>/confirm/', views.confirm_tournament_registration, name='confirm_tournament_registration'),
]

@ -1,9 +1,7 @@
import http.client
import json
import jwt
import time
import httpx
import asyncio
# APPLE WARNING: Reuse a connection as long as possible.
# In most cases, you can reuse a connection for many hours to days.

@ -2,7 +2,7 @@ import re
class LicenseValidator:
def __init__(self, license_id: str):
self.license_id = license_id.upper() # Ensure uppercase for consistency
self.license_id = license_id.lstrip('0').upper() # Ensure uppercase for consistency
@property
def stripped_license(self) -> str:

@ -1,11 +1,11 @@
# Standard library imports
import os
import csv
import pandas as pd
from .utils.extensions import create_random_filename
from api.serializers import GroupStageSerializer, MatchSerializer, PlayerRegistrationSerializer, TeamRegistrationSerializer, TeamScoreSerializer
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth import logout
from .utils.extensions import is_not_sqlite_backend
import stripe
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.views import PasswordResetCompleteView
@ -18,10 +18,7 @@ from django.utils.encoding import force_str
from django.utils.http import urlsafe_base64_decode
from django.urls import reverse
from django.conf import settings
from django.views.decorators.csrf import csrf_exempt
from django.contrib.admin.views.decorators import staff_member_required
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
from django.views.generic import View
from django.db.models import Q
from .models import Club, Tournament, CustomUser, Event, Round, Match, TeamScore, TeamRegistration, PlayerRegistration, UserOrigin
@ -30,6 +27,10 @@ import time
import json
import asyncio
import zipfile
import pandas as pd
from tournaments.utils.extensions import create_random_filename
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
from api.tokens import account_activation_token
@ -49,18 +50,23 @@ from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import PasswordResetConfirmView
from django.core.mail import EmailMessage
from django.views.decorators.csrf import csrf_protect
from .services.tournament_registration import TournamentRegistrationService
from .services.tournament_unregistration import TournamentUnregistrationService
from django.core.exceptions import ValidationError
from .forms import (
ProfileUpdateForm,
SimpleCustomUserCreationForm,
SimpleForm
SimpleForm,
TournamentRegistrationForm,
AddPlayerForm
)
from .utils.apns import send_push_notification
from .utils.licence_validator import LicenseValidator
from django.views.generic.edit import UpdateView
from .forms import CustomPasswordChangeForm
from .services.email_service import TournamentEmailService
from .services.tournament_registration import RegistrationCartManager
from .services.payment_service import PaymentService
from django.views.decorators.csrf import csrf_exempt
def index(request):
now = timezone.now()
@ -166,21 +172,19 @@ def tournament_info(request, tournament_id):
team_registration = None
is_captain = False
player_register_check = None
if request.method == 'POST':
storage = messages.get_messages(request)
for _ in storage:
pass
if len(storage._loaded_messages) == 1:
del storage._loaded_messages[0]
if request.user.is_authenticated:
# Assuming user's licence_id is stored in the user profile (e.g., request.user.licence_id)
user_licence_id = request.user.licence_id
player_register_check = tournament.player_register_check(user_licence_id)
if user_licence_id is not None and player_register_check is None:
validator = LicenseValidator(user_licence_id)
stripped_license = validator.stripped_license
# Check if there is a PlayerRegistration for this user in this tournament
registered_user = PlayerRegistration.objects.filter(
licence_id__icontains=stripped_license,
team_registration__tournament=tournament,
team_registration__walk_out=False,
).first()
registered_user = tournament.get_user_registered(request.user)
# If the user is registered, retrieve their team registration
if registered_user:
is_captain = registered_user.captain
@ -553,52 +557,6 @@ def test_websocket(request):
def terms_of_use(request):
return render(request, 'terms_of_use.html')
@csrf_exempt
def xls_to_csv(request):
if request.method == 'POST':
# Check if the request has a file
if 'file' in request.FILES:
uploaded_file = request.FILES['file']
# Save the uploaded file
directory = 'tmp/csv/'
file_path = os.path.join(directory, uploaded_file.name)
file_name = default_storage.save(file_path, ContentFile(uploaded_file.read()))
# Check available sheets and look for 'inscriptions'
xls = pd.ExcelFile(file_name)
sheet_names = xls.sheet_names
# Determine which sheet to use
target_sheet = 0 # Default to first sheet
if 'inscriptions' in [name.lower() for name in sheet_names]:
for i, name in enumerate(sheet_names):
if name.lower() == 'inscriptions':
target_sheet = i # or use the name directly: target_sheet = name
break
# Convert to csv and save
data_xls = pd.read_excel(file_name, sheet_name=target_sheet, index_col=None)
csv_file_name = create_random_filename('players', 'csv')
output_path = os.path.join(directory, csv_file_name)
data_xls.to_csv(output_path, sep=';', index=False, encoding='utf-8')
# Send the processed file back
with default_storage.open(output_path, 'rb') as file:
response = HttpResponse(file.read(), content_type='application/octet-stream')
response['Content-Disposition'] = f'attachment; filename="players.csv"'
# Clean up: delete both files
default_storage.delete(file_path)
default_storage.delete(output_path)
return response
else:
return HttpResponse("No file was uploaded", status=400)
else:
return HttpResponse("Only POST requests are allowed", status=405)
def simple_form_view(request):
if request.method == 'POST':
@ -719,19 +677,6 @@ def profile(request):
'user_name': user.username
})
@csrf_protect
def register_tournament(request, tournament_id):
tournament = get_object_or_404(Tournament, id=tournament_id)
service = TournamentRegistrationService(request, tournament)
service.initialize_context()
print("initialize_context")
if request.method == 'POST':
service.handle_post_request()
else:
service.handle_get_request()
return render(request, 'register_tournament.html', service.context)
@login_required
def unregister_tournament(request, tournament_id):
tournament = get_object_or_404(Tournament, id=tournament_id)
@ -819,63 +764,21 @@ def custom_password_change(request):
@login_required
def my_tournaments(request):
user = request.user
user_licence_id = request.user.licence_id
# If no licence_id, return empty lists
if user_licence_id is None:
return render(request, 'registration/my_tournaments.html', {
'upcoming_tournaments': [],
'running_tournaments': [],
'ended_tournaments': [],
'user_name': user.username
})
# Get all tournaments for the user based on their licence
validator = LicenseValidator(user_licence_id)
stripped_license = validator.stripped_license
def filter_user_tournaments(tournaments):
return [t for t in tournaments if t.team_registrations.filter(
player_registrations__licence_id__icontains=stripped_license,
walk_out=False
).exists()]
# Get filtered tournaments using the helper function
upcoming_tournaments = filter_user_tournaments(future_tournaments(None))
running_tournaments = filter_user_tournaments(live_tournaments(None))
ended_tournaments = filter_user_tournaments([t for t in finished_tournaments(None) if not t.is_canceled()])
upcoming_tournaments = get_user_tournaments(request.user, future_tournaments(None))
running_tournaments = get_user_tournaments(request.user, live_tournaments(None))
ended_tournaments = get_user_tournaments(request.user, [t for t in finished_tournaments(None) if not t.is_canceled()])
return render(request, 'registration/my_tournaments.html', {
'upcoming_tournaments': upcoming_tournaments,
'running_tournaments': running_tournaments,
'ended_tournaments': ended_tournaments[:12],
'user_name': user.username
'user_name': request.user.username
})
@login_required
def all_my_ended_tournaments(request):
user_licence_id = request.user.licence_id
# If no licence_id, return empty lists
if user_licence_id is None:
return render(request, '/tournaments.html', {
'tournaments': [],
'title': "Palmarès",
})
# Get all tournaments for the user based on their licence
validator = LicenseValidator(user_licence_id)
stripped_license = validator.stripped_license
def filter_user_tournaments(tournaments):
return [t for t in tournaments if t.team_registrations.filter(
player_registrations__licence_id__icontains=stripped_license,
walk_out=False
).exists()]
ended_tournaments = filter_user_tournaments(finished_tournaments(None))
ended_tournaments = get_user_tournaments(request.user, finished_tournaments(None))
return render(request,
"tournaments/tournaments_list.html",
{
@ -1113,6 +1016,528 @@ def custom_logout(request):
logout(request)
return redirect('index') # or whatever URL you want to redirect to
@login_required
def confirm_tournament_registration(request, tournament_id):
if request.method == 'POST':
tournament = get_object_or_404(Tournament, pk=tournament_id)
team_registration = tournament.get_user_team_registration(request.user)
if team_registration is None:
messages.error(request, "Aucune inscription trouvée pour ce tournoi.")
return redirect('tournament-info', tournament_id=tournament_id)
if team_registration.is_confirmation_expired():
team_registration.check_confirmation_deadline()
messages.error(request, "Le délai de confirmation est expiré. Vous ne pouvez plus confirmer votre participation.")
return redirect('tournament-info', tournament_id=tournament_id)
# Confirm registration
team_registration.confirm_registration()
messages.success(request, "Votre participation est confirmée !")
return redirect('tournament-info', tournament_id=tournament_id)
return redirect('tournament-info', tournament_id=tournament_id)
@login_required
def proceed_to_payment(request, tournament_id):
"""Handle direct payment from tournament info page"""
tournament = get_object_or_404(Tournament, id=tournament_id)
team = tournament.get_user_team_registration(request.user)
if not team:
messages.error(request, "Aucune inscription trouvée pour ce tournoi.")
return redirect('tournament-info', tournament_id=tournament_id)
if team.is_confirmation_expired():
team.check_confirmation_deadline()
messages.error(request, "Le délai de confirmation est expiré. Vous ne pouvez plus régler votre participation.")
return redirect('tournament-info', tournament_id=tournament_id)
if team.is_paid():
messages.info(request, "Votre inscription est déjà payée.")
return redirect('tournament-info', tournament_id=tournament_id)
try:
# Create payment session
payment_service = PaymentService(request)
checkout_session = payment_service.create_checkout_session(
tournament_id=str(tournament_id), # Convert UUID to string
team_fee=team.get_remaining_fee(),
team_registration_id=str(team.id) # Convert UUID to string
)
# Redirect to Stripe checkout
return redirect(checkout_session.url)
except Exception as e:
print(f"Payment error: {str(e)}") # Add debug print
messages.error(request, f"Erreur lors de la création de la session de paiement: {str(e)}")
return redirect('tournament-info', tournament_id=tournament_id)
@login_required
def tournament_payment_success(request, tournament_id):
"""Handle successful Stripe payment for tournament registration"""
# Get checkout session ID from session
checkout_session_id = request.session.get('stripe_checkout_session_id')
if not checkout_session_id:
messages.error(request, "Session de paiement introuvable.")
return redirect('tournament-info', tournament_id=tournament_id)
try:
# Verify payment status with Stripe
stripe.api_key = settings.STRIPE_SECRET_KEY
checkout_session = stripe.checkout.Session.retrieve(checkout_session_id)
if checkout_session.payment_status == 'paid':
# Process the payment success
payment_service = PaymentService(request)
success = payment_service.process_successful_payment(str(tournament_id), checkout_session)
if success:
# Set a flag for successful registration if the payment was from registration page
source_page = request.session.get('payment_source_page', 'tournament_info')
if source_page == 'register_tournament':
request.session['registration_successful'] = True
request.session['registration_paid'] = True
messages.success(request, "Paiement réussi et inscription confirmée !")
else:
messages.error(request, "Erreur lors du traitement du paiement.")
else:
messages.error(request, "Le paiement n'a pas été complété.")
except Exception as e:
print(f"Payment processing error: {str(e)}") # Add debug print
messages.error(request, f"Erreur lors de la vérification du paiement: {str(e)}")
# Get the source page to determine where to redirect
source_page = request.session.get('payment_source_page', 'tournament_info')
# Clean up session variables
for key in ['stripe_checkout_session_id', 'team_registration_id', 'payment_source_page']:
if key in request.session:
del request.session[key]
# Redirect to the appropriate page
if source_page == 'register_tournament':
# For payments during registration, redirect back to registration page
# with registration_successful flag in session
return redirect('register_tournament', tournament_id=tournament_id)
else:
# For direct payments, go to tournament info
return redirect('tournament-info', tournament_id=tournament_id)
@csrf_protect
def register_tournament(request, tournament_id):
tournament = get_object_or_404(Tournament, id=tournament_id)
# Check for registration_successful flag
registration_successful = request.session.pop('registration_successful', False)
registration_paid = request.session.pop('registration_paid', False)
# If registration was successful, render success page immediately
if registration_successful:
storage = messages.get_messages(request)
for _ in storage:
pass
if len(storage._loaded_messages) == 1:
del storage._loaded_messages[0]
context = {
'tournament': tournament,
'registration_successful': True,
'registration_paid': registration_paid,
'current_players': [],
'cart_data': {'players': []}
}
return render(request, 'register_tournament.html', context)
cart_manager = RegistrationCartManager(request)
# Debug session content
print_session_debug(request, "SESSION DUMP AT START")
# Check if tournament is open for registration
if not tournament.enable_online_registration:
messages.error(request, "L'inscription en ligne n'est pas activée pour ce tournoi.")
return redirect('tournament-info', tournament_id=tournament_id)
# Check if user is already registered
if request.user.is_authenticated and request.user.licence_id and tournament.is_user_registered(request.user):
messages.info(request, "Vous êtes déjà inscrit à ce tournoi.")
return redirect('tournament-info', tournament_id=tournament_id)
# Only initialize a fresh cart for GET requests
# For POST requests, use the existing cart to maintain state
if request.method == 'GET':
# Check if we're returning from Stripe (don't reinitialize if we have a checkout session)
if 'stripe_checkout_session_id' in request.session:
# We're returning from Stripe, don't reinitialize the cart
pass
else:
print("Initializing cart")
storage = messages.get_messages(request)
for _ in storage:
pass
if len(storage._loaded_messages) == 1:
del storage._loaded_messages[0]
# ALWAYS initialize a fresh cart when entering the registration page (GET request)
# This ensures no old cart data persists
cart_manager.initialize_cart(tournament_id)
# Auto-add the authenticated user with license
if request.user.is_authenticated and request.user.licence_id:
cart_manager.add_authenticated_user()
else:
# For POST, ensure tournament ID is correct
current_tournament_id = cart_manager.get_tournament_id()
if current_tournament_id != str(tournament_id):
cart_manager.initialize_cart(tournament_id)
# Re-add the authenticated user if they have a license
if request.user.is_authenticated and request.user.licence_id:
cart_manager.add_authenticated_user()
# Get cart data
cart_data = cart_manager.get_cart_data()
print(f"View - Cart Players Count: {len(cart_data['players'])}")
# Initialize context with cart data
context = {
'tournament': tournament,
'current_players': cart_data['players'],
'registration_successful': False,
'registration_paid': False,
'cart_data': cart_data
}
# Initialize forms
context['team_form'] = TournamentRegistrationForm(initial={
'email': request.user.email if request.user.is_authenticated else '',
'mobile_number': request.user.phone if request.user.is_authenticated else cart_data.get('mobile_number', '')
})
# Initialize the add player form
context['add_player_form'] = create_add_player_form(request, tournament, cart_data)
# Handle POST requests
if request.method == 'POST':
if 'add_player' in request.POST:
handle_add_player_request(request, tournament, cart_manager, context)
elif 'remove_player' in request.POST:
handle_remove_player_request(request, tournament, cart_manager, context)
elif 'register_team' in request.POST:
handle_register_team_request(request, tournament, cart_manager, context)
elif 'proceed_to_payment' in request.POST:
result = handle_payment_request(request, tournament, cart_manager, context, tournament_id)
if result:
return result # This is the redirect to Stripe checkout
# Debug session content before rendering
print_session_debug(request, "SESSION DUMP BEFORE RENDER")
return render(request, 'register_tournament.html', context)
def create_add_player_form(request, tournament, cart_data):
"""Create the appropriate add player form based on user status"""
# Special handling for authenticated user without license
if request.user.is_authenticated and not request.user.licence_id and not cart_data['players']:
# Setup form for user without license
initial_data = {
'first_name': request.user.first_name,
'last_name': request.user.last_name
}
# If license is required, only show license field initially
if tournament.license_is_required:
add_player_form = AddPlayerForm(initial=initial_data)
add_player_form.user_without_licence = True
return add_player_form
else:
# If license not required, show all fields
return AddPlayerForm(initial=initial_data)
elif not cart_data['players'] or len(cart_data['players']) < tournament.minimum_player_per_team:
# Regular partner addition form
return AddPlayerForm()
return None
def handle_add_player_request(request, tournament, cart_manager, context):
"""Handle the 'add_player' POST action"""
add_player_form = AddPlayerForm(request.POST)
if add_player_form.is_valid():
# Get the current form data for phone number
team_form = TournamentRegistrationForm(request.POST)
if team_form.is_valid():
# Update cart with mobile number before adding player
cart_manager.update_contact_info(
mobile_number=team_form.cleaned_data.get('mobile_number')
)
success, message = cart_manager.add_player(add_player_form.cleaned_data)
if success:
messages.success(request, message)
# Refresh cart data
cart_data = cart_manager.get_cart_data()
context['current_players'] = cart_data['players']
context['team_form'] = TournamentRegistrationForm(initial={
'email': request.user.email if request.user.is_authenticated else '',
'mobile_number': cart_data.get('mobile_number', '')
})
# Prepare a fresh form for the next player if needed
if len(cart_data['players']) < tournament.minimum_player_per_team:
context['add_player_form'] = AddPlayerForm()
else:
# Remove the form if we've reached the team limit
context['add_player_form'] = None
else:
messages.error(request, message)
if cart_manager.first_tournament:
add_player_form.first_tournament = True
context['add_player_form'] = add_player_form
else:
for field, errors in add_player_form.errors.items():
for error in errors:
messages.error(request, f"{field}: {error}")
context['add_player_form'] = add_player_form
def handle_remove_player_request(request, tournament, cart_manager, context):
"""Handle the 'remove_player' POST action"""
success, message = cart_manager.remove_player()
if success:
messages.info(request, message)
# Refresh cart data
cart_data = cart_manager.get_cart_data()
context['current_players'] = cart_data['players']
# Update the add player form based on the new state
if not cart_data['players'] and request.user.is_authenticated:
if not request.user.licence_id and tournament.license_is_required:
initial_data = {
'first_name': request.user.first_name,
'last_name': request.user.last_name
}
add_player_form = AddPlayerForm(initial=initial_data)
add_player_form.user_without_licence = True
else:
add_player_form = AddPlayerForm()
context['add_player_form'] = add_player_form
else:
context['add_player_form'] = AddPlayerForm()
else:
messages.error(request, message)
def handle_register_team_request(request, tournament, cart_manager, context):
"""Handle the 'register_team' POST action"""
team_form = TournamentRegistrationForm(request.POST)
if team_form.is_valid():
# Debug print before checkout
cart_data = cart_manager.get_cart_data()
print(f"Before checkout - Players in cart: {len(cart_data['players'])}")
# Ensure the session data is correctly saved before proceeding
request.session.modified = True
request.session.save()
# Update cart with contact info
cart_manager.update_contact_info(
mobile_number=team_form.cleaned_data.get('mobile_number')
)
# Get fresh cart data again after updating contact info
fresh_cart_data = cart_manager.get_cart_data()
print(f"After contact update - Players in cart: {len(fresh_cart_data['players'])}")
# Debug session content
print_session_debug(request, "SESSION DUMP BEFORE CHECKOUT")
# Checkout and create registration
success, result = cart_manager.checkout()
if success:
waiting_list_position = cart_data.get('waiting_list_position', -1)
if is_not_sqlite_backend():
email_service = TournamentEmailService()
email_service.send_registration_confirmation(
request,
tournament,
result, # team_registration
waiting_list_position
)
context['registration_successful'] = True
context['registration_paid'] = False
context['current_players'] = []
context['add_player_form'] = None # No more adding players after success
else:
messages.error(request, result)
else:
for field, errors in team_form.errors.items():
for error in errors:
messages.error(request, f"{field}: {error}")
context['team_form'] = team_form
def handle_payment_request(request, tournament, cart_manager, context, tournament_id):
"""Handle the 'proceed_to_payment' POST action"""
team_form = TournamentRegistrationForm(request.POST)
if team_form.is_valid():
# Update cart with contact info
cart_manager.update_contact_info(
mobile_number=team_form.cleaned_data.get('mobile_number')
)
# Create payment session
try:
# Get cart data for payment metadata
cart_data = cart_manager.get_cart_data()
# Create and redirect to payment session
payment_service = PaymentService(request)
checkout_session = payment_service.create_checkout_session(
tournament_id=tournament_id,
team_fee=tournament.team_fee(), # Use the appropriate fee field
cart_data=cart_data
)
# Redirect to Stripe checkout
return redirect(checkout_session.url)
except Exception as e:
messages.error(request, f"Erreur lors de la création de la session de paiement: {str(e)}")
else:
for field, errors in team_form.errors.items():
for error in errors:
messages.error(request, f"{field}: {error}")
context['team_form'] = team_form
return None
def print_session_debug(request, header):
"""Debug utility to print session contents"""
print(f"===== {header} =====")
for key, value in request.session.items():
if key.startswith('registration_'):
print(f"{key}: {value}")
print("================================")
@login_required
def cancel_registration(request, tournament_id):
"""Cancel the current registration process and clear the cart"""
cart_manager = RegistrationCartManager(request)
cart_manager.clear_cart()
try:
tournament = Tournament.objects.get(id=tournament_id)
tournament.reserved_spots = max(0, tournament.reserved_spots - 1)
tournament.save()
except Tournament.DoesNotExist:
messages.error(request, "Tournoi introuvable.")
return redirect('tournaments-list')
messages.info(request, "Processus d'inscription annulé.")
return redirect('tournament-info', tournament_id=tournament_id)
@login_required
def confirm_call(request, tournament_id):
if request.method == 'POST':
tournament = get_object_or_404(Tournament, pk=tournament_id)
team = tournament.get_user_team_registration(request.user)
if team and team.call_date:
team.confirmation_date = timezone.now()
team.save()
# messages.success(request, 'Réception de la convocation confirmée.')
else:
messages.error(request, 'Une erreur est survenue.')
return redirect('tournament-info', tournament_id=tournament_id)
@csrf_exempt
def xls_to_csv(request):
# Check if the request has a file
if 'file' in request.FILES:
uploaded_file = request.FILES['file']
# Save the uploaded file
directory = 'tmp/csv/'
file_path = os.path.join(directory, uploaded_file.name)
file_name = default_storage.save(file_path, ContentFile(uploaded_file.read()))
# Check available sheets and look for 'inscriptions'
xls = pd.ExcelFile(file_name)
sheet_names = xls.sheet_names
# Determine which sheet to use
target_sheet = 0 # Default to first sheet
if 'inscriptions' in [name.lower() for name in sheet_names]:
for i, name in enumerate(sheet_names):
if name.lower() == 'inscriptions':
target_sheet = i # or use the name directly: target_sheet = name
break
# Convert to csv and save
data_xls = pd.read_excel(file_name, sheet_name=target_sheet, index_col=None)
csv_file_name = create_random_filename('players', 'csv')
output_path = os.path.join(directory, csv_file_name)
data_xls.to_csv(output_path, sep=';', index=False, encoding='utf-8')
# Send the processed file back
with default_storage.open(output_path, 'rb') as file:
response = HttpResponse(file.read(), content_type='application/octet-stream')
response['Content-Disposition'] = f'attachment; filename="players.csv"'
# Clean up: delete both files
default_storage.delete(file_path)
default_storage.delete(output_path)
return response
else:
return HttpResponse("No file was uploaded", status=400)
def get_user_tournaments(user, tournaments):
user_tournaments = []
if user.is_authenticated is False:
return user_tournaments
user_licence_id = user.licence_id
stripped_license = None
# If no licence_id, return empty lists
if user_licence_id is not None:
# Get all tournaments for the user based on their licence
validator = LicenseValidator(user_licence_id)
stripped_license = validator.stripped_license
for t in tournaments:
# First check for direct user relationship
direct_registration_exists = t.team_registrations.filter(
player_registrations__user=user,
walk_out=False
).exists()
# If direct relationship exists, add the tournament
if direct_registration_exists:
user_tournaments.append(t)
continue
# Otherwise, check by license
license_registration_exists = None
if stripped_license:
license_registration_exists = t.team_registrations.filter(
player_registrations__licence_id__icontains=stripped_license,
walk_out=False
).exists()
if license_registration_exists:
user_tournaments.append(t)
return user_tournaments
class UserListExportView(LoginRequiredMixin, View):
def get(self, request, *args, **kwargs):
users = CustomUser.objects.order_by('date_joined')

Loading…
Cancel
Save