diff --git a/api/serializers.py b/api/serializers.py index 67758f6..6eedf50 100644 --- a/api/serializers.py +++ b/api/serializers.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) diff --git a/api/urls.py b/api/urls.py index 749c0b1..0c49667 100644 --- a/api/urls.py +++ b/api/urls.py @@ -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//', 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"), diff --git a/api/views.py b/api/views.py index bfbc0c3..be20311 100644 --- a/api/views.py +++ b/api/views.py @@ -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) + }) diff --git a/padelclub_backend/settings.py b/padelclub_backend/settings.py index ed2fe42..b6c8262 100644 --- a/padelclub_backend/settings.py +++ b/padelclub_backend/settings.py @@ -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, diff --git a/padelclub_backend/settings_local.py.dist b/padelclub_backend/settings_local.py.dist index 43ea805..f983539 100644 --- a/padelclub_backend/settings_local.py.dist +++ b/padelclub_backend/settings_local.py.dist @@ -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 diff --git a/requirements.txt b/requirements.txt index 1f1169f..cc14798 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/shop/admin.py b/shop/admin.py index 62fa1b3..72d69c6 100644 --- a/shop/admin.py +++ b/shop/admin.py @@ -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 diff --git a/shop/management/commands/create_initial_shop_data.py b/shop/management/commands/create_initial_shop_data.py index d07d71e..b81c332 100644 --- a/shop/management/commands/create_initial_shop_data.py +++ b/shop/management/commands/create_initial_shop_data.py @@ -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' }, diff --git a/shop/migrations/0026_alter_order_user.py b/shop/migrations/0026_alter_order_user.py new file mode 100644 index 0000000..fa5fa73 --- /dev/null +++ b/shop/migrations/0026_alter_order_user.py @@ -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), + ), + ] diff --git a/shop/models.py b/shop/models.py index 14f64ed..55e8345 100644 --- a/shop/models.py +++ b/shop/models.py @@ -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) diff --git a/shop/signals.py b/shop/signals.py index 025c7a5..f449550 100644 --- a/shop/signals.py +++ b/shop/signals.py @@ -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()} diff --git a/shop/stripe_utils.py b/shop/stripe_utils.py index 2e7cba1..2fe4ede 100644 --- a/shop/stripe_utils.py +++ b/shop/stripe_utils.py @@ -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 diff --git a/shop/templates/admin/shop/order/change_list.html b/shop/templates/admin/shop/order/change_list.html new file mode 100644 index 0000000..e3e7fdc --- /dev/null +++ b/shop/templates/admin/shop/order/change_list.html @@ -0,0 +1,18 @@ +{% extends "admin/change_list.html" %} + +{% block object-tools %} + +{% endblock %} diff --git a/shop/templates/admin/shop/order/preparation_view.html b/shop/templates/admin/shop/order/preparation_view.html new file mode 100644 index 0000000..63213a5 --- /dev/null +++ b/shop/templates/admin/shop/order/preparation_view.html @@ -0,0 +1,103 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +
+

Total orders with status PAID: {{ total_orders }}

+

Total items to prepare: {{ total_items }}

+ + + Back to Orders + +

Items Summary

+ + + + + + + + + + + + {% for item in items %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
ProductColorSizeQuantityOrders
{{ item.product.title }} + {% if item.color %} + + {{ item.color.name }} + {% else %} + - + {% endif %} + {{ item.size.name|default:"-" }}{{ item.quantity }} + {% for order_id in item.orders %} + Order #{{ order_id }}{% if not forloop.last %}, {% endif %} + {% endfor %} +
No items to prepare
+ +

Order Details

+ + + + + + + + + + + {% for order in orders %} + + + + + + + {% empty %} + + + + {% endfor %} + +
Order #DateCustomerItems
Order #{{ order.id }}{{ order.date_ordered|date:"Y-m-d H:i" }} + {% if order.user %} + {{ order.user.email }} + {% elif order.guest_user %} + {{ order.guest_user.email }} (Guest) + {% else %} + Unknown + {% endif %} + + {% 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 %} +
+ {% endfor %} +
No orders found
+ + +
+{% endblock %} diff --git a/tournaments/admin.py b/tournaments/admin.py index 1248c10..5cc7cfb 100644 --- a/tournaments/admin.py +++ b/tournaments/admin.py @@ -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 = '%s' % ( + 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) diff --git a/tournaments/apps.py b/tournaments/apps.py index 1c6c698..335ed59 100644 --- a/tournaments/apps.py +++ b/tournaments/apps.py @@ -1,4 +1,5 @@ from django.apps import AppConfig +import logging class TournamentsConfig(AppConfig): name = 'tournaments' diff --git a/tournaments/custom_views.py b/tournaments/custom_views.py index 4e3d2f1..6469c21 100644 --- a/tournaments/custom_views.py +++ b/tournaments/custom_views.py @@ -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) diff --git a/tournaments/management/commands/check_deadlines.py b/tournaments/management/commands/check_deadlines.py new file mode 100644 index 0000000..925cd2a --- /dev/null +++ b/tournaments/management/commands/check_deadlines.py @@ -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')) diff --git a/tournaments/management/commands/schedule_tasks.py b/tournaments/management/commands/schedule_tasks.py new file mode 100644 index 0000000..9ebdb3b --- /dev/null +++ b/tournaments/management/commands/schedule_tasks.py @@ -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' + )) diff --git a/tournaments/middleware.py b/tournaments/middleware.py index ad517b2..ad4b5d2 100644 --- a/tournaments/middleware.py +++ b/tournaments/middleware.py @@ -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 diff --git a/tournaments/migrations/0116_customuser_disable_ranking_federal_ruling_and_more.py b/tournaments/migrations/0116_customuser_disable_ranking_federal_ruling_and_more.py new file mode 100644 index 0000000..f6cd860 --- /dev/null +++ b/tournaments/migrations/0116_customuser_disable_ranking_federal_ruling_and_more.py @@ -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), + ), + ] diff --git a/tournaments/migrations/0117_playerregistration_user_teamregistration_user_and_more.py b/tournaments/migrations/0117_playerregistration_user_teamregistration_user_and_more.py new file mode 100644 index 0000000..c81d5c8 --- /dev/null +++ b/tournaments/migrations/0117_playerregistration_user_teamregistration_user_and_more.py @@ -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), + ), + ] diff --git a/tournaments/models/__init__.py b/tournaments/models/__init__.py index 545a5b1..b093bda 100644 --- a/tournaments/models/__init__.py +++ b/tournaments/models/__init__.py @@ -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 diff --git a/tournaments/models/custom_user.py b/tournaments/models/custom_user.py index 3137948..a94cdb8 100644 --- a/tournaments/models/custom_user.py +++ b/tournaments/models/custom_user.py @@ -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 diff --git a/tournaments/models/enums.py b/tournaments/models/enums.py index d7c7dc2..22abf9e 100644 --- a/tournaments/models/enums.py +++ b/tournaments/models/enums.py @@ -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' diff --git a/tournaments/models/group_stage.py b/tournaments/models/group_stage.py index f53bfdf..fdc7231 100644 --- a/tournaments/models/group_stage.py +++ b/tournaments/models/group_stage.py @@ -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 diff --git a/tournaments/models/match.py b/tournaments/models/match.py index f846598..d114249 100644 --- a/tournaments/models/match.py +++ b/tournaments/models/match.py @@ -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): diff --git a/tournaments/models/player_registration.py b/tournaments/models/player_registration.py index 6b1568d..f13de81 100644 --- a/tournaments/models/player_registration.py +++ b/tournaments/models/player_registration.py @@ -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 diff --git a/tournaments/models/round.py b/tournaments/models/round.py index 2bb7204..42d30c0 100644 --- a/tournaments/models/round.py +++ b/tournaments/models/round.py @@ -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 diff --git a/tournaments/models/team_registration.py b/tournaments/models/team_registration.py index 6ff5168..be38eb9 100644 --- a/tournaments/models/team_registration.py +++ b/tournaments/models/team_registration.py @@ -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 + ) diff --git a/tournaments/models/team_score.py b/tournaments/models/team_score.py index 0ca950f..d51467b 100644 --- a/tournaments/models/team_score.py +++ b/tournaments/models/team_score.py @@ -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 diff --git a/tournaments/models/tournament.py b/tournaments/models/tournament.py index 6b5696e..4000302 100644 --- a/tournaments/models/tournament.py +++ b/tournaments/models/tournament.py @@ -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 diff --git a/tournaments/models/unregistered_player.py b/tournaments/models/unregistered_player.py index ba2fc32..4295447 100644 --- a/tournaments/models/unregistered_player.py +++ b/tournaments/models/unregistered_player.py @@ -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() diff --git a/tournaments/models/unregistered_team.py b/tournaments/models/unregistered_team.py index a5b91e7..7f9c04f 100644 --- a/tournaments/models/unregistered_team.py +++ b/tournaments/models/unregistered_team.py @@ -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) diff --git a/tournaments/repositories.py b/tournaments/repositories.py deleted file mode 100644 index d30f636..0000000 --- a/tournaments/repositories.py +++ /dev/null @@ -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 diff --git a/tournaments/services/email_service.py b/tournaments/services/email_service.py index b297224..f3398a4 100644 --- a/tournaments/services/email_service.py +++ b/tournaments/services/email_service.py @@ -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'{link_text}' 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'{link_text}' 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'{link_text}' 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'{link_text}' + + 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'{link_text}' 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'{link_text}' 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'{link_text}' 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'{link_text}' 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'{link_text}' 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'{link_text}' 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'{link_text}' + + 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'{link_text}' + + 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) diff --git a/tournaments/services/payment_service.py b/tournaments/services/payment_service.py new file mode 100644 index 0000000..b176c6b --- /dev/null +++ b/tournaments/services/payment_service.py @@ -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)}") diff --git a/tournaments/services/tournament_registration.py b/tournaments/services/tournament_registration.py index 019ef29..d36c0c7 100644 --- a/tournaments/services/tournament_registration.py +++ b/tournaments/services/tournament_registration.py @@ -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) diff --git a/tournaments/services/tournament_unregistration.py b/tournaments/services/tournament_unregistration.py index 4586b36..f143aee 100644 --- a/tournaments/services/tournament_unregistration.py +++ b/tournaments/services/tournament_unregistration.py @@ -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): diff --git a/tournaments/signals.py b/tournaments/signals.py index b24ef6a..1c3c5ac 100644 --- a/tournaments/signals.py +++ b/tournaments/signals.py @@ -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() diff --git a/tournaments/static/tournaments/css/style.css b/tournaments/static/tournaments/css/style.css index 16404ef..a652b1b 100644 --- a/tournaments/static/tournaments/css/style.css +++ b/tournaments/static/tournaments/css/style.css @@ -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; +} diff --git a/tournaments/static/tournaments/css/tournament_bracket.css b/tournaments/static/tournaments/css/tournament_bracket.css index 5593b51..12ba4d6 100644 --- a/tournaments/static/tournaments/css/tournament_bracket.css +++ b/tournaments/static/tournaments/css/tournament_bracket.css @@ -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; +} diff --git a/tournaments/static/tournaments/js/tournament_bracket.js b/tournaments/static/tournaments/js/tournament_bracket.js index bbf058d..ea8b1ad 100644 --- a/tournaments/static/tournaments/js/tournament_bracket.js +++ b/tournaments/static/tournaments/js/tournament_bracket.js @@ -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) {
`; - 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); + } } diff --git a/tournaments/tasks.py b/tournaments/tasks.py new file mode 100644 index 0000000..66836a6 --- /dev/null +++ b/tournaments/tasks.py @@ -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") diff --git a/tournaments/templates/register_tournament.html b/tournaments/templates/register_tournament.html index 66c4dd7..a2ff531 100644 --- a/tournaments/templates/register_tournament.html +++ b/tournaments/templates/register_tournament.html @@ -13,171 +13,293 @@
-
-

Inscription : {{ tournament.display_name }} {{ tournament.get_federal_age_category_display}}

- - +

Inscription : {{ tournament.display_name }} {{ tournament.get_federal_age_category_display}}

- - {% if registration_successful %} -

Merci, l'inscription a bien été envoyée au juge-arbitre.

-

- 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. -

- {% else %} - - {% if team_form.errors %} -
- {% for field in team_form %} - {% if field.errors %} - {% for error in field.errors %} -
{{ field.label }} : {{ error }}
- {% endfor %} - {% endif %} - {% endfor %} -
- {% endif %} - - {% if messages %} -
- {% for message in messages %} -
- {{ message }} -
- {% endfor %} -
- {% endif %} - -
- {% csrf_token %} - - -
-

-

- Informations de contact -
-

- {% if team_form.non_field_errors %} - {% for error in team_form.non_field_errors %} -

{{ error }}

- {% endfor %} - {% endif %} - - {{ team_form.as_p }} -
- - - {% if current_players %} -

-

- Constitution de votre équipe -
-

-
    - {% for player in current_players %} -
  • -
    - {{ player.first_name }} {{ player.last_name }}{% if player.licence_id %} ({{ player.licence_id }}){% endif %} -
    -
    - {{ player.club_name }} -
    -
    - Classement à ce jour : {{ player.rank }} -
    - {% if not forloop.first %} -
    - -
    - {% endif %} -
  • - {% endfor %} -
- {% endif %} - - - {% if current_players|length < 2 %} -
- {% if current_players|length == 1 %} -
- Inscrivez votre partenaire -
- {% endif %} - {% if current_players|length == 0 and add_player_form.user_without_licence and tournament.license_is_required %} -
- Une licence est obligatoire pour vous inscrire : -
- {% 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 %} -
- 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. -
-
- Précisez les informations du joueur : -
- {% 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 }} +
{{ tournament.local_start_date_formatted }}
+
{{ tournament.event.club.name }}
+ {% if tournament.has_club_address %} +
{{ tournament.event.club.address }}
+
{{ tournament.event.club.city_zipcode }}
{% endif %} - {% endif %} - - -
- {% endif %} - - - {% if current_players|length >= tournament.minimum_player_per_team %} -
-
-
- {% 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 %} -
- -
- +
+ {% endif %} + + {% endfor %} + + {% endif %} + + + {% if current_players|length < 2 %} +
+ {% if current_players|length == 1 %} +
+ Inscrivez votre partenaire +
+ {% endif %} + {% if current_players|length == 0 and add_player_form.user_without_licence and tournament.license_is_required %} +
+ Une licence est obligatoire pour vous inscrire : +
+ {% 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 %} +
+ 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. +
+
+ Précisez les informations du joueur : +
+ {% 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 %} + + +
+ {% endif %} + + + {% if current_players|length >= tournament.minimum_player_per_team %} +
+
+
+ {% 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 %} +
+ +
+ {% if tournament.should_request_payment and cart_data.waiting_list_position < 0 %} +
+ Confirmer votre inscription en payant immédiatement : +
+ + {% 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 %} +
+ Ou confirmer votre inscription et payer sur place le jour du tournoi : +
+ {% endif %} + + {% endif %} +
+ {% endif %} +
{% endif %} - -
- {% endif %} - - {% endif %} -
+
+ + {% endblock %} diff --git a/tournaments/templates/registration/my_tournaments.html b/tournaments/templates/registration/my_tournaments.html index b237df0..557481a 100644 --- a/tournaments/templates/registration/my_tournaments.html +++ b/tournaments/templates/registration/my_tournaments.html @@ -11,65 +11,46 @@ {% load tz %}
-
-
+ {% if running_tournaments %} +
- {% if running_tournaments %} - {% for tournament in running_tournaments %} - {% include 'tournaments/tournament_row.html' %} - {% endfor %} - {% else %} -
- Aucun tournoi en cours -
- {% endif %} - + {% for tournament in running_tournaments %} + {% include 'tournaments/tournament_row.html' %} + {% endfor %}
- -
+ {% endif %} + {% if upcoming_tournaments %} +
- {% if upcoming_tournaments %} - {% for tournament in upcoming_tournaments %} - {% include 'tournaments/tournament_row.html' %} - {% endfor %} - {% else %} -
- Aucun tournoi à venir -
- {% endif %} + {% for tournament in upcoming_tournaments %} + {% include 'tournaments/tournament_row.html' %} + {% endfor %}
+ {% endif %} -
- -
-
- + {% if ended_tournaments %} +
- {% if ended_tournaments %} - {% for tournament in ended_tournaments %} - {% include 'tournaments/tournament_row.html' %} - {% endfor %} - -
+ {% endif %} +
{% endblock %} diff --git a/tournaments/templates/tournaments/bracket_match_cell.html b/tournaments/templates/tournaments/bracket_match_cell.html index 5b8051b..b7181b9 100644 --- a/tournaments/templates/tournaments/bracket_match_cell.html +++ b/tournaments/templates/tournaments/bracket_match_cell.html @@ -1,9 +1,9 @@ {% load static %} -
+
-
+
{% if match.bracket_name %} {% endif %} @@ -17,28 +17,31 @@ {% for team in match.teams %}
- {% if team.id %} - - {% endif %} - - {% if team.is_lucky_loser or team.walk_out == 1 %} -
- {% if team.is_lucky_loser %}(LL){% elif team.walk_out == 1 %}(WO){% endif %} -
- {% endif %} +
+ {% if team.id %} + + {% endif %} +
+ {% for name in team.names %} +
+ {% if name|length > 0 %} + {{ name }} + {% else %} +   + {% endif %} +
+ {% endfor %} +
+ {% if team.id and team.weight %} +
+ {% endif %} - {% for name in team.names %} -
- {% if name|length > 0 %} - {{ name }} - {% else %} -   + {% if team.is_lucky_loser or team.walk_out == 1 %} +
+ {% if match.should_show_lucky_loser_status and team.is_lucky_loser %}(LL){% endif %} +
{% endif %}
- {% endfor %} - {% if team.id %} - - {% endif %}
{% if match.has_walk_out %} @@ -64,8 +67,8 @@ {% endfor %}
-
-
+
+