merge main and migrations

sync_v2
Laurent 5 months ago
commit e81976889f
  1. 3
      .gitignore
  2. 16
      api/serializers.py
  3. 3
      api/urls.py
  4. 231
      api/views.py
  5. 4
      padelclub_backend/settings.py
  6. 2
      padelclub_backend/settings_app.py
  7. 6
      padelclub_backend/urls.py
  8. 1
      requirements.txt
  9. 165
      shop/admin.py
  10. 13
      shop/forms.py
  11. 25
      shop/management/commands/create_initial_shop_data.py
  12. 41
      shop/migrations/0027_shippingaddress_alter_order_payment_status_and_more.py
  13. 18
      shop/migrations/0028_alter_order_status.py
  14. 40
      shop/models.py
  15. 33
      shop/signals.py
  16. 226
      shop/static/shop/css/shop.css
  17. BIN
      shop/static/shop/images/products/PC008/blanc/PS_PA439_WHITE.png.avif
  18. BIN
      shop/static/shop/images/products/PC008/bleu-sport/PS_PA439_SPORTYNAVY.png.avif
  19. BIN
      shop/static/shop/images/products/PC008/kaki-fonce/PS_PA439_DARKKHAKI.png.avif
  20. BIN
      shop/static/shop/images/products/PC008/noir/PS_PA439_BLACK.png.avif
  21. BIN
      shop/static/shop/images/products/PC008/rose-clair/PS_PA439_PALEPINK.png.avif
  22. BIN
      shop/static/shop/images/products/PC008/sand/PS_PA439_SAND.png.avif
  23. BIN
      shop/static/shop/images/products/PC009/blanc/PS_PA438_WHITE.png.avif
  24. BIN
      shop/static/shop/images/products/PC009/bleu-sport/PS_PA438_SPORTYNAVY.png.avif
  25. BIN
      shop/static/shop/images/products/PC009/noir/PS_PA438_BLACK.png.avif
  26. BIN
      shop/static/shop/images/products/PC009/olive/PS_PA438_OLIVE.png.avif
  27. BIN
      shop/static/shop/images/products/PC009/rose-clair/PS_PA438_PALEPINK.png.avif
  28. BIN
      shop/static/shop/images/products/PC009/sand/PS_PA438_SAND.png.avif
  29. 60
      shop/stripe_utils.py
  30. 32
      shop/templates/admin/shop/order/change_status.html
  31. 52
      shop/templates/admin/shop/order/preparation_view.html
  32. 62
      shop/templates/shop/cart.html
  33. 15
      shop/templates/shop/checkout.html
  34. 81
      shop/templates/shop/my_orders.html
  35. 150
      shop/templates/shop/order_detail.html
  36. 12
      shop/templates/shop/partials/navigation_base.html
  37. 124
      shop/templates/shop/partials/order_items_display.html
  38. 15
      shop/templates/shop/payment.html
  39. 14
      shop/templates/shop/payment_cancel.html
  40. 14
      shop/templates/shop/payment_success.html
  41. 16
      shop/templates/shop/product_list.html
  42. 6
      shop/urls.py
  43. 261
      shop/views.py
  44. 69
      tournaments/admin.py
  45. 18
      tournaments/migrations/0118_tournament_animation_type.py
  46. 18
      tournaments/migrations/0119_alter_tournament_animation_type.py
  47. 33
      tournaments/migrations/0120_image.py
  48. 26
      tournaments/migrations/0121_alter_image_options_remove_image_is_primary_and_more.py
  49. 43
      tournaments/migrations/0122_groupstage_planned_start_date_and_more.py
  50. 18
      tournaments/migrations/0123_teamregistration_unique_random_index.py
  51. 33
      tournaments/migrations/0124_playerregistration_club_code_and_more.py
  52. 18
      tournaments/migrations/0125_tournament_unregister_delta_in_hours.py
  53. 24
      tournaments/migrations/0126_image_club_alter_image_event.py
  54. 14
      tournaments/migrations/0127_merge_20250604_1534.py
  55. 3
      tournaments/models/__init__.py
  56. 4
      tournaments/models/club.py
  57. 2
      tournaments/models/court.py
  58. 6
      tournaments/models/enums.py
  59. 1
      tournaments/models/group_stage.py
  60. 49
      tournaments/models/image.py
  61. 63
      tournaments/models/match.py
  62. 30
      tournaments/models/player_registration.py
  63. 4
      tournaments/models/round.py
  64. 42
      tournaments/models/team_registration.py
  65. 8
      tournaments/models/team_score.py
  66. 279
      tournaments/models/tournament.py
  67. 47
      tournaments/services/email_service.py
  68. 4
      tournaments/services/payment_service.py
  69. 82
      tournaments/services/tournament_registration.py
  70. 5
      tournaments/services/tournament_unregistration.py
  71. 14097
      tournaments/static/rankings/CLASSEMENT-PADEL-DAMES-05-2025.csv
  72. 14526
      tournaments/static/rankings/CLASSEMENT-PADEL-DAMES-06-2025.csv
  73. 80001
      tournaments/static/rankings/CLASSEMENT-PADEL-MESSIEURS-05-2025.csv
  74. 80001
      tournaments/static/rankings/CLASSEMENT-PADEL-MESSIEURS-06-2025.csv
  75. 119
      tournaments/static/tournaments/css/broadcast.css
  76. 20
      tournaments/static/tournaments/css/style.css
  77. 117
      tournaments/static/tournaments/css/tournament_bracket.css
  78. 96
      tournaments/static/tournaments/js/tournament_bracket.js
  79. 39
      tournaments/templates/profile.html
  80. 7
      tournaments/templates/register_tournament.html
  81. 17
      tournaments/templates/stripe/onboarding_complete.html
  82. 19
      tournaments/templates/stripe/refresh_account_link.html
  83. 14
      tournaments/templates/tournaments/broadcast/broadcast.html
  84. 5
      tournaments/templates/tournaments/broadcast/broadcast_base.html
  85. 1
      tournaments/templates/tournaments/broadcast/broadcast_club.html
  86. 38
      tournaments/templates/tournaments/broadcast/broadcasted_auto.html
  87. 32
      tournaments/templates/tournaments/broadcast/broadcasted_auto_event.html
  88. 31
      tournaments/templates/tournaments/broadcast/broadcasted_bracket.html
  89. 2
      tournaments/templates/tournaments/broadcast/broadcasted_group_stage.html
  90. 14
      tournaments/templates/tournaments/broadcast/broadcasted_group_stages.html
  91. 15
      tournaments/templates/tournaments/broadcast/broadcasted_matches.html
  92. 375
      tournaments/templates/tournaments/broadcast/broadcasted_planning.html
  93. 14
      tournaments/templates/tournaments/broadcast/broadcasted_prog.html
  94. 25
      tournaments/templates/tournaments/broadcast/broadcasted_rankings.html
  95. 25
      tournaments/templates/tournaments/broadcast/broadcasted_summons.html
  96. 13
      tournaments/templates/tournaments/match_cell.html
  97. 2
      tournaments/templates/tournaments/navigation_base.html
  98. 6
      tournaments/templates/tournaments/navigation_tournament.html
  99. 46
      tournaments/templates/tournaments/prog.html
  100. 13
      tournaments/templates/tournaments/team_stats.html
  101. Some files were not shown because too many files have changed in this diff Show More

3
.gitignore vendored

@ -173,3 +173,6 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
# Media files
media/
*/media/

@ -1,6 +1,6 @@
from rest_framework import serializers from rest_framework import serializers
from tournaments.models.court import Court from tournaments.models.court import Court
from tournaments.models import Club, LiveMatch, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, FailedApiCall, DateInterval, Log, DeviceToken, UnregisteredTeam, UnregisteredPlayer from tournaments.models import Club, LiveMatch, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, FailedApiCall, DateInterval, Log, DeviceToken, UnregisteredTeam, UnregisteredPlayer, Image
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from django.conf import settings from django.conf import settings
@ -238,3 +238,17 @@ class UnregisteredPlayerSerializer(serializers.ModelSerializer):
model = UnregisteredPlayer model = UnregisteredPlayer
fields = '__all__' fields = '__all__'
# ['id', 'team_registration_id', 'first_name', 'last_name', 'licence_id', 'rank', 'has_paid'] # ['id', 'team_registration_id', 'first_name', 'last_name', 'licence_id', 'rank', 'has_paid']
class ImageSerializer(serializers.ModelSerializer):
image_url = serializers.SerializerMethodField()
def get_image_url(self, obj):
if obj.image:
return self.context['request'].build_absolute_uri(obj.image.url)
return None
class Meta:
model = Image
fields = ['id', 'title', 'description', 'image', 'image_url', 'uploaded_at',
'event', 'image_type']
read_only_fields = ['id', 'uploaded_at', 'image_url']

@ -11,6 +11,7 @@ router.register(r'users', views.UserViewSet)
router.register(r'user-agents', views.ShortUserViewSet) router.register(r'user-agents', views.ShortUserViewSet)
router.register(r'clubs', views.ClubViewSet) router.register(r'clubs', views.ClubViewSet)
router.register(r'tournaments', views.TournamentViewSet) router.register(r'tournaments', views.TournamentViewSet)
router.register(r'images', views.ImageViewSet)
router.register(r'events', views.EventViewSet) router.register(r'events', views.EventViewSet)
router.register(r'rounds', views.RoundViewSet) router.register(r'rounds', views.RoundViewSet)
router.register(r'group-stages', views.GroupStageViewSet) router.register(r'group-stages', views.GroupStageViewSet)
@ -50,5 +51,7 @@ urlpatterns = [
# forgotten password # forgotten password
path('dj-rest-auth/', include('dj_rest_auth.urls')), path('dj-rest-auth/', include('dj_rest_auth.urls')),
path('stripe/create-account/', views.create_stripe_connect_account, name='create_stripe_account'),
path('stripe/create-account-link/', views.create_stripe_account_link, name='create_account_link'),
] ]

@ -1,5 +1,5 @@
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 .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, DrawLogSerializer, TournamentSerializer, UserSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, PurchaseSerializer, ShortUserSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer, CustomUserSerializer, UnregisteredTeamSerializer, UnregisteredPlayerSerializer, ImageSerializer
from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer, Image
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.response import Response from rest_framework.response import Response
@ -29,6 +29,9 @@ from django.core.files.storage import default_storage
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
import os import os
from django.http import HttpResponse from django.http import HttpResponse
import logging
logger = logging.getLogger(__name__)
@api_view(['GET']) @api_view(['GET'])
def user_by_token(request): def user_by_token(request):
@ -303,6 +306,34 @@ class ShortUserViewSet(viewsets.ModelViewSet):
def get_queryset(self): def get_queryset(self):
return self.request.user.agents return self.request.user.agents
class ImageViewSet(viewsets.ModelViewSet):
"""
Viewset for handling event image uploads and retrieval.
This allows umpires/organizers to upload images for events from the iOS app,
which can then be displayed on the event pages.
"""
serializer_class = ImageSerializer
queryset = Image.objects.all()
def get_queryset(self):
queryset = Image.objects.all()
# Filter by event
event_id = self.request.query_params.get('event_id')
image_type = self.request.query_params.get('image_type')
if event_id:
queryset = queryset.filter(event_id=event_id)
if image_type:
queryset = queryset.filter(image_type=image_type)
return queryset
def perform_create(self, serializer):
serializer.save()
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def process_refund(request, team_registration_id): def process_refund(request, team_registration_id):
@ -317,7 +348,7 @@ def process_refund(request, team_registration_id):
payment_service = PaymentService(request) payment_service = PaymentService(request)
players_serializer = PlayerRegistrationSerializer(team_registration.players_sorted_by_rank, many=True) players_serializer = PlayerRegistrationSerializer(team_registration.players_sorted_by_rank, many=True)
success, message, refund = payment_service.process_refund(team_registration_id) success, message, refund = payment_service.process_refund(team_registration_id, force_refund=True)
return Response({ return Response({
'success': success, 'success': success,
'message': message, 'message': message,
@ -329,46 +360,6 @@ def process_refund(request, team_registration_id):
'message': str(e) 'message': str(e)
}, status=400) }, 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']) @api_view(['POST'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def xls_to_csv(request): def xls_to_csv(request):
@ -381,8 +372,12 @@ def xls_to_csv(request):
file_path = os.path.join(directory, uploaded_file.name) file_path = os.path.join(directory, uploaded_file.name)
file_name = default_storage.save(file_path, ContentFile(uploaded_file.read())) file_name = default_storage.save(file_path, ContentFile(uploaded_file.read()))
logger.info(f'file saved at {file_name}')
full_path = default_storage.path(file_name)
logger.info(f'full_path = {full_path}')
# Check available sheets and look for 'inscriptions' # Check available sheets and look for 'inscriptions'
xls = pd.ExcelFile(file_name) xls = pd.ExcelFile(full_path)
sheet_names = xls.sheet_names sheet_names = xls.sheet_names
# Determine which sheet to use # Determine which sheet to use
@ -394,13 +389,14 @@ def xls_to_csv(request):
break break
# Convert to csv and save # Convert to csv and save
data_xls = pd.read_excel(file_name, sheet_name=target_sheet, index_col=None) data_xls = pd.read_excel(full_path, sheet_name=target_sheet, index_col=None)
csv_file_name = create_random_filename('players', 'csv') csv_file_name = create_random_filename('players', 'csv')
output_path = os.path.join(directory, csv_file_name) output_path = os.path.join(directory, csv_file_name)
data_xls.to_csv(output_path, sep=';', index=False, encoding='utf-8') full_output_path = default_storage.path(output_path)
data_xls.to_csv(full_output_path, sep=';', index=False, encoding='utf-8')
# Send the processed file back # Send the processed file back
with default_storage.open(output_path, 'rb') as file: with default_storage.open(full_output_path, 'rb') as file:
response = HttpResponse(file.read(), content_type='application/octet-stream') response = HttpResponse(file.read(), content_type='application/octet-stream')
response['Content-Disposition'] = f'attachment; filename="players.csv"' response['Content-Disposition'] = f'attachment; filename="players.csv"'
@ -431,3 +427,144 @@ def get_payment_config(request):
return Response({ return Response({
'stripe_fee': getattr(settings, 'STRIPE_FEE', 0) 'stripe_fee': getattr(settings, 'STRIPE_FEE', 0)
}) })
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def create_stripe_connect_account(request):
stripe.api_key = settings.STRIPE_SECRET_KEY
user = request.user
try:
# Create a new Standard account
account = stripe.Account.create(
type='standard',
metadata={
'padelclub_email': user.email,
'platform': 'padelclub'
}
)
return Response({
'success': True,
'account_id': account.id,
})
except Exception as e:
return Response({
'success': False,
'error': str(e)
}, status=400)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def create_stripe_account_link(request):
"""
Create an account link for a Stripe account.
Uses HTTPS URLs only - no custom URL schemes.
"""
stripe.api_key = settings.STRIPE_SECRET_KEY
# Parse request data
data = json.loads(request.body)
account_id = data.get('account_id')
if not account_id:
return Response({
'success': False,
'error': 'No Stripe account ID found'
}, status=400)
try:
base_path = f"{request.scheme}://{request.get_host()}"
refresh_url = f"{base_path}/stripe-refresh-account-link/"
return_url = f"{base_path}/stripe-onboarding-complete/"
# Generate the account link URL
account_link = stripe.AccountLink.create(
account=account_id,
refresh_url=refresh_url,
return_url=return_url,
type='account_onboarding',
)
return Response({
'success': True,
'url': account_link.url,
'account_id': account_id
})
except Exception as e:
return Response({
'success': False,
'error': str(e)
}, status=400)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def validate_stripe_account(request):
"""
Validate a Stripe account for a tournament.
Returns validation status and onboarding URL if needed.
"""
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': 'No account ID found to validate',
'needs_onboarding': True
}, status=200)
try:
# Validate the account with Stripe
account = stripe.Account.retrieve(account_id)
# Check account capabilities
charges_enabled = account.get('charges_enabled', False)
payouts_enabled = account.get('payouts_enabled', False)
details_submitted = account.get('details_submitted', False)
# Determine if the account is valid and ready
is_valid = account.id is not None
can_process_payments = charges_enabled and payouts_enabled
onboarding_complete = details_submitted
needs_onboarding = not (can_process_payments and onboarding_complete)
return Response({
'valid': is_valid,
'can_process_payments': can_process_payments,
'onboarding_complete': onboarding_complete,
'needs_onboarding': needs_onboarding,
'account': {
'id': account.id,
'charges_enabled': charges_enabled,
'payouts_enabled': payouts_enabled,
'details_submitted': details_submitted
}
})
except stripe.error.PermissionError:
# Account doesn't exist or isn't connected to your platform
return Response({
'valid': False,
'error': 'This Stripe account is not connected to your platform or does not exist.',
'needs_onboarding': True,
}, status=200)
except stripe.error.InvalidRequestError:
return Response({
'valid': False,
'error': 'Invalid account ID format',
'needs_onboarding': True,
}, status=200)
except Exception as e:
return Response({
'valid': False,
'error': f'Unexpected error: {str(e)}',
'needs_onboarding': True,
}, status=200)

@ -147,6 +147,10 @@ USE_L10N = True
STATIC_URL = '/static/' STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'static/') STATIC_ROOT = os.path.join(BASE_DIR, 'static/')
# Media files (User uploads)
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media/')
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field

@ -38,7 +38,7 @@ QR_CODE_CACHE_ALIAS = 'qr-code'
SYNC_APPS = { SYNC_APPS = {
'sync': {}, 'sync': {},
'tournaments': { 'exclude': ['Log', 'FailedApiCall', 'DeviceToken'] } 'tournaments': { 'exclude': ['Log', 'FailedApiCall', 'DeviceToken', 'Image'] }
} }
SYNC_MODEL_CHILDREN_SHARING = { SYNC_MODEL_CHILDREN_SHARING = {

@ -15,6 +15,8 @@ Including another URLconf
""" """
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [ urlpatterns = [
@ -27,3 +29,7 @@ urlpatterns = [
path('dj-auth/', include('django.contrib.auth.urls')), path('dj-auth/', include('django.contrib.auth.urls')),
] ]
# Serve media files in development
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

@ -17,3 +17,4 @@ django-filter==24.3
cryptography==41.0.7 cryptography==41.0.7
stripe==11.6.0 stripe==11.6.0
django-background-tasks==1.2.8 django-background-tasks==1.2.8
Pillow==10.2.0

@ -1,11 +1,19 @@
from django.contrib import admin from django.contrib import admin
from django.shortcuts import render from django.shortcuts import render, redirect
from .models import Product, Color, Size, Order, OrderItem, GuestUser, Coupon, CouponUsage, OrderStatus
from django.utils.html import format_html from django.utils.html import format_html
from django.urls import path
from django.http import HttpResponseRedirect
from django import forms
from .models import (
Product, Color, Size, Order, OrderItem, GuestUser, Coupon, CouponUsage,
OrderStatus, ShippingAddress
)
@admin.register(Product) @admin.register(Product)
class ProductAdmin(admin.ModelAdmin): class ProductAdmin(admin.ModelAdmin):
list_display = ("title", "ordering_value", "price", "cut") list_display = ("title", "ordering_value", "price", "cut")
search_fields = ["title", "description"] # Enable search for autocomplete
@admin.register(Color) @admin.register(Color)
class ColorAdmin(admin.ModelAdmin): class ColorAdmin(admin.ModelAdmin):
@ -34,14 +42,94 @@ class SizeAdmin(admin.ModelAdmin):
class OrderItemInline(admin.TabularInline): class OrderItemInline(admin.TabularInline):
model = OrderItem model = OrderItem
extra = 0 extra = 1 # Show one extra row for adding new items
readonly_fields = ('product', 'quantity', 'color', 'size', 'price') autocomplete_fields = ['product'] # Enable product search
fields = ('product', 'quantity', 'color', 'size', 'price')
@admin.register(OrderItem)
class OrderItemAdmin(admin.ModelAdmin):
list_display = ('order', 'product', 'quantity', 'color', 'size', 'price', 'get_total_price')
list_filter = ('product', 'color', 'size', 'order__status')
search_fields = ('order__id', 'product__title', 'order__user__email', 'order__guest_user__email')
autocomplete_fields = ['order', 'product']
list_editable = ('quantity', 'price')
def get_total_price(self, obj):
return obj.get_total_price()
get_total_price.short_description = 'Total Price'
get_total_price.admin_order_field = 'price' # Allows column to be sortable
@admin.register(ShippingAddress)
class ShippingAddressAdmin(admin.ModelAdmin):
list_display = ('street_address', 'city', 'postal_code', 'country')
search_fields = ('street_address', 'city', 'postal_code', 'country')
class ChangeOrderStatusForm(forms.Form):
_selected_action = forms.CharField(widget=forms.MultipleHiddenInput)
status = forms.ChoiceField(choices=OrderStatus.choices, label="New Status")
@admin.register(Order) @admin.register(Order)
class OrderAdmin(admin.ModelAdmin): class OrderAdmin(admin.ModelAdmin):
list_display = ('id', 'date_ordered', 'status', 'total_price') list_display = ('id', 'get_email', 'date_ordered', 'status', 'total_price', 'get_shipping_address')
inlines = [OrderItemInline] inlines = [OrderItemInline]
list_filter = ('status', 'payment_status') list_filter = ('status', 'payment_status')
readonly_fields = ('shipping_address_details',)
actions = ['change_order_status']
autocomplete_fields = ['user'] # Add this line for user search functionality
search_fields = ['id', 'user__email', 'user__username', 'guest_user__email'] # Add this line
def get_email(self, obj):
if obj.guest_user:
return obj.guest_user.email
else:
return obj.user.email
get_email.short_description = 'Email'
def get_shipping_address(self, obj):
if obj.shipping_address:
return f"{obj.shipping_address.street_address}, {obj.shipping_address.city}"
return "No shipping address"
get_shipping_address.short_description = 'Shipping Address'
def shipping_address_details(self, obj):
if obj.shipping_address:
return format_html(
"""
<div style="padding: 10px; background-color: #f9f9f9; border-radius: 4px;">
<strong>Street:</strong> {}<br>
{}
<strong>City:</strong> {}<br>
<strong>State:</strong> {}<br>
<strong>Postal Code:</strong> {}<br>
<strong>Country:</strong> {}
</div>
""",
obj.shipping_address.street_address,
f"<strong>Apartment:</strong> {obj.shipping_address.apartment}<br>" if obj.shipping_address.apartment else "",
obj.shipping_address.city,
obj.shipping_address.state,
obj.shipping_address.postal_code,
obj.shipping_address.country,
)
return "No shipping address set"
shipping_address_details.short_description = 'Shipping Address Details'
fieldsets = (
(None, {
'fields': ('user', 'guest_user', 'status', 'payment_status', 'total_price')
}),
('Shipping Information', {
'fields': ('shipping_address_details',),
}),
('Payment Details', {
'fields': ('stripe_payment_intent_id', 'stripe_checkout_session_id', 'stripe_mode'),
'classes': ('collapse',)
}),
('Discount Information', {
'fields': ('coupon', 'discount_amount'),
'classes': ('collapse',)
}),
)
def changelist_view(self, request, extra_context=None): def changelist_view(self, request, extra_context=None):
# If 'show_preparation' parameter is in the request, show the preparation view # If 'show_preparation' parameter is in the request, show the preparation view
@ -103,6 +191,73 @@ class OrderAdmin(admin.ModelAdmin):
context context
) )
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('prepare-all-orders/', self.admin_site.admin_view(self.prepare_all_orders), name='prepare_all_orders'),
path('prepare-order/<int:order_id>/', self.admin_site.admin_view(self.prepare_order), name='prepare_order'),
path('cancel-and-refund-order/<int:order_id>/', self.admin_site.admin_view(self.cancel_and_refund_order), name='cancel_and_refund_order'),
]
return custom_urls + urls
def prepare_all_orders(self, request):
if request.method == 'POST':
Order.objects.filter(status=OrderStatus.PAID).update(status=OrderStatus.PREPARED)
self.message_user(request, "All orders have been marked as prepared.")
return redirect('admin:shop_order_changelist')
def prepare_order(self, request, order_id):
if request.method == 'POST':
order = Order.objects.get(id=order_id)
order.status = OrderStatus.PREPARED
order.save()
self.message_user(request, f"Order #{order_id} has been marked as prepared.")
return redirect('admin:shop_order_changelist')
def cancel_and_refund_order(self, request, order_id):
if request.method == 'POST':
order = Order.objects.get(id=order_id)
try:
# Reuse the cancel_order logic from your views
from .views import cancel_order
cancel_order(request, order_id)
self.message_user(request, f"Order #{order_id} has been cancelled and refunded.")
except Exception as e:
self.message_user(request, f"Error cancelling order: {str(e)}", level='ERROR')
return redirect('admin:shop_order_changelist')
def change_order_status(self, request, queryset):
"""Admin action to change the status of selected orders"""
form = None
if 'apply' in request.POST:
form = ChangeOrderStatusForm(request.POST)
if form.is_valid():
status = form.cleaned_data['status']
count = 0
for order in queryset:
order.status = status
order.save()
count += 1
self.message_user(request, f"{count} orders have been updated to status '{OrderStatus(status).label}'.")
return HttpResponseRedirect(request.get_full_path())
if not form:
form = ChangeOrderStatusForm(initial={'_selected_action': request.POST.getlist('_selected_action')})
context = {
'title': 'Change Order Status',
'orders': queryset,
'form': form,
'action': 'change_order_status'
}
return render(request, 'admin/shop/order/change_status.html', context)
change_order_status.short_description = "Change status for selected orders"
class GuestUserOrderInline(admin.TabularInline): class GuestUserOrderInline(admin.TabularInline):
model = Order model = Order
extra = 0 extra = 0

@ -1,5 +1,6 @@
from django import forms from django import forms
from .models import Coupon from .models import Coupon
from .models import ShippingAddress
class GuestCheckoutForm(forms.Form): class GuestCheckoutForm(forms.Form):
email = forms.EmailField(required=True) email = forms.EmailField(required=True)
@ -7,3 +8,15 @@ class GuestCheckoutForm(forms.Form):
class CouponApplyForm(forms.Form): class CouponApplyForm(forms.Form):
code = forms.CharField(max_length=50) code = forms.CharField(max_length=50)
class ShippingAddressForm(forms.ModelForm):
class Meta:
model = ShippingAddress
fields = ['street_address', 'apartment', 'city', 'postal_code', 'country']
widgets = {
'street_address': forms.TextInput(attrs={'placeholder': 'Adresse'}),
'apartment': forms.TextInput(attrs={'placeholder': 'Appartement (optionnel)'}),
'city': forms.TextInput(attrs={'placeholder': 'Ville'}),
'postal_code': forms.TextInput(attrs={'placeholder': 'Code postal'}),
'country': forms.TextInput(attrs={'placeholder': 'Pays'}),
}

@ -18,10 +18,13 @@ class Command(BaseCommand):
{'name': 'Fuchsia', 'hex': '#C1366B', 'secondary_hex': None, 'ordering': 30}, {'name': 'Fuchsia', 'hex': '#C1366B', 'secondary_hex': None, 'ordering': 30},
{'name': 'Corail / Noir', 'hex': '#FF7F50', 'secondary_hex': '#000000', 'ordering': 40}, {'name': 'Corail / Noir', 'hex': '#FF7F50', 'secondary_hex': '#000000', 'ordering': 40},
{'name': 'Gris Foncé Chiné / Noir', 'hex': '#4D4D4D', 'secondary_hex': '#000000', 'ordering': 50}, {'name': 'Gris Foncé Chiné / Noir', 'hex': '#4D4D4D', 'secondary_hex': '#000000', 'ordering': 50},
{'name': 'Olive', 'hex': '#635E53', 'secondary_hex': None, 'ordering': 54},
{'name': 'Kaki Foncé', 'hex': '#707163', 'secondary_hex': None, 'ordering': 55}, {'name': 'Kaki Foncé', 'hex': '#707163', 'secondary_hex': None, 'ordering': 55},
{'name': 'Noir', 'hex': '#000000', 'secondary_hex': None, 'ordering': 60}, {'name': 'Noir', 'hex': '#000000', 'secondary_hex': None, 'ordering': 60},
{'name': 'Noir / Corail', 'hex': '#000000', 'secondary_hex': '#FF7F50', 'ordering': 61}, {'name': 'Noir / Corail', 'hex': '#000000', 'secondary_hex': '#FF7F50', 'ordering': 61},
{'name': 'Noir / Gris Foncé Chiné', 'hex': '#000000', 'secondary_hex': '#4D4D4D', 'ordering': 62}, {'name': 'Noir / Gris Foncé Chiné', 'hex': '#000000', 'secondary_hex': '#4D4D4D', 'ordering': 62},
{'name': 'Rose Clair', 'hex': '#E7C8CF', 'secondary_hex': None, 'ordering': 31},
{'name': 'Sand', 'hex': '#B4A885', 'secondary_hex': None, 'ordering': 32},
] ]
color_objects = {} color_objects = {}
@ -137,6 +140,28 @@ class Command(BaseCommand):
'sizes': ['S', 'M', 'L', 'XL', 'XXL', '3XL'], 'sizes': ['S', 'M', 'L', 'XL', 'XXL', '3XL'],
'image_filename': 'PS_PA1030_WHITE-SPORTYNAVY.png.avif' 'image_filename': 'PS_PA1030_WHITE-SPORTYNAVY.png.avif'
}, },
{
'sku': 'PC008',
'title': 'T-shirt Simple Femme',
'description': 'T-shirt simple avec logo coeur.',
'price': 20.00,
'ordering_value': 60,
'cut': 1, # Women
'colors': ['Blanc', 'Bleu Sport', 'Sand', 'Noir', 'Kaki Foncé', 'Rose Clair'],
'sizes': ['XS','S', 'M', 'L', 'XL', 'XXL'],
'image_filename': 'PS_PA439_WHITE.png.avif'
},
{
'sku': 'PC009',
'title': 'T-shirt Simple Homme',
'description': 'T-shirt simple avec logo coeur.',
'price': 20.00,
'ordering_value': 61,
'cut': 2, # Men
'colors': ['Blanc', 'Bleu Sport', 'Sand', 'Noir', 'Olive', 'Rose Clair'],
'sizes': ['XS','S', 'M', 'L', 'XL', 'XXL', '3XL'],
'image_filename': 'PS_PA438_WHITE.png.avif'
},
] ]
for product_data in products: for product_data in products:

@ -0,0 +1,41 @@
# Generated by Django 5.1 on 2025-05-06 10:21
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0026_alter_order_user'),
]
operations = [
migrations.CreateModel(
name='ShippingAddress',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('street_address', models.CharField(max_length=255)),
('apartment', models.CharField(blank=True, max_length=50, null=True)),
('city', models.CharField(max_length=100)),
('state', models.CharField(blank=True, max_length=100, null=True)),
('postal_code', models.CharField(max_length=20)),
('country', models.CharField(max_length=100)),
],
),
migrations.AlterField(
model_name='order',
name='payment_status',
field=models.CharField(choices=[('UNPAID', 'Unpaid'), ('PAID', 'Paid'), ('FAILED', 'Failed'), ('REFUNDED', 'Refunded')], default='UNPAID', max_length=20),
),
migrations.AlterField(
model_name='order',
name='status',
field=models.CharField(choices=[('PENDING', 'Pending'), ('PAID', 'Paid'), ('SHIPPED', 'Shipped'), ('DELIVERED', 'Delivered'), ('CANCELED', 'Canceled'), ('REFUNDED', 'Refunded'), ('PREPARED', 'Prepared')], default='PENDING', max_length=20),
),
migrations.AddField(
model_name='order',
name='shipping_address',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.shippingaddress'),
),
]

@ -0,0 +1,18 @@
# Generated by Django 5.1 on 2025-05-06 19:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0027_shippingaddress_alter_order_payment_status_and_more'),
]
operations = [
migrations.AlterField(
model_name='order',
name='status',
field=models.CharField(choices=[('PENDING', 'Pending'), ('PAID', 'Paid'), ('SHIPPED', 'Shipped'), ('DELIVERED', 'Delivered'), ('CANCELED', 'Canceled'), ('REFUNDED', 'Refunded'), ('PREPARED', 'Prepared'), ('READY', 'Ready')], default='PENDING', max_length=20),
),
]

@ -8,6 +8,9 @@ class OrderStatus(models.TextChoices):
SHIPPED = 'SHIPPED', 'Shipped' SHIPPED = 'SHIPPED', 'Shipped'
DELIVERED = 'DELIVERED', 'Delivered' DELIVERED = 'DELIVERED', 'Delivered'
CANCELED = 'CANCELED', 'Canceled' CANCELED = 'CANCELED', 'Canceled'
REFUNDED = 'REFUNDED', 'Refunded'
PREPARED = 'PREPARED', 'Prepared'
READY = 'READY', 'Ready'
class CutChoices(models.IntegerChoices): class CutChoices(models.IntegerChoices):
UNISEX = 0, 'Unisex' UNISEX = 0, 'Unisex'
@ -71,6 +74,14 @@ class CartItem(models.Model):
def get_total_price(self): def get_total_price(self):
return self.product.price * self.quantity return self.product.price * self.quantity
class ShippingAddress(models.Model):
street_address = models.CharField(max_length=255)
apartment = models.CharField(max_length=50, blank=True, null=True)
city = models.CharField(max_length=100)
state = models.CharField(max_length=100, blank=True, null=True)
postal_code = models.CharField(max_length=20)
country = models.CharField(max_length=100)
class GuestUser(models.Model): class GuestUser(models.Model):
email = models.EmailField() email = models.EmailField()
phone = models.CharField(max_length=20) phone = models.CharField(max_length=20)
@ -112,6 +123,7 @@ class Coupon(models.Model):
class Order(models.Model): class Order(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True)
shipping_address = models.ForeignKey(ShippingAddress, on_delete=models.SET_NULL, null=True, blank=True)
date_ordered = models.DateTimeField(auto_now_add=True) date_ordered = models.DateTimeField(auto_now_add=True)
status = models.CharField(max_length=20, choices=OrderStatus.choices, default=OrderStatus.PENDING) 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) total_price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00)
@ -122,6 +134,7 @@ class Order(models.Model):
('UNPAID', 'Unpaid'), ('UNPAID', 'Unpaid'),
('PAID', 'Paid'), ('PAID', 'Paid'),
('FAILED', 'Failed'), ('FAILED', 'Failed'),
('REFUNDED', 'Refunded')
]) ])
webhook_processed = models.BooleanField(default=False) webhook_processed = models.BooleanField(default=False)
stripe_mode = models.CharField(max_length=10, default='test', choices=[ stripe_mode = models.CharField(max_length=10, default='test', choices=[
@ -137,6 +150,33 @@ class Order(models.Model):
def get_total_after_discount(self): def get_total_after_discount(self):
return max(self.total_price - self.discount_amount, 0) return max(self.total_price - self.discount_amount, 0)
def is_cancellable(self):
"""Check if the order can be cancelled"""
return self.status in [OrderStatus.PENDING, OrderStatus.PAID]
def shipping_address_can_be_edited(self):
return self.status in [OrderStatus.PENDING, OrderStatus.PAID, OrderStatus.PREPARED, OrderStatus.READY]
def get_shipping_address(self):
"""
Returns a formatted string of the shipping address
"""
if not self.shipping_address:
return "Aucune adresse de livraison fournie"
address_parts = [
self.shipping_address.street_address,
self.shipping_address.apartment if self.shipping_address.apartment else None,
self.shipping_address.city,
self.shipping_address.state if self.shipping_address.state else None,
self.shipping_address.postal_code,
self.shipping_address.country
]
# Filter out None values and join with newlines
formatted_address = '\n'.join(part for part in address_parts if part)
return formatted_address
class OrderItem(models.Model): class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items') order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items')
product = models.ForeignKey(Product, on_delete=models.CASCADE) product = models.ForeignKey(Product, on_delete=models.CASCADE)

@ -70,11 +70,11 @@ def _get_order_details(instance):
# Translate statuses # Translate statuses
status_fr_map = { status_fr_map = {
"PENDING": "EN ATTENTE", "PAID": "PAYÉE", "PENDING": "EN ATTENTE", "PAID": "PAYÉE",
"SHIPPED": "EXPÉDIÉE", "DELIVERED": "LIVRÉE", "CANCELED": "ANNULÉE" "SHIPPED": "EXPÉDIÉE", "DELIVERED": "LIVRÉE", "CANCELED": "ANNULÉE", "REFUNDED": "REMBOURSÉE", "PREPARED": "EN COURS DE PRÉPARATION", "READY": "PRÊT"
} }
payment_status_fr_map = { payment_status_fr_map = {
"UNPAID": "NON PAYÉE", "PAID": "PAYÉE", "FAILED": "ÉCHOUÉE" "UNPAID": "NON PAYÉE", "PAID": "PAYÉE", "FAILED": "ÉCHOUÉE", "REFUNDED": "REMBOURSÉE",
} }
# Calculate discount information # Calculate discount information
@ -106,7 +106,8 @@ def _get_order_details(instance):
'customer_info': customer_info, 'customer_info': customer_info,
'customer_email': customer_email, 'customer_email': customer_email,
'date_ordered': instance.date_ordered, 'date_ordered': instance.date_ordered,
'admin_url': f"{settings.SHOP_SITE_ROOT_URL}{reverse('admin:shop_order_change', args=[instance.id])}" 'admin_url': f"{settings.SHOP_SITE_ROOT_URL}{reverse('admin:shop_order_change', args=[instance.id])}",
'shipping_address': instance.get_shipping_address(),
} }
def _build_items_list(order_id, action): def _build_items_list(order_id, action):
@ -153,6 +154,8 @@ Statut de paiement: {order_details['payment_status_fr']}
{order_details['customer_info']} {order_details['customer_info']}
{order_details['shipping_address']}
Articles: Articles:
{items_list} {items_list}
@ -195,7 +198,8 @@ def _send_customer_notification(instance, order_details, items_list):
order_details['final_price'], order_details['final_price'],
items_list, items_list,
contact_email, contact_email,
shop_url shop_url,
order_details['shipping_address']
) )
# Skip if no email content returned # Skip if no email content returned
@ -213,7 +217,7 @@ def _send_customer_notification(instance, order_details, items_list):
def _get_customer_email_content(status, payment_status, order_id, date, status_fr, def _get_customer_email_content(status, payment_status, order_id, date, status_fr,
total_price, has_coupon, coupon_info, discount_amount, total_price, has_coupon, coupon_info, discount_amount,
final_price, items_list, contact_email, shop_url): final_price, items_list, contact_email, shop_url, shipping_address):
"""Get the appropriate customer email content based on order status.""" """Get the appropriate customer email content based on order status."""
# Build price information with coupon details if applicable # Build price information with coupon details if applicable
@ -230,21 +234,24 @@ Montant payé: {final_price}€"""
'subject': f"Confirmation de votre commande #{order_id} - Padel Club", 'subject': f"Confirmation de votre commande #{order_id} - Padel Club",
'message': _build_payment_confirmation_email(order_id, date, status_fr, 'message': _build_payment_confirmation_email(order_id, date, status_fr,
price_info, items_list, price_info, items_list,
contact_email, shop_url) contact_email, shop_url, shipping_address)
} }
# Order status update email # Order status update email
elif status in [OrderStatus.SHIPPED, OrderStatus.DELIVERED, OrderStatus.CANCELED]: elif status in [OrderStatus.SHIPPED, OrderStatus.DELIVERED, OrderStatus.CANCELED, OrderStatus.PREPARED, OrderStatus.REFUNDED, OrderStatus.READY]:
status_message = { status_message = {
OrderStatus.PREPARED: "Votre commande est en cours de préparation.",
OrderStatus.READY: "Votre commande est prête.",
OrderStatus.SHIPPED: "Votre commande a été expédiée et est en cours de livraison.", OrderStatus.SHIPPED: "Votre commande a été expédiée et est en cours de livraison.",
OrderStatus.DELIVERED: "Votre commande a été livrée. Nous espérons que vous apprécierez vos produits !", OrderStatus.DELIVERED: "Votre commande a été livrée. Nous espérons que vous apprécierez vos produits !",
OrderStatus.REFUNDED: "Votre commande a été annulée et son remboursement est en cours de traitement.",
OrderStatus.CANCELED: "Votre commande a été annulée. Si vous n'êtes pas à l'origine de cette annulation, veuillez nous contacter immédiatement." OrderStatus.CANCELED: "Votre commande a été annulée. Si vous n'êtes pas à l'origine de cette annulation, veuillez nous contacter immédiatement."
}.get(status, "") }.get(status, "")
return { return {
'subject': f"Mise à jour de votre commande #{order_id} - Padel Club", 'subject': f"Mise à jour de votre commande #{order_id} - Padel Club",
'message': _build_status_update_email(order_id, date, status_message, status_fr, 'message': _build_status_update_email(order_id, date, status_message, status_fr,
price_info, items_list, contact_email) price_info, items_list, contact_email, shipping_address)
} }
# Payment issue notification # Payment issue notification
@ -266,7 +273,7 @@ Montant payé: {final_price}€"""
# No email needed # No email needed
return None return None
def _build_payment_confirmation_email(order_id, date, status_fr, price_info, items_list, contact_email, shop_url): def _build_payment_confirmation_email(order_id, date, status_fr, price_info, items_list, contact_email, shop_url, shipping_address):
"""Build payment confirmation email message.""" """Build payment confirmation email message."""
return f""" return f"""
Bonjour, Bonjour,
@ -282,7 +289,10 @@ Détail de votre commande :
{items_list} {items_list}
IMPORTANT - COMMENT RÉCUPÉRER VOTRE COMMANDE : IMPORTANT - COMMENT RÉCUPÉRER VOTRE COMMANDE :
Notre boutique fonctionne entre amis 'Padel Club'. Nous allons préparer votre commande et vous la remettre en main propre lors d'une prochaine session de padel ! Aucune expédition n'est prévue, nous vous remettrons directement vos articles sur place. Notre boutique fonctionne entre amis 'Padel Club'.
Nous allons préparer votre commande et vous la remettre en main propre lors d'une prochaine session de padel !
Si jamais la livraison est possible, nous vous expédierons votre commande à l'adresse indiquée. Vous serez alors notifiés par email lorsque votre commande sera expédiée.
{shipping_address}
Pour toute question concernant votre commande, n'hésitez pas à contacter notre service client : Pour toute question concernant votre commande, n'hésitez pas à contacter notre service client :
{contact_email} {contact_email}
@ -295,7 +305,7 @@ Merci de votre confiance et à bientôt sur Padel Club !
L'équipe Padel Club L'équipe Padel Club
""" """
def _build_status_update_email(order_id, date, status_message, status_fr, price_info, items_list, contact_email): def _build_status_update_email(order_id, date, status_message, status_fr, price_info, items_list, contact_email, shipping_address):
"""Build status update email message.""" """Build status update email message."""
return f""" return f"""
Bonjour, Bonjour,
@ -303,6 +313,7 @@ Bonjour,
Mise à jour concernant votre commande Padel Club #{order_id} du {date} : Mise à jour concernant votre commande Padel Club #{order_id} du {date} :
{status_message} {status_message}
{shipping_address}
Statut actuel: {status_fr} Statut actuel: {status_fr}
{price_info} {price_info}

@ -546,3 +546,229 @@ v .cart-table {
border: 3px solid #90ee90 !important; /* Use your light-green color */ border: 3px solid #90ee90 !important; /* Use your light-green color */
transform: scale(1.1); /* Makes the selected color slightly larger */ transform: scale(1.1); /* Makes the selected color slightly larger */
} }
.status-badge {
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
color: white;
display: inline-block;
}
.status-badge.pending {
background-color: #f39200;
}
.status-badge.paid {
background-color: #27ae60;
}
.status-badge.shipped {
background-color: #3498db;
}
.status-badge.delivered {
background-color: #2c3e50;
}
.status-badge.canceled {
background-color: #e74c3c;
}
.status-badge.refunded {
background-color: #e74c3c;
}
.status-badge.prepared {
background-color: #27ae60;
}
.original-price {
text-decoration: line-through;
color: #777;
font-size: 0.9em;
display: block;
font-weight: bold;
}
.discounted-price {
font-weight: bold;
}
.view-btn {
background-color: #3498db;
color: white;
border: none;
padding: 5px 10px;
border-radius: 12px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: background-color 0.3s ease;
}
.view-btn:hover {
background-color: #2980b9;
color: white;
}
.inline-form {
display: inline;
}
.empty-orders {
text-align: center;
padding: 20px;
}
.actions {
text-align: center;
}
.order-meta {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 20px;
border-bottom: 1px solid #eee;
padding-bottom: 15px;
}
.discount-section {
margin-top: 20px;
border-top: 1px dashed #eee;
padding-top: 15px;
}
.discount-row {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
}
.total-row {
font-weight: bold;
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid #eee;
}
.coupon-info {
margin-top: 10px;
font-size: 0.9em;
color: #666;
}
.order-actions {
margin-top: 20px;
text-align: right;
padding-top: 15px;
border-top: 1px solid #eee;
}
.order-items-section {
margin-bottom: 20px;
}
.order-items-section h3 {
margin-bottom: 10px;
padding-bottom: 5px;
border-bottom: 1px solid #eee;
}
.order-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
border-radius: 12px;
overflow: hidden;
padding: 0px;
}
.order-table tbody tr.odd-row {
background-color: #f0f0f0;
}
.order-table tbody tr.even-row {
background-color: #e8e8e8;
}
.shipping-address-section {
padding: 15px;
background: #f9f9f9;
border-radius: 5px;
}
.address-details {
margin: 10px 0;
}
.address-actions {
margin-top: 10px;
}
.edit-address-btn,
.add-address-btn {
background-color: #007bff;
color: white;
border: none;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
}
.edit-address-btn:hover,
.add-address-btn:hover {
background-color: #0056b3;
}
.shipping-address-form {
margin-top: 15px;
padding: 15px;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
}
.form-actions {
margin-top: 10px;
display: flex;
gap: 10px;
}
.save-btn {
background-color: #27ae60;
color: white;
border: none;
padding: 5px 15px;
border-radius: 4px;
cursor: pointer;
}
.cancel-btn {
background-color: #dc3545;
color: white;
border: none;
padding: 5px 15px;
border-radius: 4px;
cursor: pointer;
}
.save-btn:hover {
background-color: #218838;
}
.cancel-btn:hover {
background-color: #c82333;
}
.address-input {
padding: 8px;
border: 1px solid #ddd;
border-radius: 12px;
font-size: 14px;
}
.address-section {
margin: 20px 0;
padding: 15px;
border-radius: 5px;
}
#address-message {
margin-top: 10px;
font-size: 14px;
}

@ -76,5 +76,65 @@ class StripeService:
"""Retrieve a payment intent by ID""" """Retrieve a payment intent by ID"""
return stripe.PaymentIntent.retrieve(payment_intent_id) return stripe.PaymentIntent.retrieve(payment_intent_id)
def create_refund(self, payment_intent_id, amount=None, reason=None):
"""
Create a refund for a payment intent
Args:
payment_intent_id (str): The payment intent ID to refund
amount (int, optional): Amount to refund in cents. If None, refunds the entire amount.
reason (str, optional): The reason for the refund, one of 'duplicate', 'fraudulent', or 'requested_by_customer'
Returns:
stripe.Refund: The created refund object
"""
try:
refund_params = {
'payment_intent': payment_intent_id,
}
if amount is not None:
refund_params['amount'] = amount
if reason in ['duplicate', 'fraudulent', 'requested_by_customer']:
refund_params['reason'] = reason
# Log the refund attempt
mode_str = "TEST" if self.is_test_mode else "LIVE"
logger.info(f"[{mode_str}] Creating refund for payment intent {payment_intent_id}")
# Process the refund
refund = stripe.Refund.create(**refund_params)
# Log success
logger.info(f"Refund created successfully: {refund.id}")
return refund
except stripe.error.StripeError as e:
# Log the error
logger.error(f"Stripe error creating refund: {str(e)}")
raise
except Exception as e:
# Log any other errors
logger.error(f"Unexpected error creating refund: {str(e)}")
raise
def get_refund(self, refund_id):
"""
Retrieve a refund by ID
Args:
refund_id (str): The ID of the refund to retrieve
Returns:
stripe.Refund: The refund object
"""
try:
return stripe.Refund.retrieve(refund_id)
except stripe.error.StripeError as e:
logger.error(f"Stripe error retrieving refund {refund_id}: {str(e)}")
raise
# Create a singleton instance for import and use throughout the app # Create a singleton instance for import and use throughout the app
stripe_service = StripeService() stripe_service = StripeService()

@ -0,0 +1,32 @@
{% extends "admin/base_site.html" %}
{% load i18n l10n admin_urls %}
{% block content %}
<div class="module">
<h2>{{ title }}</h2>
<div>
<p>Change status for the following {{ orders|length }} orders:</p>
<ul>
{% for order in orders %}
<li>{{ order }}</li>
{% endfor %}
</ul>
</div>
<form action="{% url 'admin:shop_order_changelist' %}" method="post">
{% csrf_token %}
<input type="hidden" name="action" value="{{ action }}" />
{{ form.as_p }}
{% for obj in orders %}
<input type="hidden" name="_selected_action" value="{{ obj.pk }}" />
{% endfor %}
<div class="actions">
<input type="submit" name="apply" value="Change Status" class="default" />
<a href="{% url 'admin:shop_order_changelist' %}" class="button cancel-link">Cancel</a>
</div>
</form>
</div>
{% endblock %}

@ -5,8 +5,20 @@
<p>Total orders with status PAID: {{ total_orders }}</p> <p>Total orders with status PAID: {{ total_orders }}</p>
<p>Total items to prepare: {{ total_items }}</p> <p>Total items to prepare: {{ total_items }}</p>
<button onclick="window.print()" style="margin-bottom: 20px">Print This Page</button> <div style="margin-bottom: 20px;">
<a href="?" class="button" style="margin-left: 10px">Back to Orders</a> <button onclick="window.print()" style="margin-right: 10px">Print This Page</button>
<a href="?" class="button" style="margin-right: 10px">Back to Orders</a>
<!-- Global prepare button -->
<form method="POST" action="{% url 'admin:prepare_all_orders' %}" style="display: inline;">
{% csrf_token %}
<button type="submit"
onclick="return confirm('Are you sure you want to mark all orders as prepared?')"
style="background-color: #28a745; color: white; border: none; padding: 5px 10px; cursor: pointer;">
Mark All Orders as Prepared
</button>
</form>
</div>
<h2>Items Summary</h2> <h2>Items Summary</h2>
<table style="width: 100%; border-collapse: collapse;"> <table style="width: 100%; border-collapse: collapse;">
@ -35,7 +47,7 @@
<td style="padding: 8px; border-bottom: 1px solid #ddd; font-weight: bold;">{{ item.quantity }}</td> <td style="padding: 8px; border-bottom: 1px solid #ddd; font-weight: bold;">{{ item.quantity }}</td>
<td style="padding: 8px; border-bottom: 1px solid #ddd;"> <td style="padding: 8px; border-bottom: 1px solid #ddd;">
{% for order_id in item.orders %} {% for order_id in item.orders %}
<a href="../{{ order_id }}/change/">Order #{{ order_id }}</a>{% if not forloop.last %}, {% endif %} <a href="../order/{{ order_id }}/change/">Order #{{ order_id }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %} {% endfor %}
</td> </td>
</tr> </tr>
@ -54,13 +66,15 @@
<th style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">Order #</th> <th style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">Order #</th>
<th style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">Date</th> <th style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">Date</th>
<th style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">Customer</th> <th style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">Customer</th>
<th style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">Shipping Address</th>
<th style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">Items</th> <th style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">Items</th>
<th style="text-align: left; padding: 8px; border-bottom: 1px solid #ddd;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for order in orders %} {% for order in orders %}
<tr> <tr>
<td style="padding: 8px; border-bottom: 1px solid #ddd;"><a href="../{{ order.id }}/change/">Order #{{ order.id }}</a></td> <td style="padding: 8px; border-bottom: 1px solid #ddd;"><a href="../order/{{ order.id }}/change/">Order #{{ order.id }}</a></td>
<td style="padding: 8px; border-bottom: 1px solid #ddd;">{{ order.date_ordered|date:"Y-m-d H:i" }}</td> <td style="padding: 8px; border-bottom: 1px solid #ddd;">{{ order.date_ordered|date:"Y-m-d H:i" }}</td>
<td style="padding: 8px; border-bottom: 1px solid #ddd;"> <td style="padding: 8px; border-bottom: 1px solid #ddd;">
{% if order.user %} {% if order.user %}
@ -71,6 +85,16 @@
Unknown Unknown
{% endif %} {% endif %}
</td> </td>
<td style="padding: 8px; border-bottom: 1px solid #ddd;">
{% if order.shipping_address %}
{{ order.shipping_address.street_address }}
{% if order.shipping_address.apartment %}, {{ order.shipping_address.apartment }}{% endif %}<br>
{{ order.shipping_address.postal_code }} {{ order.shipping_address.city }}<br>
{{ order.shipping_address.state }}, {{ order.shipping_address.country }}
{% else %}
No shipping address
{% endif %}
</td>
<td style="padding: 8px; border-bottom: 1px solid #ddd;"> <td style="padding: 8px; border-bottom: 1px solid #ddd;">
{% for item in order.items.all %} {% for item in order.items.all %}
{{ item.quantity }}x {{ item.product.title }} {{ item.quantity }}x {{ item.product.title }}
@ -79,10 +103,28 @@
<br> <br>
{% endfor %} {% endfor %}
</td> </td>
<td style="padding: 8px; border-bottom: 1px solid #ddd;">
<form method="POST" action="{% url 'admin:prepare_order' order.id %}" style="display: inline;">
{% csrf_token %}
<button type="submit"
style="background-color: #28a745; color: white; border: none; padding: 5px 10px; margin-right: 5px; cursor: pointer;">
Mark as Prepared
</button>
</form>
<form method="POST" action="{% url 'admin:cancel_and_refund_order' order.id %}" style="display: inline;">
{% csrf_token %}
<button type="submit"
onclick="return confirm('Are you sure you want to cancel and refund this order?')"
style="background-color: #dc3545; color: white; border: none; padding: 5px 10px; cursor: pointer;">
Cancel & Refund
</button>
</form>
</td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="4" style="padding: 8px; text-align: center;">No orders found</td> <td colspan="6" style="padding: 8px; text-align: center;">No orders found</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

@ -5,17 +5,7 @@
{% block second_title %}La Boutique{% endblock %} {% block second_title %}La Boutique{% endblock %}
{% block content %} {% block content %}
<nav class="margin10"> {% include 'shop/partials/navigation_base.html' %}
<a href="{% url 'shop:product_list' %}">La Boutique</a>
<a href="{% url 'index' %}" class="orange">Accueil</a>
<a href="{% url 'clubs' %}" class="orange">Clubs</a>
{% if user.is_authenticated %}
<a href="{% url 'my-tournaments' %}" class="orange">Mes tournois</a>
<a href="{% url 'profile' %}">Mon compte</a>
{% else %}
<a href="{% url 'login' %}">Se connecter</a>
{% endif %}
</nav>
{% if STRIPE_IS_TEST_MODE %} {% if STRIPE_IS_TEST_MODE %}
<div class="alert alert-warning" style="background-color: #fff3cd; color: #856404; padding: 10px; margin: 10px 0; border-radius: 5px; border: 1px solid #ffeeba;"> <div class="alert alert-warning" style="background-color: #fff3cd; color: #856404; padding: 10px; margin: 10px 0; border-radius: 5px; border: 1px solid #ffeeba;">
<strong> Test Mode:</strong> Stripe is currently in test mode. No real payments will be processed. <strong> Test Mode:</strong> Stripe is currently in test mode. No real payments will be processed.
@ -26,18 +16,18 @@
<div class="grid-x"> <div class="grid-x">
<div class="small-12 medium-9 large-6 padding10"> <div class="small-12 medium-9 large-6 padding10">
<h1 class="club padding10 topmargin20">Votre panier</h1> <h1 class="club padding10">Votre panier</h1>
<div class="bubble"> <div class="bubble">
{% if display_data.items %} {% if display_data.items %}
<div class="info-box" style="background-color: #f8f9fa; border-left: 4px solid #4e73df; padding: 15px; margin: 15px 0; border-radius: 5px;"> <div class="info-box" style="background-color: #f8f9fa; border-left: 4px solid #4e73df; padding: 15px; margin: 15px 0; border-radius: 4px;">
<h4 style="color: #4e73df; margin-top: 0;">Comment fonctionne la livraison ?</h4> <h4 style="color: #4e73df; margin-top: 0;">Comment fonctionne la livraison ?</h4>
<p>Cette boutique fonctionne entre amis 'Padel Club'. Les commandes sont :</p> <p>Cette boutique fonctionne entre amis 'Padel Club'. Les commandes sont :</p>
<ol style="padding-left: 20px; margin-bottom: 0;"> <ol style="padding-left: 20px; margin-bottom: 0;">
<li>Passées en ligne via notre système</li> <li>Passées en ligne via notre système</li>
<li>Préparées par notre équipe</li> <li>Préparées par notre équipe</li>
<li>Remises en main propre lors d'une prochaine session de padel</li> <li>Remises en main propre lors d'une prochaine session de padel ou livrées à l'adresse indiquée dans la mesure du possible</li>
</ol> </ol>
<p style="margin-top: 10px; margin-bottom: 0;">Pas d'expédition : nous vous remettrons votre commande personnellement au club !</p> <p style="margin-top: 10px; margin-bottom: 0;">Livraison : En général, nous vous remettrons votre commande personnellement au club. Les livraisons peuvent être possible en fonction du lieu, n'hésitez donc pas à indiquer une adresse de livraison.</p>
</div> </div>
{% include 'shop/partials/order_items_display.html' with items=display_data.items total_quantity=display_data.total_quantity total_price=display_data.total_price edit_mode=True %} {% include 'shop/partials/order_items_display.html' with items=display_data.items total_quantity=display_data.total_quantity total_price=display_data.total_price edit_mode=True %}
@ -67,6 +57,24 @@
</div> </div>
</div> </div>
<div class="coupon-section">
<div>Adresse de livraison (dans la mesure du possible)</div>
<div class="coupon-form"">
<input type="text" id="street-address" class="address-input" style="flex-grow: 1; width: 100%;" placeholder="Adresse">
<div style="margin-bottom: 10px;">
<input type="text" id="apartment" class="address-input" style="width: 100%;" placeholder="Appartement (optionnel)">
</div>
<div style="display: flex; gap: 10px;">
<input type="text" id="postal-code" class="address-input" style="width: 25%;" placeholder="Code postal">
<input type="text" id="city" class="address-input" style="width: 25%;" placeholder="Ville">
<input type="text" id="country" class="address-input" style="width: 25%;" placeholder="Pays">
</div>
</div>
<div id="address-message"></div>
</div>
<div class="cart-summary"> <div class="cart-summary">
{% if user.is_authenticated %} {% if user.is_authenticated %}
@ -124,6 +132,24 @@
const discountAmount = document.getElementById('discount-amount'); const discountAmount = document.getElementById('discount-amount');
const finalTotal = document.getElementById('final-total'); const finalTotal = document.getElementById('final-total');
// Get address input elements directly
const streetAddress = document.getElementById('street-address');
const apartment = document.getElementById('apartment');
const postalCode = document.getElementById('postal-code');
const city = document.getElementById('city');
const country = document.getElementById('country');
// Function to collect shipping address data
function getShippingData() {
return {
street_address: streetAddress ? streetAddress.value : '',
apartment: apartment ? apartment.value : '',
postal_code: postalCode ? postalCode.value : '',
city: city ? city.value : '',
country: country ? country.value : ''
};
}
// Initial values // Initial values
const originalTotal = parseFloat('{{ display_data.total_price }}'); const originalTotal = parseFloat('{{ display_data.total_price }}');
@ -244,6 +270,9 @@
checkoutButton.textContent = 'Chargement...'; checkoutButton.textContent = 'Chargement...';
checkoutButton.disabled = true; checkoutButton.disabled = true;
// Get shipping data
const shippingData = getShippingData();
// Create order and get checkout session // Create order and get checkout session
fetch('{% url "shop:create_checkout_session" %}', { fetch('{% url "shop:create_checkout_session" %}', {
method: 'POST', method: 'POST',
@ -251,6 +280,9 @@
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}' 'X-CSRFToken': '{{ csrf_token }}'
}, },
body: JSON.stringify({
shipping_address: shippingData
}),
credentials: 'same-origin', credentials: 'same-origin',
}) })
.then(function(response) { .then(function(response) {

@ -6,18 +6,9 @@
{% block content %} {% block content %}
<nav class="margin10"> {% include 'shop/partials/navigation_base.html' %}
<a href="{% url 'shop:product_list' %}">La Boutique</a>
<a href="{% url 'index' %}" class="orange">Accueil</a> <h1 class="club padding10">Validation de la commande</h1>
<a href="{% url 'clubs' %}" class="orange">Clubs</a>
{% if user.is_authenticated %}
<a href="{% url 'my-tournaments' %}" class="orange">Mes tournois</a>
<a href="{% url 'profile' %}">Mon compte</a>
{% else %}
<a href="{% url 'login' %}">Se connecter</a>
{% endif %}
</nav>
<h1 class="club padding10 topmargin20">Validation de la commande</h1>
<div class="grid-x"> <div class="grid-x">
<div class="small-12 medium-6 large-6 padding10"> <div class="small-12 medium-6 large-6 padding10">
<div class="bubble checkout-container"> <div class="bubble checkout-container">

@ -0,0 +1,81 @@
{% extends 'tournaments/base.html' %}
{% block head_title %}Mes Commandes{% endblock %}
{% block first_title %}Padel Club{% endblock %}
{% block second_title %}Mes Commandes{% endblock %}
{% block content %}
{% include 'shop/partials/navigation_base.html' %}
<div class="grid-x">
<div class="cell medium-12 large-6 padding10">
<h1 class="club padding10">Mes Commandes</h1>
<div class="bubble">
{% if orders %}
<table class="order-table">
<tbody>
{% for order in orders %}
<tr class="{% cycle 'odd-row' 'even-row' %}">
<td class="text-left">Commande #{{ order.id }}</td>
<td>
<a href="{% url 'shop:order_detail' order.id %}" class="view-btn">Détails</a>
</td>
<td class="text-left">{{ order.date_ordered|date:"d/m/Y H:i" }}</td>
<td class="text-left">
{% if order.status == 'PENDING' %}
<span class="status-badge pending">En attente</span>
{% elif order.status == 'PAID' %}
<span class="status-badge paid">Payée</span>
{% elif order.status == 'PREPARED' %}
<span class="status-badge prepared">En cours de préparation</span>
{% elif order.status == 'SHIPPED' %}
<span class="status-badge shipped">Expédiée</span>
{% elif order.status == 'DELIVERED' %}
<span class="status-badge delivered">Livrée</span>
{% elif order.status == 'CANCELED' %}
<span class="status-badge canceled">Annulée</span>
{% elif order.status == 'REFUNDED' %}
<span class="status-badge refunded">Remboursée</span>
{% endif %}
</td>
<td class="price-column">
{% if order.discount_amount > 0 %}
<span class="original-price">{{ order.total_price }}€</span>
<span class="discounted-price">{{ order.get_total_after_discount }}€</span>
{% else %}
{{ order.total_price }}€
{% endif %}
</td>
<td class="actions">
{% if order.status == 'PENDING' or order.status == 'PAID' %}
<form method="post" action="{% url 'shop:cancel_order' order.id %}" class="inline-form" onsubmit="return confirm('Êtes-vous sûr de vouloir annuler cette commande?');">
{% csrf_token %}
<button type="submit" class="remove-btn">Annuler</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-orders">
<p>Vous n'avez pas encore de commandes.</p>
<a href="{% url 'shop:product_list' %}" class="checkout-button confirm-nav-button">Découvrir la boutique</a>
</div>
{% endif %}
</div>
{% if messages %}
<div class="messages">
{% for message in messages %}
<div class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endblock %}

@ -0,0 +1,150 @@
{% extends 'tournaments/base.html' %}
{% block head_title %}Détail de commande{% endblock %}
{% block first_title %}Padel Club{% endblock %}
{% block second_title %}Détail de commande{% endblock %}
{% block content %}
{% include 'shop/partials/navigation_base.html' %}
<div class="grid-x">
<div class="cell medium-8 large-8 padding10">
<h1 class="club padding10">Commande #{{ order.id }}</h1>
<div class="bubble">
<div class="order-meta">
<div class="order-date">
<strong>Date:</strong> {{ order.date_ordered|date:"d/m/Y H:i" }}
</div>
<div class="order-status">
<strong>Statut:</strong>
{% if order.status == 'PENDING' %}
<span class="status-badge pending">En attente</span>
{% elif order.status == 'PREPARED' %}
<span class="status-badge prepared">En cours de préparation</span>
{% elif order.status == 'PAID' %}
<span class="status-badge paid">Payée</span>
{% elif order.status == 'SHIPPED' %}
<span class="status-badge shipped">Expédiée</span>
{% elif order.status == 'DELIVERED' %}
<span class="status-badge delivered">Livrée</span>
{% elif order.status == 'CANCELED' %}
<span class="status-badge canceled">Annulée</span>
{% elif order.status == 'REFUNDED' %}
<span class="status-badge refunded">Remboursée</span>
{% endif %}
</div>
</div>
<div class="order-items-section">
<h3>Produits</h3>
{% with items=order_items total_quantity=total_quantity total_price=order.total_price %}
{% include 'shop/partials/order_items_display.html' with items=items total_quantity=total_quantity total_price=total_price edit_mode=False cancel_mode=order.is_cancellable %}
{% endwith %}
</div>
<div class="coupon-section">
<div>Adresse de livraison (dans la mesure du possible)</div>
{% if order.shipping_address %}
<div class="address-details" style="margin: 10px 0; padding: 10px; background: #f8f9fa; border-radius: 4px;">
<p style="margin: 0;">{{ order.shipping_address.street_address }}</p>
{% if order.shipping_address.apartment %}
<p style="margin: 5px 0;">{{ order.shipping_address.apartment }}</p>
{% endif %}
<p style="margin: 0;">{{ order.shipping_address.postal_code }} {{ order.shipping_address.city }}, {{ order.shipping_address.country }}</p>
</div>
{% if order.shipping_address_can_be_edited %}
<button class="edit-address-btn confirm-nav-button" style="margin-top: 10px;" onclick="toggleAddressForm()">Modifier l'adresse</button>
<div id="address-form-container" style="display: none; margin-top: 10px;">
<form method="post" action="{% url 'shop:update_shipping_address' order.id %}" class="coupon-form">
{% csrf_token %}
<input type="text" name="street_address" class="address-input" style="width: 100%;" placeholder="Adresse" value="{{ order.shipping_address.street_address }}">
<div style="margin: 10px 0;">
<input type="text" name="apartment" class="address-input" style="width: 100%;" placeholder="Appartement (optionnel)" value="{{ order.shipping_address.apartment|default:'' }}">
</div>
<div style="display: flex; gap: 10px;">
<input type="text" name="postal_code" class="address-input" style="width: 25%;" placeholder="Code postal" value="{{ order.shipping_address.postal_code }}">
<input type="text" name="city" class="address-input" style="width: 25%;" placeholder="Ville" value="{{ order.shipping_address.city }}">
<input type="text" name="country" class="address-input" style="width: 25%;" placeholder="Pays" value="{{ order.shipping_address.country }}">
</div>
<button type="submit" class="save-btn confirm-nav-button">Enregistrer</button>
</form>
</div>
{% endif %}
{% else %}
<p>Aucune adresse de livraison renseignée</p>
{% if order.shipping_address_can_be_edited %}
<button class="add-address-btn confirm-nav-button" onclick="toggleAddressForm()">Ajouter une adresse</button>
<div id="address-form-container" style="display: none; margin-top: 10px;">
<form method="post" action="{% url 'shop:update_shipping_address' order.id %}" class="coupon-form">
{% csrf_token %}
<input type="text" name="street_address" class="address-input" style="width: 100%;" placeholder="Adresse">
<div style="margin: 10px 0;">
<input type="text" name="apartment" class="address-input" style="width: 100%;" placeholder="Appartement (optionnel)">
</div>
<div style="display: flex; gap: 10px;">
<input type="text" name="postal_code" class="address-input" style="width: 25%;" placeholder="Code postal">
<input type="text" name="city" class="address-input" style="width: 25%;" placeholder="Ville">
<input type="text" name="country" class="address-input" style="width: 25%;" placeholder="Pays">
</div>
<button type="submit" class="save-btn confirm-nav-button">Enregistrer</button>
</form>
</div>
{% endif %}
{% endif %}
</div>
{% if order.discount_amount > 0 %}
<div class="discount-section">
<div class="discount-row">
<span>Sous-total:</span>
<span class="price-value">{{ order.total_price }}€</span>
</div>
<div class="discount-row">
<span>Réduction:</span>
<span class="price-value">-{{ order.discount_amount }}€</span>
</div>
<div class="discount-row total-row">
<span>Total final:</span>
<span class="price-value">{{ order.get_total_after_discount }}€</span>
</div>
{% if order.coupon %}
<div class="coupon-info">
Coupon appliqué: {{ order.coupon.code }}
</div>
{% endif %}
</div>
{% endif %}
<div class="order-actions">
{% if order.status == 'PENDING' or order.status == 'PAID' %}
<form method="post" action="{% url 'shop:cancel_order' order.id %}" class="inline-form" onsubmit="return confirm('Êtes-vous sûr de vouloir annuler cette commande? Cette action est irréversible.');">
{% csrf_token %}
<button type="submit" class="remove-btn">Annuler la commande</button>
</form>
{% endif %}
</div>
</div>
</div>
{% if messages %}
<div class="messages">
{% for message in messages %}
<div class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
</div>
<script>
function toggleAddressForm() {
const formContainer = document.getElementById('address-form-container');
const isHidden = formContainer.style.display === 'none';
formContainer.style.display = isHidden ? 'block' : 'none';
}
</script>
{% endblock %}

@ -0,0 +1,12 @@
<nav class="margin10">
<a href="{% url 'index' %}" class="orange">Accueil</a>
<a href="{% url 'clubs' %}" class="orange">Clubs</a>
{% if user.is_authenticated %}
<a href="{% url 'my-tournaments' %}" class="orange">Mes tournois</a>
<a href="{% url 'shop:my_orders' %}">Mes commandes</a>
<a href="{% url 'profile' %}">Mon compte</a>
{% else %}
<a href="{% url 'custom-login' %}">Se connecter</a>
{% endif %}
<a href="{% url 'shop:product_list' %}">La boutique</a>
</nav>

@ -1,60 +1,68 @@
<table class="cart-table"> <table class="cart-table">
<tbody> <tbody>
{% for item in items %} {% for item in items %}
<tr class="{% cycle 'odd-row' 'even-row' %}"> <tr class="{% cycle 'odd-row' 'even-row' %}">
<td class="text-left product-name" data-label="Produit">{{ item.product_title }}</td> <td class="text-left product-name" data-label="Produit">{{ item.product_title }}</td>
{% if item.product_description %} {% if item.product_description %}
<td class="text-left product-description" data-label="Description">{{ item.product_description }}</td> <td class="text-left product-description" data-label="Description">{{ item.product_description }}</td>
{% endif %} {% endif %}
<td class="product-color" data-label="Couleur"> <td class="product-color" data-label="Couleur">
<div class="color-display"> <div class="color-display">
<div class="color-sample-cart" <div class="color-sample-cart"
{% if item.secondary_color_hex %} {% if item.secondary_color_hex %}
style="background-image: linear-gradient(to right, {{ item.color_hex }} 50%, {{ item.secondary_color_hex }} 50%);" style="background-image: linear-gradient(to right, {{ item.color_hex }} 50%, {{ item.secondary_color_hex }} 50%);"
{% else %} {% else %}
style="background-color: {{ item.color_hex }};" style="background-color: {{ item.color_hex }};"
{% endif %} {% endif %}
></div> ></div>
{{ item.color_name }} | {{ item.size_name }} {{ item.color_name }} | {{ item.size_name }}
</div> </div>
</td> </td>
<td class="product-quantity" data-label="Quantité"> <td class="product-quantity" data-label="Quantité">
{% if edit_mode %} {% if edit_mode %}
<div class="quantity-controls"> <div class="quantity-controls">
<form method="post" action="{% url 'shop:update_cart_item' %}" class="quantity-form"> <form method="post" action="{% url 'shop:update_cart_item' %}" class="quantity-form">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="item_id" value="{{ item.id }}"> <input type="hidden" name="item_id" value="{{ item.id }}">
<button type="submit" name="action" value="decrease" class="quantity-btn" {% if item.quantity <= 1 %}disabled{% endif %}>-</button> <button type="submit" name="action" value="decrease" class="quantity-btn" {% if item.quantity <= 1 %}disabled{% endif %}>-</button>
<span class="quantity-value">{{ item.quantity }}</span> <span class="quantity-value">{{ item.quantity }}</span>
<button type="submit" name="action" value="increase" class="quantity-btn">+</button> <button type="submit" name="action" value="increase" class="quantity-btn">+</button>
</form> </form>
</div> </div>
{% else %} {% else %}
<span>x {{ item.quantity }}</span> <span>x {{ item.quantity }}</span>
{% endif %} {% endif %}
</td> </td>
<td class="price-column product-price" data-label="Prix">{{ item.total_price }} €</td> <td class="price-column product-price" data-label="Prix">{{ item.total_price }} €</td>
{% if edit_mode %} {% if edit_mode %}
<td class="product-actions"> <td class="product-actions">
<form method="post" action="{% url 'shop:remove_from_cart' %}" class="remove-form"> <form method="post" action="{% url 'shop:remove_from_cart' %}" class="remove-form">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="item_id" value="{{ item.id }}"> <input type="hidden" name="item_id" value="{{ item.id }}">
<button type="submit" class="remove-btn">retirer</button> <button type="submit" class="remove-btn">retirer</button>
</form> </form>
</td> </td>
{% endif %} {% elif cancel_mode and items.count > 1 %}
</tr> <td class="product-actions">
{% endfor %} <form method="post" action="{% url 'shop:cancel_order_item' order.id item.id %}" class="remove-form"
</tbody> onsubmit="return confirm('Êtes-vous sûr de vouloir annuler cet article ? {% if order.status == 'PAID' %}Un remboursement sera effectué.{% endif %}');">
<tfoot> {% csrf_token %}
<tr> <button type="submit" class="remove-btn">annuler</button>
<td class="total-quantity" data-label="total-quantity">{{ total_quantity }} produit(s)</td> </form>
<td class="total-label text-left"></td> </td>
<td class="total-label text-left"></td> {% endif %}
<td class="price-column total-price" data-label="total-price">{{ total_price }} €</td> </tr>
{% if edit_mode %} {% endfor %}
<td class="total-label text-left"></td> </tbody>
{% endif %} <tfoot>
</tr> <tr>
</tfoot> <td class="total-quantity" data-label="total-quantity">{{ total_quantity }} produit(s)</td>
<td class="total-label text-left"></td>
<td class="total-label text-left"></td>
<td class="price-column total-price" data-label="total-price">{{ total_price }} €</td>
{% if edit_mode or cancel_mode %}
<td class="total-label text-left"></td>
{% endif %}
</tr>
</tfoot>
</table> </table>

@ -12,21 +12,12 @@
<small>Use test card: 4242 4242 4242 4242 with any future date and any CVC.</small> <small>Use test card: 4242 4242 4242 4242 with any future date and any CVC.</small>
</div> </div>
{% endif %} {% endif %}
<nav class="margin10"> {% include 'shop/partials/navigation_base.html' %}
<a href="{% url 'shop:product_list' %}">La Boutique</a>
<a href="{% url 'index' %}" class="orange">Accueil</a>
<a href="{% url 'clubs' %}" class="orange">Clubs</a>
{% if user.is_authenticated %}
<a href="{% url 'my-tournaments' %}" class="orange">Mes tournois</a>
<a href="{% url 'profile' %}">Mon compte</a>
{% else %}
<a href="{% url 'login' %}">Se connecter</a>
{% endif %}
</nav>
<div class="grid-x"> <div class="grid-x">
<div class="cell medium-6 large-6 padding10"> <div class="cell medium-6 large-6 padding10">
<h1 class="club padding10 topmargin20">Résumé de votre commande</h1> <h1 class="club padding10">Résumé de votre commande</h1>
<div class="bubble"> <div class="bubble">
{% include 'shop/partials/order_items_display.html' with items=display_data.items total_quantity=display_data.total_quantity total_price=display_data.total_price edit_mode=False %} {% include 'shop/partials/order_items_display.html' with items=display_data.items total_quantity=display_data.total_quantity total_price=display_data.total_price edit_mode=False %}

@ -7,21 +7,11 @@
{% block content %} {% block content %}
<nav class="margin10"> {% include 'shop/partials/navigation_base.html' %}
<a href="{% url 'shop:product_list' %}">La Boutique</a>
<a href="{% url 'index' %}" class="orange">Accueil</a>
<a href="{% url 'clubs' %}" class="orange">Clubs</a>
{% if user.is_authenticated %}
<a href="{% url 'my-tournaments' %}" class="orange">Mes tournois</a>
<a href="{% url 'profile' %}">Mon compte</a>
{% else %}
<a href="{% url 'login' %}">Se connecter</a>
{% endif %}
</nav>
<div class="grid-x"> <div class="grid-x">
<div class="cell medium-6 large-6 padding10"> <div class="cell medium-6 large-6 padding10">
<h1 class="club padding10 topmargin20">Paiement</h1> <h1 class="club padding10">Paiement</h1>
<div class="bubble"> <div class="bubble">
<h2>Le paiement a été annulé</h2> <h2>Le paiement a été annulé</h2>
<p>Votre commande n'a pas été finalisée car le paiement a été annulé.</p> <p>Votre commande n'a pas été finalisée car le paiement a été annulé.</p>

@ -5,21 +5,11 @@
{% block second_title %}La Boutique{% endblock %} {% block second_title %}La Boutique{% endblock %}
{% block content %} {% block content %}
<nav class="margin10"> {% include 'shop/partials/navigation_base.html' %}
<a href="{% url 'shop:product_list' %}">La Boutique</a>
<a href="{% url 'index' %}" class="orange">Accueil</a>
<a href="{% url 'clubs' %}" class="orange">Clubs</a>
{% if user.is_authenticated %}
<a href="{% url 'my-tournaments' %}" class="orange">Mes tournois</a>
<a href="{% url 'profile' %}">Mon compte</a>
{% else %}
<a href="{% url 'login' %}">Se connecter</a>
{% endif %}
</nav>
<div class="grid-x"> <div class="grid-x">
<div class="cell medium-6 large-6 padding10"> <div class="cell medium-6 large-6 padding10">
<h1 class="club padding10 topmargin20">Paiement réussi</h1> <h1 class="club padding10">Paiement réussi</h1>
<div class="bubble"> <div class="bubble">
<h2>Merci pour votre commande !</h2> <h2>Merci pour votre commande !</h2>
<p>Votre paiement a été traité avec succès.</p> <p>Votre paiement a été traité avec succès.</p>

@ -6,22 +6,12 @@
{% block content %} {% block content %}
<nav class="margin10"> {% include 'shop/partials/navigation_base.html' %}
<a href="{% url 'shop:product_list' %}">La Boutique</a>
<a href="{% url 'index' %}" class="orange">Accueil</a>
<a href="{% url 'clubs' %}" class="orange">Clubs</a>
{% if user.is_authenticated %}
<a href="{% url 'my-tournaments' %}" class="orange">Mes tournois</a>
<a href="{% url 'profile' %}">Mon compte</a>
{% else %}
<a href="{% url 'login' %}">Se connecter</a>
{% endif %}
</nav>
<div class="info-box" style="border-left: 4px solid #f39200; padding: 12px; margin: 20px 12px;"> <div class="info-box" style="border-left: 4px solid #f39200; padding: 12px; margin: 20px 12px;">
<h3 style="color: #505050; margin-top: 0;">Bienvenue sur la boutique Padel Club des copains !</h3> <h3 style="color: #505050; margin-top: 0;">Bienvenue sur la boutique Padel Club des copains !</h3>
<p style="margin-top: 10px; margin-bottom: 0;"><strong>Photos :</strong> Les photos des vêtements n'ont pas encore tous le logo, la description indique où il situera. <p style="margin-top: 10px; margin-bottom: 0;"><strong>Photos :</strong> Les photos des vêtements n'ont pas encore tous le logo, la description indique où il se situera.
<p style="margin-top: 10px; margin-bottom: 0;"><strong>Livraison :</strong> Commandez en ligne et récupérez votre commande en main propre lors de votre prochaine session de padel au club !</p> <p style="margin-top: 10px; margin-bottom: 0;"><strong>Livraison :</strong> Commandez en ligne et récupérez votre commande en main propre lors de votre prochaine session de padel au club. La livraison peut être possible !</p>
</div> </div>
<nav class="margin10"> <nav class="margin10">

@ -24,5 +24,9 @@ urlpatterns = [
path('debug/simulate-payment-failure/', views.simulate_payment_failure, name='simulate_payment_failure'), path('debug/simulate-payment-failure/', views.simulate_payment_failure, name='simulate_payment_failure'),
path('apply-coupon/', views.apply_coupon, name='apply_coupon'), path('apply-coupon/', views.apply_coupon, name='apply_coupon'),
path('remove-coupon/', views.remove_coupon, name='remove_coupon'), path('remove-coupon/', views.remove_coupon, name='remove_coupon'),
path('my-orders/', views.my_orders, name='my_orders'),
path('order/<int:order_id>/', views.order_detail, name='order_detail'),
path('cancel-order/<int:order_id>/', views.cancel_order, name='cancel_order'),
path('cancel-order-item/<int:order_id>/<int:item_id>/', views.cancel_order_item, name='cancel_order_item'),
path('order/<int:order_id>/update-shipping/', views.update_shipping_address, name='update_shipping_address'),
] ]

@ -13,6 +13,8 @@ from django.views.decorators.csrf import ensure_csrf_cookie
from django.http import HttpResponse from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.utils import timezone from django.utils import timezone
from .forms import ShippingAddressForm
import json # Add this with your other imports
from . import cart from . import cart
@ -143,16 +145,20 @@ def view_cart(request):
total = cart.get_cart_total(request) total = cart.get_cart_total(request)
total_quantity = cart_items.aggregate(total_quantity=Sum('quantity'))['total_quantity'] total_quantity = cart_items.aggregate(total_quantity=Sum('quantity'))['total_quantity']
display_data = prepare_item_display_data(cart_items, is_cart=True) display_data = prepare_item_display_data(cart_items, is_cart=True)
context = { context = {
'display_data': display_data, 'display_data': display_data,
'total': total, 'total': total,
'total_quantity': total_quantity, 'total_quantity': total_quantity,
'settings': settings, # Add this line to pass settings to template 'settings': settings,
} }
# Add Stripe publishable key for authenticated users # Add shipping form and Stripe key for authenticated users
if request.user.is_authenticated: if request.user.is_authenticated:
context['stripe_publishable_key'] = settings.STRIPE_PUBLISHABLE_KEY context.update({
'stripe_publishable_key': settings.STRIPE_PUBLISHABLE_KEY,
'shipping_form': ShippingAddressForm()
})
return render(request, 'shop/cart.html', context) return render(request, 'shop/cart.html', context)
@ -457,19 +463,32 @@ def create_checkout_session(request):
if not request.user.is_authenticated: if not request.user.is_authenticated:
return JsonResponse({'error': 'User must be authenticated'}, status=403) return JsonResponse({'error': 'User must be authenticated'}, status=403)
# Create the order shipping_address = None
order = create_order(request) # Parse shipping address data from request
try:
data = json.loads(request.body)
shipping_data = data.get('shipping_address', {})
except json.JSONDecodeError:
shipping_data = {}
# Validate shipping address
shipping_form = ShippingAddressForm(shipping_data)
if shipping_form.is_valid():
shipping_address = shipping_form.save()
# Create the order with shipping address
order = create_order(request)
if not order: if not order:
return JsonResponse({'error': 'Could not create order from cart'}, status=400) return JsonResponse({'error': 'Could not create order from cart'}, status=400)
# Get order items # Attach shipping address to order
order_items = order.items.all() order.shipping_address = shipping_address
order.save()
# Create line items # Create line items and checkout session as before
order_items = order.items.all()
line_items = _create_stripe_line_items(order_items) line_items = _create_stripe_line_items(order_items)
# Create checkout session
try: try:
checkout_session = _create_stripe_checkout_session(request, order, line_items) checkout_session = _create_stripe_checkout_session(request, order, line_items)
return JsonResponse({'id': checkout_session.id}) return JsonResponse({'id': checkout_session.id})
@ -752,3 +771,227 @@ def remove_coupon(request):
if 'coupon_id' in request.session: if 'coupon_id' in request.session:
del request.session['coupon_id'] del request.session['coupon_id']
return JsonResponse({'status': 'success', 'message': 'Coupon supprimé'}) return JsonResponse({'status': 'success', 'message': 'Coupon supprimé'})
def my_orders(request):
"""Display all orders for the logged-in user"""
if not request.user.is_authenticated:
messages.error(request, "Vous devez être connecté pour voir vos commandes.")
return redirect('login')
# Get all orders for the current user, ordered by date (newest first)
orders = Order.objects.filter(user=request.user).order_by('-date_ordered')
return render(request, 'shop/my_orders.html', {
'orders': orders,
})
def order_detail(request, order_id):
"""Display details for a specific order"""
if not request.user.is_authenticated:
messages.error(request, "Vous devez être connecté pour voir vos commandes.")
return redirect('login')
# Get the order, ensuring it belongs to the current user
order = get_object_or_404(Order, id=order_id, user=request.user)
# Get order items
order_items = order.items.all()
# Calculate total quantity
total_quantity = sum(item.quantity for item in order_items)
# Transform order items to match the display format expected by the template
items_for_display = []
for item in order_items:
items_for_display.append({
'id': item.id,
'product_title': item.product.title,
'product_description': item.product.description,
'color_name': item.color.name if item.color else 'N/A',
'color_hex': item.color.colorHex if item.color else '#FFFFFF',
'secondary_color_hex': item.color.secondary_hex_color if item.color and item.color.secondary_hex_color else None,
'size_name': item.size.name if item.size else 'N/A',
'quantity': item.quantity,
'total_price': item.get_total_price()
})
shipping_form = ShippingAddressForm(instance=order.shipping_address)
return render(request, 'shop/order_detail.html', {
'order': order,
'order_items': items_for_display,
'total_quantity': total_quantity,
'shipping_form': shipping_form,
})
@require_POST
def cancel_order(request, order_id):
"""Cancel an order and process refund if applicable"""
if not request.user.is_authenticated:
messages.error(request, "Vous devez être connecté pour annuler une commande.")
return redirect('login')
order = get_object_or_404(Order, id=order_id, user=request.user)
# Check if order can be cancelled
if not order.is_cancellable():
messages.error(request, "Cette commande ne peut pas être annulée.")
return redirect('shop:my_orders')
print("Order cancellation initiated", order.status, order.stripe_payment_intent_id)
# Process refund for paid orders
if order.status == OrderStatus.PAID and order.stripe_payment_intent_id:
try:
# Attempt to refund through Stripe
refund = stripe_service.create_refund(
order.stripe_payment_intent_id,
reason='requested_by_customer'
)
# Update order status
order.status = OrderStatus.REFUNDED
order.payment_status = 'REFUNDED' # Or 'REFUNDED' if you add this status
order.save()
messages.success(request, "Votre commande a été annulée et remboursée avec succès.")
except Exception as e:
print(f"Refund error: {str(e)}")
messages.error(request, "Un problème est survenu lors du remboursement. Veuillez contacter le support.")
else:
# For pending orders, just cancel without refund
order.status = OrderStatus.CANCELED
order.save()
messages.success(request, "Votre commande a été annulée avec succès.")
# Redirect back to the page they came from, or order list
referring_page = request.META.get('HTTP_REFERER')
if referring_page and 'order_detail' in referring_page:
return redirect('shop:order_detail', order_id=order.id)
else:
return redirect('shop:my_orders')
@require_POST
def cancel_order_item(request, order_id, item_id):
"""Cancel a specific item in an order and process partial refund if applicable"""
if not request.user.is_authenticated:
messages.error(request, "Vous devez être connecté pour annuler un article.")
return redirect('login')
# Get the order and ensure it belongs to the user
order = get_object_or_404(Order, id=order_id, user=request.user)
# Check if order can be modified
if not order.is_cancellable():
messages.error(request, "Cette commande ne peut plus être modifiée.")
return redirect('shop:order_detail', order_id=order.id)
# Get the specific item
try:
order_item = order.items.get(id=item_id)
except OrderItem.DoesNotExist:
messages.error(request, "Article non trouvé.")
return redirect('shop:order_detail', order_id=order.id)
# Calculate refund amount for this item
item_total = order_item.get_total_price()
# If this is the last item, cancel the whole order
remaining_items = order.items.exclude(id=item_id)
if not remaining_items.exists():
return cancel_order(request, order_id)
# Process refund for paid orders
if order.status == OrderStatus.PAID and order.stripe_payment_intent_id:
try:
# Calculate proportional discount if any
if order.discount_amount > 0:
discount_ratio = item_total / order.total_price
item_discount = order.discount_amount * discount_ratio
refund_amount = item_total - item_discount
else:
refund_amount = item_total
# Create partial refund through Stripe
refund = stripe_service.create_refund(
order.stripe_payment_intent_id,
amount=int(refund_amount * 100), # Convert to cents
reason='requested_by_customer'
)
# Update order totals
order.total_price -= item_total
if order.discount_amount > 0:
order.discount_amount -= item_discount
order.save()
# Delete the item
order_item.delete()
messages.success(request, "L'article a été annulé et remboursé avec succès.")
except Exception as e:
print(f"Refund error: {str(e)}")
messages.error(request, "Un problème est survenu lors du remboursement. Veuillez contacter le support.")
else:
# For pending orders, just remove the item and update totals
order.total_price -= item_total
order.save()
order_item.delete()
messages.success(request, "L'article a été annulé avec succès.")
return redirect('shop:order_detail', order_id=order.id)
def checkout_view(request):
if request.method == 'POST':
guest_form = GuestCheckoutForm(request.POST)
shipping_form = ShippingAddressForm(request.POST)
if guest_form.is_valid() and shipping_form.is_valid():
guest_user = guest_form.save()
shipping_address = shipping_form.save()
# Create order with shipping address
order = Order.objects.create(
guest_user=guest_user,
shipping_address=shipping_address,
# ... other order fields
)
return redirect('shop:order_detail', order_id=order.id)
else:
guest_form = GuestCheckoutForm()
shipping_form = ShippingAddressForm()
context = {
'form': guest_form,
'shipping_form': shipping_form,
}
return render(request, 'shop/checkout.html', context)
def update_shipping_address(request, order_id):
order = get_object_or_404(Order, id=order_id)
# Check if order can be edited
if not order.shipping_address_can_be_edited():
messages.error(request, "L'adresse ne peut plus être modifiée pour cette commande.")
return redirect('shop:order_detail', order_id=order.id)
if request.method == 'POST':
form = ShippingAddressForm(request.POST, instance=order.shipping_address)
print("Shipping address")
if form.is_valid():
shipping_address = form.save()
print("Shipping address updated")
if not order.shipping_address:
order.shipping_address = shipping_address
order.save()
messages.success(request, "L'adresse de livraison a été mise à jour.")
return redirect('shop:order_detail', order_id=order.id)
else:
form = ShippingAddressForm(instance=order.shipping_address)
context = {
'form': form,
'order': order
}
return redirect('shop:order_detail', order_id=order.id)

@ -6,7 +6,7 @@ from django.utils.html import escape
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import mark_safe 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 .models import Club, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, Court, DateInterval, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer, Image
from .forms import CustomUserCreationForm, CustomUserChangeForm from .forms import CustomUserCreationForm, CustomUserChangeForm
from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter, StartDateRangeFilter from .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter, StartDateRangeFilter
@ -45,13 +45,42 @@ class CustomUserAdmin(UserAdmin):
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)
class EventAdmin(SyncedObjectAdmin): class EventAdmin(SyncedObjectAdmin):
list_display = ['creation_date', 'name', 'club', 'creator', 'creator_full_name', 'tenup_id'] list_display = ['creation_date', 'name', 'club', 'creator', 'creator_full_name', 'tenup_id', 'display_images']
list_filter = ['creator', 'tenup_id'] list_filter = ['creator', 'tenup_id']
raw_id_fields = ['creator'] raw_id_fields = ['creator']
ordering = ['-creation_date'] ordering = ['-creation_date']
readonly_fields = ['display_images_preview']
fieldsets = [
(None, {'fields': ['name', 'club', 'creator', 'creation_date', 'tenup_id']}),
('Images', {'fields': ['display_images_preview'], 'classes': ['collapse']}),
]
def display_images(self, obj):
count = obj.images.count()
return count if count > 0 else '-'
display_images.short_description = 'Images'
def display_images_preview(self, obj):
html = '<div style="display: flex; flex-wrap: wrap; gap: 10px;">'
for image in obj.images.all():
html += f'''
<div style="text-align: center; margin-bottom: 15px;">
<img src="{image.image.url}" style="max-width: 150px; max-height: 150px; object-fit: contain;" />
<p style="margin: 5px 0 0 0; font-size: 12px;">
<strong>{image.title or "Untitled"}</strong><br>
Type: {image.get_image_type_display()}<br>
</p>
</div>
'''
html += '</div>'
if not obj.images.exists():
html = '<p>No images uploaded for this event.</p>'
return mark_safe(html)
display_images_preview.short_description = 'Images Preview'
class TournamentAdmin(SyncedObjectAdmin): class TournamentAdmin(SyncedObjectAdmin):
list_display = ['display_name', 'event', 'is_private', 'start_date', 'payment', 'creator'] list_display = ['display_name', 'event', 'is_private', 'start_date', 'payment', 'creator', 'is_canceled']
list_filter = [StartDateRangeFilter, 'is_deleted', 'event__creator'] list_filter = [StartDateRangeFilter, 'is_deleted', 'event__creator']
ordering = ['-start_date'] ordering = ['-start_date']
search_fields = ['id'] search_fields = ['id']
@ -157,6 +186,39 @@ class UnregisteredPlayerAdmin(admin.ModelAdmin):
list_filter = [] list_filter = []
ordering = ['last_name', 'first_name'] ordering = ['last_name', 'first_name']
class ImageAdmin(admin.ModelAdmin):
list_display = ['title', 'event', 'image_type', 'order', 'uploaded_at', 'file_size', 'image_preview_small']
list_filter = ['event', 'image_type', 'uploaded_at']
search_fields = ['title', 'description', 'event__name']
ordering = ['order']
readonly_fields = ['id', 'uploaded_at', 'image_preview', 'file_size']
raw_id_fields = ['event']
def image_preview(self, obj):
if obj.image:
return mark_safe(f'<img src="{obj.image.url}" width="150" height="auto" style="max-height: 150px; object-fit: contain;" />')
return "No Image"
image_preview.short_description = 'Preview'
def image_preview_small(self, obj):
if obj.image:
return mark_safe(f'<img src="{obj.image.url}" width="50" height="auto" style="max-height: 50px; object-fit: contain;" />')
return "No Image"
image_preview_small.short_description = 'Preview'
def file_size(self, obj):
if obj.image and hasattr(obj.image, 'size'):
# Convert bytes to KB or MB
size_bytes = obj.image.size
if size_bytes < 1024:
return f"{size_bytes} bytes"
elif size_bytes < 1024 * 1024:
return f"{size_bytes/1024:.1f} KB"
else:
return f"{size_bytes/(1024*1024):.1f} MB"
return "Unknown"
file_size.short_description = 'File Size'
action_flags = { action_flags = {
ADDITION: 'Addition', ADDITION: 'Addition',
@ -220,3 +282,4 @@ admin.site.register(DeviceToken, DeviceTokenAdmin)
admin.site.register(DrawLog, DrawLogAdmin) admin.site.register(DrawLog, DrawLogAdmin)
admin.site.register(UnregisteredTeam, UnregisteredTeamAdmin) admin.site.register(UnregisteredTeam, UnregisteredTeamAdmin)
admin.site.register(UnregisteredPlayer, UnregisteredPlayerAdmin) admin.site.register(UnregisteredPlayer, UnregisteredPlayerAdmin)
admin.site.register(Image, ImageAdmin)

@ -0,0 +1,18 @@
# Generated by Django 5.1 on 2025-05-03 05:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0117_playerregistration_user_teamregistration_user_and_more'),
]
operations = [
migrations.AddField(
model_name='tournament',
name='animation_type',
field=models.IntegerField(choices=[(0, 'Tournoi'), (1, 'Mêlée')], default=0),
),
]

@ -0,0 +1,18 @@
# Generated by Django 5.1 on 2025-05-06 07:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0118_tournament_animation_type'),
]
operations = [
migrations.AlterField(
model_name='tournament',
name='animation_type',
field=models.IntegerField(choices=[(0, 'Tournoi'), (1, 'Mêlée'), (2, 'Classement'), (3, 'Consolation')], default=0),
),
]

@ -0,0 +1,33 @@
# Generated by Django 5.1 on 2025-05-07 07:58
import django.db.models.deletion
import django.utils.timezone
import tournaments.models.image
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0119_alter_tournament_animation_type'),
]
operations = [
migrations.CreateModel(
name='Image',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(blank=True, max_length=255)),
('description', models.TextField(blank=True)),
('image', models.ImageField(upload_to=tournaments.models.image.image_upload_path)),
('uploaded_at', models.DateTimeField(default=django.utils.timezone.now)),
('image_type', models.CharField(choices=[('sponsor', 'Sponsor'), ('club', 'Club')], default='sponsor', max_length=20)),
('is_primary', models.BooleanField(default=False)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='tournaments.event')),
],
options={
'ordering': ['-uploaded_at'],
},
),
]

@ -0,0 +1,26 @@
# Generated by Django 5.1 on 2025-05-07 11:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0120_image'),
]
operations = [
migrations.AlterModelOptions(
name='image',
options={'ordering': ['order']},
),
migrations.RemoveField(
model_name='image',
name='is_primary',
),
migrations.AddField(
model_name='image',
name='order',
field=models.IntegerField(default=0),
),
]

File diff suppressed because one or more lines are too long

@ -0,0 +1,18 @@
# Generated by Django 5.1 on 2025-05-13 09:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0122_groupstage_planned_start_date_and_more'),
]
operations = [
migrations.AddField(
model_name='teamregistration',
name='unique_random_index',
field=models.IntegerField(default=0),
),
]

@ -0,0 +1,33 @@
# Generated by Django 5.1 on 2025-05-19 09:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0123_teamregistration_unique_random_index'),
]
operations = [
migrations.AddField(
model_name='playerregistration',
name='club_code',
field=models.CharField(blank=True, max_length=20, null=True),
),
migrations.AddField(
model_name='playerregistration',
name='club_member',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='tournament',
name='club_member_fee_deduction',
field=models.FloatField(blank=True, null=True),
),
migrations.AlterField(
model_name='club',
name='code',
field=models.CharField(blank=True, max_length=20, null=True),
),
]

@ -0,0 +1,18 @@
# Generated by Django 5.1 on 2025-05-19 13:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0124_playerregistration_club_code_and_more'),
]
operations = [
migrations.AddField(
model_name='tournament',
name='unregister_delta_in_hours',
field=models.IntegerField(default=24),
),
]

@ -0,0 +1,24 @@
# Generated by Django 5.1 on 2025-05-20 09:46
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0125_tournament_unregister_delta_in_hours'),
]
operations = [
migrations.AddField(
model_name='image',
name='club',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='images', to='tournaments.club'),
),
migrations.AlterField(
model_name='image',
name='event',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='images', to='tournaments.event'),
),
]

@ -0,0 +1,14 @@
# Generated by Django 5.1 on 2025-06-04 13:34
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('tournaments', '0119_customuser_can_synchronize'),
('tournaments', '0126_image_club_alter_image_event'),
]
operations = [
]

@ -5,7 +5,7 @@ from .custom_user import CustomUser
from .club import Club from .club import Club
from .court import Court from .court import Court
from .date_interval import DateInterval from .date_interval import DateInterval
from .enums import UserOrigin, TournamentPayment, FederalCategory, FederalLevelCategory, FederalAgeCategory, FederalMatchCategory, OnlineRegistrationStatus, RegistrationStatus from .enums import UserOrigin, TournamentPayment, FederalCategory, FederalLevelCategory, FederalAgeCategory, FederalMatchCategory, OnlineRegistrationStatus, RegistrationStatus, AnimationType
from .player_enums import PlayerSexType, PlayerDataSource, PlayerPaymentType from .player_enums import PlayerSexType, PlayerDataSource, PlayerPaymentType
from .event import Event from .event import Event
from .tournament import Tournament, TeamSummon, TeamSortingType, TeamItem from .tournament import Tournament, TeamSummon, TeamSortingType, TeamItem
@ -22,3 +22,4 @@ from .device_token import DeviceToken
from .draw_log import DrawLog from .draw_log import DrawLog
from .unregistered_team import UnregisteredTeam from .unregistered_team import UnregisteredTeam
from .unregistered_player import UnregisteredPlayer from .unregistered_player import UnregisteredPlayer
from .image import Image

@ -9,7 +9,7 @@ class Club(BaseModel):
name = models.CharField(max_length=50) name = models.CharField(max_length=50)
acronym = models.CharField(max_length=50) acronym = models.CharField(max_length=50)
phone = models.CharField(max_length=15, null=True, blank=True) phone = models.CharField(max_length=15, null=True, blank=True)
code = models.CharField(max_length=10, null=True, blank=True) code = models.CharField(max_length=20, null=True, blank=True)
federal_club_data = models.JSONField(null=True, blank=True) federal_club_data = models.JSONField(null=True, blank=True)
address = models.CharField(max_length=200, null=True, blank=True) address = models.CharField(max_length=200, null=True, blank=True)
@ -42,7 +42,7 @@ class Club(BaseModel):
return court.name return court.name
if index is not None: if index is not None:
return f"Terrain {index + 1}" return f"Piste {index + 1}"
return "" return ""

@ -17,4 +17,4 @@ class Court(BaseModel):
if self.name: if self.name:
return self.name return self.name
else: else:
return f"Terrain {self.index + 1}" return f"Piste {self.index + 1}"

@ -303,3 +303,9 @@ class RegistrationPaymentMode(models.IntegerChoices):
CORPORATE = 1, 'Corporate' CORPORATE = 1, 'Corporate'
NO_FEE = 2, 'No Service Fee' NO_FEE = 2, 'No Service Fee'
STRIPE = 3, 'Stripe' STRIPE = 3, 'Stripe'
class AnimationType(models.IntegerChoices):
TOURNAMENT = 0, 'Tournoi'
MELEE = 1, 'Mêlée'
LOSER_BRACKET = 2, 'Classement'
CONSOLATION_BRACKET = 3, 'Consolation'

@ -15,6 +15,7 @@ class GroupStage(TournamentSubModel):
size = models.IntegerField(default=4) size = models.IntegerField(default=4)
format = models.IntegerField(default=FederalMatchCategory.NINE_GAMES, choices=FederalMatchCategory.choices, null=True, blank=True) format = models.IntegerField(default=FederalMatchCategory.NINE_GAMES, choices=FederalMatchCategory.choices, null=True, blank=True)
start_date = models.DateTimeField(null=True, blank=True) start_date = models.DateTimeField(null=True, blank=True)
planned_start_date = models.DateTimeField(null=True, blank=True)
name = models.CharField(max_length=200, null=True, blank=True) name = models.CharField(max_length=200, null=True, blank=True)
step = models.IntegerField(default=0) step = models.IntegerField(default=0)

@ -0,0 +1,49 @@
from django.db import models
import uuid
import os
from django.utils.timezone import now
from .event import Event, Club
def image_upload_path(instance, filename):
"""Generate a unique file path for the uploaded image."""
# Get the file extension from the original filename
ext = filename.split('.')[-1]
# Create a unique filename using UUID
unique_filename = f"{uuid.uuid4().hex}.{ext}"
if instance.event:
# Determine the folder based on the event
folder = f"event_{instance.event.id}"
elif instance.club:
folder = f"club_{instance.club.id}"
else:
folder = f"unknown"
# Return the complete upload path
return os.path.join('images', folder, unique_filename)
class Image(models.Model):
"""Model for storing uploaded images for events."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
title = models.CharField(max_length=255, blank=True)
description = models.TextField(blank=True)
image = models.ImageField(upload_to=image_upload_path)
uploaded_at = models.DateTimeField(default=now)
# Relation to event model
event = models.ForeignKey(Event, null=True, blank=True, on_delete=models.CASCADE, related_name='images')
club = models.ForeignKey(Club, null=True, blank=True, on_delete=models.CASCADE, related_name='images')
# Image type for filtering
IMAGE_TYPES = (
('sponsor', 'Sponsor'),
('club', 'Club'),
)
image_type = models.CharField(max_length=20, choices=IMAGE_TYPES, default='sponsor')
order = models.IntegerField(default=0)
class Meta:
ordering = ['order']
def __str__(self):
return self.title or f"Event Image {self.id}"

@ -14,6 +14,7 @@ class Match(TournamentSubModel):
group_stage = models.ForeignKey(GroupStage, null=True, blank=True, on_delete=models.SET_NULL, related_name='matches') group_stage = models.ForeignKey(GroupStage, null=True, blank=True, on_delete=models.SET_NULL, related_name='matches')
name = models.CharField(max_length=200, null=True, blank=True) name = models.CharField(max_length=200, null=True, blank=True)
start_date = models.DateTimeField(null=True, blank=True) start_date = models.DateTimeField(null=True, blank=True)
planned_start_date = models.DateTimeField(null=True, blank=True)
end_date = models.DateTimeField(null=True, blank=True) end_date = models.DateTimeField(null=True, blank=True)
index = models.IntegerField(default=0) index = models.IntegerField(default=0)
#order = models.IntegerField(default=0) #order = models.IntegerField(default=0)
@ -66,7 +67,7 @@ class Match(TournamentSubModel):
if club: if club:
return club.court_name(index) return club.court_name(index)
elif index is not None: elif index is not None:
return f"Terrain {index + 1}" return f"Piste {index + 1}"
return "" return ""
def backup_name(self): def backup_name(self):
@ -76,7 +77,7 @@ class Match(TournamentSubModel):
if self.round.index > 0: if self.round.index > 0:
items.append(f" #{self.index_in_round() + 1}") items.append(f" #{self.index_in_round() + 1}")
elif self.group_stage: elif self.group_stage:
items.append(self.group_stage.name()) items.append(self.group_stage.display_name())
items.append(f"Match #{self.index + 1}") items.append(f"Match #{self.index + 1}")
return " ".join(items) return " ".join(items)
@ -182,7 +183,7 @@ class Match(TournamentSubModel):
def is_ready(self): def is_ready(self):
return self.team_scores.count() == 2 return self.team_scores.count() == 2
def live_teams(self): def live_teams(self, hide_names=False, short_names=False):
#print('player names from match') #print('player names from match')
##return map(lambda ts: ts.player_names(), self.team_scores.all()) ##return map(lambda ts: ts.player_names(), self.team_scores.all())
# List to hold the names of the teams # List to hold the names of the teams
@ -195,7 +196,7 @@ class Match(TournamentSubModel):
loser_top_match = self.loser_precedent_match(True) loser_top_match = self.loser_precedent_match(True)
loser_bottom_match = self.loser_precedent_match(False) loser_bottom_match = self.loser_precedent_match(False)
if len(team_scores) == 0: if len(team_scores) == 0 or hide_names is True:
if (self.round and self.round.parent is None and self.round.tournament.rounds.filter(parent__isnull=True, group_stage_loser_bracket=False).count() - 1 == self.round.index): if (self.round and self.round.parent is None and self.round.tournament.rounds.filter(parent__isnull=True, group_stage_loser_bracket=False).count() - 1 == self.round.index):
names = ["Qualifié"] names = ["Qualifié"]
@ -245,7 +246,7 @@ class Match(TournamentSubModel):
teams.append(team) teams.append(team)
elif len(team_scores) == 1: elif len(team_scores) == 1:
# Only one team score, handle missing one # Only one team score, handle missing one
existing_team = team_scores[0].live_team(self) existing_team = team_scores[0].live_team(self, short_names=short_names)
if (self.group_stage): if (self.group_stage):
teams.append(existing_team) teams.append(existing_team)
names = ["Équipe de poule"] names = ["Équipe de poule"]
@ -285,19 +286,19 @@ class Match(TournamentSubModel):
elif len(team_scores) == 2: elif len(team_scores) == 2:
# Both team scores present # Both team scores present
teams.extend([team_score.live_team(self) for team_score in team_scores]) teams.extend([team_score.live_team(self, short_names=short_names) for team_score in team_scores])
if self.round is not None and self.round.parent is None: if self.round is not None and self.round.parent is None:
pos1 = team_scores[0].team_registration.bracket_position if hasattr(team_scores[0], 'team_registration') and team_scores[0].team_registration else None pos1 = team_scores[0].team_registration.bracket_position if hasattr(team_scores[0], 'team_registration') and team_scores[0].team_registration else None
pos2 = team_scores[1].team_registration.bracket_position if hasattr(team_scores[1], 'team_registration') and team_scores[1].team_registration else None pos2 = team_scores[1].team_registration.bracket_position if hasattr(team_scores[1], 'team_registration') and team_scores[1].team_registration else None
if pos1 is not None and pos2 is not None and pos1 // 2 == self.index and pos2 // 2 == self.index: if pos1 is not None and pos2 is not None and pos1 // 2 == self.index and pos2 // 2 == self.index:
if pos1 > pos2: if pos1 > pos2:
teams = [team_scores[1].live_team(self), team_scores[0].live_team(self)] teams = [team_scores[1].live_team(self, short_names=short_names), team_scores[0].live_team(self, short_names=short_names)]
else: else:
teams = [team_scores[0].live_team(self), team_scores[1].live_team(self)] teams = [team_scores[0].live_team(self, short_names=short_names), team_scores[1].live_team(self, short_names=short_names)]
else: else:
teams.extend([team_score.live_team(self) for team_score in team_scores if team_score.walk_out != 1]) teams.extend([team_score.live_team(self, short_names=short_names) for team_score in team_scores if team_score.walk_out != 1])
return teams return teams
@ -305,6 +306,10 @@ class Match(TournamentSubModel):
timezone = self.get_tournament().timezone() timezone = self.get_tournament().timezone()
return self.start_date.astimezone(timezone) return self.start_date.astimezone(timezone)
def local_planned_start_date(self):
timezone = self.tournament().timezone()
return self.planned_start_date.astimezone(timezone)
def formatted_start_date(self): def formatted_start_date(self):
if self.start_date: if self.start_date:
local_start = self.local_start_date() local_start = self.local_start_date()
@ -339,13 +344,25 @@ class Match(TournamentSubModel):
return 'À venir...' return 'À venir...'
def magic_duration(self): def magic_duration(self):
return format_seconds(self.smart_time_played())
def smart_time_played(self):
if self.start_date:
if self.end_date:
return self.__smart_duration()
else:
return (timezone.now() - self.start_date).total_seconds()
else:
return 0
def __smart_duration(self):
seconds = (self.end_date - self.start_date).total_seconds() seconds = (self.end_date - self.start_date).total_seconds()
average_duration = self.average_seconds_duration() average_duration = self.average_seconds_duration()
if (average_duration / 2) > seconds or seconds > (average_duration * 2): if (average_duration / 2) > seconds or seconds > (average_duration * 2):
seconds = average_duration seconds = average_duration
return format_seconds(seconds) return seconds
def average_seconds_duration(self): def average_seconds_duration(self):
return 3 * 60 * self.total_number_of_games() return 3 * 60 * self.total_number_of_games()
@ -418,7 +435,19 @@ class Match(TournamentSubModel):
# _minutes = int((_seconds % 3600) / 60) # _minutes = int((_seconds % 3600) / 60)
# return f"{_hours:02d}h{_minutes:02d}min" # return f"{_hours:02d}h{_minutes:02d}min"
def live_match(self): def tournament_title(self, full_name=False):
if self.group_stage:
if full_name:
return self.group_stage.tournament.full_name()
else:
return self.group_stage.tournament.short_full_name()
else:
if full_name:
return self.round.tournament.full_name()
else:
return self.round.tournament.short_full_name()
def live_match(self, hide_teams=False, event_mode=False, short_names=False, broadcast=False):
title = self.computed_name() title = self.computed_name()
date = self.formatted_start_date() date = self.formatted_start_date()
time_indication = self.time_indication() time_indication = self.time_indication()
@ -433,9 +462,13 @@ class Match(TournamentSubModel):
ended = self.end_date is not None ended = self.end_date is not None
live_format = "Format " + FederalMatchCategory(self.format).format_label_short 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, self.should_show_lucky_loser_status()) tournament_title = None
if event_mode is True:
tournament_title = self.tournament_title(broadcast == False)
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(), tournament_title)
for team in self.live_teams(): for team in self.live_teams(hide_teams, short_names):
livematch.add_team(team) livematch.add_team(team)
return livematch return livematch
@ -500,7 +533,7 @@ class Team:
} }
class LiveMatch: class LiveMatch:
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): 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, tournament_title):
self.index = index self.index = index
self.title = title self.title = title
self.date = date self.date = date
@ -517,6 +550,7 @@ class LiveMatch:
self.court_index = court_index self.court_index = court_index
self.bracket_name = bracket_name self.bracket_name = bracket_name
self.should_show_lucky_loser_status = should_show_lucky_loser_status self.should_show_lucky_loser_status = should_show_lucky_loser_status
self.tournament_title = tournament_title
def add_team(self, team): def add_team(self, team):
self.teams.append(team) self.teams.append(team)
@ -540,6 +574,7 @@ class LiveMatch:
"court_index": self.court_index, "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, "should_show_lucky_loser_status": self.should_show_lucky_loser_status,
"tournament_title": self.tournament_title,
} }
def show_time_indication(self): def show_time_indication(self):

@ -27,6 +27,8 @@ class PlayerRegistration(TournamentSubModel):
club_name = models.CharField(max_length=200, null=True, blank=True) club_name = models.CharField(max_length=200, null=True, blank=True)
ligue_name = models.CharField(max_length=200, null=True, blank=True) ligue_name = models.CharField(max_length=200, null=True, blank=True)
assimilation = models.CharField(max_length=50, null=True, blank=True) assimilation = models.CharField(max_length=50, null=True, blank=True)
club_code = models.CharField(max_length=20, null=True, blank=True)
club_member = models.BooleanField(default=False)
#beachpadel #beachpadel
phone_number = models.CharField(max_length=50, null=True, blank=True) phone_number = models.CharField(max_length=50, null=True, blank=True)
@ -70,11 +72,11 @@ class PlayerRegistration(TournamentSubModel):
def name(self): def name(self):
return f"{self.first_name} {self.last_name}" return f"{self.first_name} {self.last_name}"
def shortened_name(self): def shortened_name(self, forced=False):
name = self.name() name = self.name()
if len(name) > 20 and self.first_name: if (len(name) > 20 or forced) and self.first_name:
name = f"{self.first_name[0]}. {self.last_name}" name = f"{self.first_name[0]}. {self.last_name}"
if len(name) > 20: if len(name) > 20 or forced:
name_parts = self.last_name.split(" ") name_parts = self.last_name.split(" ")
name = f"{self.first_name[0]}. {name_parts[0]}" name = f"{self.first_name[0]}. {name_parts[0]}"
return name return name
@ -119,6 +121,16 @@ class PlayerRegistration(TournamentSubModel):
tournament = self.team_registration.tournament tournament = self.team_registration.tournament
tournament_status_team_count = tournament.get_tournament_status_team_count() tournament_status_team_count = tournament.get_tournament_status_team_count()
if tournament.is_canceled():
return {
'header': "Équipes",
'position': tournament_status_team_count,
'display_box': True,
'box_class': 'light-red',
'short_label': 'annulé'
}
status = { status = {
'header': "Équipes", 'header': "Équipes",
'position': tournament_status_team_count, 'position': tournament_status_team_count,
@ -173,3 +185,15 @@ class PlayerRegistration(TournamentSubModel):
def has_paid(self): def has_paid(self):
return self.payment_type is not None return self.payment_type is not None
def get_remaining_fee(self):
if self.has_paid():
return 0
if self.club_member and self.team_registration.tournament.club_member_fee_deduction is not None and self.team_registration.tournament.entry_fee is not None:
return self.team_registration.tournament.entry_fee - self.team_registration.tournament.club_member_fee_deduction
elif self.team_registration.tournament.entry_fee is not None:
return self.team_registration.tournament.entry_fee
elif self.team_registration.tournament.club_member_fee_deduction is not None:
return self.team_registration.tournament.club_member_fee_deduction
else:
return 0

@ -9,6 +9,7 @@ class Round(TournamentSubModel):
parent = models.ForeignKey('self', blank=True, null=True, on_delete=models.SET_NULL, related_name='children') parent = models.ForeignKey('self', blank=True, null=True, on_delete=models.SET_NULL, related_name='children')
format = models.IntegerField(default=FederalMatchCategory.NINE_GAMES, choices=FederalMatchCategory.choices, null=True, blank=True) format = models.IntegerField(default=FederalMatchCategory.NINE_GAMES, choices=FederalMatchCategory.choices, null=True, blank=True)
start_date = models.DateTimeField(null=True, blank=True) start_date = models.DateTimeField(null=True, blank=True)
planned_start_date = models.DateTimeField(null=True, blank=True)
group_stage_loser_bracket = models.BooleanField(default=False) group_stage_loser_bracket = models.BooleanField(default=False)
loser_bracket_mode = models.IntegerField(default=0) loser_bracket_mode = models.IntegerField(default=0)
@ -171,7 +172,8 @@ class Round(TournamentSubModel):
name=name, name=name,
matches=first_half_matches, matches=first_half_matches,
round_id=self.id, round_id=self.id,
round_index=self.index round_index=self.index,
short_names=double_butterfly_mode
) )
return match_group return match_group

@ -5,6 +5,7 @@ from . import TournamentSubModel, Tournament, GroupStage, Match, CustomUser
from .enums import RegistrationStatus from .enums import RegistrationStatus
from .player_enums import PlayerPaymentType from .player_enums import PlayerPaymentType
from ..services.email_service import TournamentEmailService, TeamEmailType from ..services.email_service import TournamentEmailService, TeamEmailType
from ..utils.extensions import format_seconds
import uuid import uuid
@ -34,6 +35,7 @@ class TeamRegistration(TournamentSubModel):
final_ranking = models.IntegerField(null=True, blank=True) final_ranking = models.IntegerField(null=True, blank=True)
points_earned = models.IntegerField(null=True, blank=True) points_earned = models.IntegerField(null=True, blank=True)
unique_random_index = models.IntegerField(default=0)
def delete_dependencies(self): def delete_dependencies(self):
for player_registration in self.player_registrations.all(): for player_registration in self.player_registrations.all():
@ -67,7 +69,7 @@ class TeamRegistration(TournamentSubModel):
else: else:
return self.player_names_as_list() return self.player_names_as_list()
def shortened_team_names(self): def shortened_team_names(self, forced=False):
if self.name: if self.name:
return [self.name] #add an empty line if it's a team name return [self.name] #add an empty line if it's a team name
else: else:
@ -80,9 +82,9 @@ class TeamRegistration(TournamentSubModel):
else: else:
return ['Place réservée'] return ['Place réservée']
elif len(players) == 1: elif len(players) == 1:
return [players[0].shortened_name()] return [players[0].shortened_name(forced=forced)]
else: else:
return [pr.shortened_name() for pr in players] return [pr.shortened_name(forced=forced) for pr in players]
@property @property
def players_sorted_by_rank(self): def players_sorted_by_rank(self):
@ -181,13 +183,13 @@ class TeamRegistration(TournamentSubModel):
matches = self.get_matches() matches = self.get_matches()
upcoming = matches.filter(end_date__isnull=True).order_by('start_date') upcoming = matches.filter(end_date__isnull=True).order_by('start_date')
print(f"Upcoming matches count: {upcoming.count()}") # print(f"Upcoming matches count: {upcoming.count()}")
return [match.live_match() for match in upcoming] return [match.live_match() for match in upcoming]
def get_completed_matches(self): def get_completed_matches(self):
matches = self.get_matches() matches = self.get_matches()
completed = matches.filter(end_date__isnull=False).order_by('-end_date') completed = matches.filter(end_date__isnull=False).order_by('-end_date')
print(f"Completed matches count: {completed.count()}") # print(f"Completed matches count: {completed.count()}")
return [match.live_match() for match in completed] return [match.live_match() for match in completed]
def get_statistics(self): def get_statistics(self):
@ -199,6 +201,7 @@ class TeamRegistration(TournamentSubModel):
'initial_stage': self.get_initial_stage(), 'initial_stage': self.get_initial_stage(),
'matches_played': self.count_matches_played(), 'matches_played': self.count_matches_played(),
'victory_ratio': self.calculate_victory_ratio(), 'victory_ratio': self.calculate_victory_ratio(),
'time_played': self.calculate_time_played(),
'team_rank': self.team_rank_label(), 'team_rank': self.team_rank_label(),
'total_teams': self.total_teams(), 'total_teams': self.total_teams(),
} }
@ -281,6 +284,13 @@ class TeamRegistration(TournamentSubModel):
def count_matches_played(self): def count_matches_played(self):
return self.get_matches().filter(end_date__isnull=False).count() return self.get_matches().filter(end_date__isnull=False).count()
def calculate_time_played(self):
total_seconds = 0
for match in self.get_matches().filter(end_date__isnull=False):
total_seconds += match.smart_time_played()
return format_seconds(total_seconds)
def calculate_victory_ratio(self): def calculate_victory_ratio(self):
matches = self.get_matches().filter(end_date__isnull=False) matches = self.get_matches().filter(end_date__isnull=False)
total_matches = matches.count() total_matches = matches.count()
@ -396,14 +406,11 @@ class TeamRegistration(TournamentSubModel):
return status == 'PAID' return status == 'PAID'
def get_remaining_fee(self): def get_remaining_fee(self):
"""Get the remaining fee for this team""" # Get all player registrations for this team
status = self.get_payment_status() player_registrations = self.players_sorted_by_rank
if status == 'PAID': # Check payment status for each player
return 0 payment_statuses = [player.get_remaining_fee() for player in player_registrations]
elif status == 'UNPAID': return sum(payment_statuses)
return self.tournament.team_fee()
elif status == 'MIXED':
return self.tournament.player_fee()
def is_confirmation_expired(self): def is_confirmation_expired(self):
""" """
@ -495,3 +502,12 @@ class TeamRegistration(TournamentSubModel):
tournament, tournament,
TeamEmailType.REQUIRES_TIME_CONFIRMATION TeamEmailType.REQUIRES_TIME_CONFIRMATION
) )
def is_unregistration_possible(self):
if self.call_date is not None:
return False
if self.bracket_position is not None:
return False
if self.group_stage_position is not None:
return False
return True

@ -57,10 +57,10 @@ class TeamScore(TournamentSubModel):
else: else:
return "--" return "--"
def shortened_team_names(self): def shortened_team_names(self, forced=False):
names = [] names = []
if self.team_registration: if self.team_registration:
names = self.team_registration.shortened_team_names() names = self.team_registration.shortened_team_names(forced=forced)
return names return names
def team_names(self): def team_names(self):
@ -117,7 +117,7 @@ class TeamScore(TournamentSubModel):
scores = self.scores() scores = self.scores()
return sum(scores) return sum(scores)
def live_team(self, match): def live_team(self, match, short_names=False):
if self.team_registration: if self.team_registration:
id = self.team_registration.id id = self.team_registration.id
image = self.team_registration.logo image = self.team_registration.logo
@ -131,7 +131,7 @@ class TeamScore(TournamentSubModel):
image = None image = None
weight= None weight= None
is_winner = False is_winner = False
names = self.shortened_team_names() names = self.shortened_team_names(forced=short_names)
scores = self.parsed_scores() scores = self.parsed_scores()
walk_out = self.walk_out walk_out = self.walk_out
is_lucky_loser = self.lucky_loser is not None is_lucky_loser = self.lucky_loser is not None

@ -1,6 +1,6 @@
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from django.db import models from django.db import models
from . import BaseModel, Event, TournamentPayment, FederalMatchCategory, FederalCategory, FederalLevelCategory, FederalAgeCategory, OnlineRegistrationStatus, RegistrationStatus from . import BaseModel, Event, TournamentPayment, FederalMatchCategory, FederalCategory, FederalLevelCategory, FederalAgeCategory, OnlineRegistrationStatus, RegistrationStatus, AnimationType
import uuid import uuid
from django.utils import timezone, formats from django.utils import timezone, formats
@ -89,6 +89,11 @@ class Tournament(BaseModel):
enable_time_to_confirm = models.BooleanField(default=False) enable_time_to_confirm = models.BooleanField(default=False)
is_corporate_tournament = models.BooleanField(default=False) is_corporate_tournament = models.BooleanField(default=False)
is_template = models.BooleanField(default=False) is_template = models.BooleanField(default=False)
animation_type = models.IntegerField(default=AnimationType.TOURNAMENT, choices=AnimationType.choices)
publish_prog = models.BooleanField(default=False)
show_teams_in_prog = models.BooleanField(default=False)
club_member_fee_deduction = models.FloatField(null=True, blank=True)
unregister_delta_in_hours = models.IntegerField(default=24)
def delete_dependencies(self): def delete_dependencies(self):
for team_registration in self.team_registrations.all(): for team_registration in self.team_registrations.all():
@ -159,6 +164,13 @@ class Tournament(BaseModel):
str = f"{str} {age}" str = f"{str} {age}"
return str return str
def short_full_name(self):
age = self.age()
str = f"{self.level()}{self.category()[0]}"
if age is not None and self.federal_age_category != 200:
str = f"{str} {age}"
return str
def short_base_name(self): def short_base_name(self):
category = self.category() category = self.category()
if len(category) > 0 and self.federal_level_category > 1: if len(category) > 0 and self.federal_level_category > 1:
@ -218,6 +230,12 @@ class Tournament(BaseModel):
def in_progress(self): def in_progress(self):
return self.end_date is None return self.end_date is None
def sorting_finished_date(self):
if self.end_date:
return self.end_date
else:
return self.start_date
def creator(self): def creator(self):
if self.event and self.event.creator: if self.event and self.event.creator:
return self.event.creator.username return self.event.creator.username
@ -422,20 +440,22 @@ class Tournament(BaseModel):
t.registration_date is None, t.registration_date is None,
t.registration_date or datetime.min, t.registration_date or datetime.min,
t.initial_weight, t.initial_weight,
t.team_registration.unique_random_index,
t.team_registration.id t.team_registration.id
)) ))
waiting_teams.sort(key=lambda t: ( waiting_teams.sort(key=lambda t: (
t.registration_date is None, t.registration_date is None,
t.registration_date or datetime.min, t.registration_date or datetime.min,
t.initial_weight, t.initial_weight,
t.team_registration.unique_random_index,
t.team_registration.id t.team_registration.id
)) ))
else: else:
complete_teams.sort(key=lambda t: (t.initial_weight, t.team_registration.id)) complete_teams.sort(key=lambda t: (t.initial_weight, t.team_registration.unique_random_index, t.team_registration.id))
waiting_teams.sort(key=lambda t: (t.initial_weight, t.team_registration.id)) waiting_teams.sort(key=lambda t: (t.initial_weight, t.team_registration.unique_random_index, t.team_registration.id))
wildcard_group_stage.sort(key=lambda t: (t.initial_weight, t.team_registration.id)) wildcard_group_stage.sort(key=lambda t: (t.initial_weight, t.team_registration.unique_random_index, t.team_registration.id))
wildcard_bracket.sort(key=lambda t: (t.initial_weight, t.team_registration.id)) wildcard_bracket.sort(key=lambda t: (t.initial_weight, t.team_registration.unique_random_index, t.team_registration.id))
# Split teams into main bracket and waiting list # Split teams into main bracket and waiting list
computed_team_count = self.team_count - len(wildcard_bracket) - len(wildcard_group_stage) computed_team_count = self.team_count - len(wildcard_bracket) - len(wildcard_group_stage)
@ -444,8 +464,8 @@ class Tournament(BaseModel):
qualified_teams = complete_teams[:computed_team_count] qualified_teams = complete_teams[:computed_team_count]
excess_teams = complete_teams[computed_team_count:] excess_teams = complete_teams[computed_team_count:]
qualified_teams.sort(key=lambda t: (t.initial_weight, t.team_registration.id)) qualified_teams.sort(key=lambda t: (t.initial_weight, t.team_registration.unique_random_index, t.team_registration.id))
excess_teams.sort(key=lambda t: (t.initial_weight, t.team_registration.id)) excess_teams.sort(key=lambda t: (t.initial_weight, t.team_registration.unique_random_index, t.team_registration.id))
# Combine all waiting list teams # Combine all waiting list teams
waiting_list = excess_teams + waiting_teams waiting_list = excess_teams + waiting_teams
@ -454,16 +474,17 @@ class Tournament(BaseModel):
t.registration_date is None, t.registration_date is None,
t.registration_date or datetime.min, t.registration_date or datetime.min,
t.initial_weight, t.initial_weight,
t.team_registration.unique_random_index,
t.team_registration.id t.team_registration.id
)) ))
else: else:
waiting_list.sort(key=lambda t: (t.initial_weight, t.team_registration.id)) waiting_list.sort(key=lambda t: (t.initial_weight, t.team_registration.unique_random_index, t.team_registration.id))
# Return final sorted list # Return final sorted list
bracket_teams = qualified_teams[:bracket_seeds] + wildcard_bracket bracket_teams = qualified_teams[:bracket_seeds] + wildcard_bracket
gs_teams = qualified_teams[bracket_seeds:(bracket_seeds+group_stage_team_count)] + wildcard_group_stage gs_teams = qualified_teams[bracket_seeds:(bracket_seeds+group_stage_team_count)] + wildcard_group_stage
bracket_teams.sort(key=lambda t: (t.initial_weight, t.team_registration.id)) bracket_teams.sort(key=lambda t: (t.initial_weight, t.team_registration.unique_random_index, t.team_registration.id))
gs_teams.sort(key=lambda t: (t.initial_weight, t.team_registration.id)) gs_teams.sort(key=lambda t: (t.initial_weight, t.team_registration.unique_random_index, t.team_registration.id))
all_teams = bracket_teams + gs_teams all_teams = bracket_teams + gs_teams
for team in bracket_teams: for team in bracket_teams:
team.set_stage("Tableau") team.set_stage("Tableau")
@ -561,9 +582,9 @@ class Tournament(BaseModel):
return groups return groups
def create_match_group(self, name, matches, round_id=None, round_index=None): def create_match_group(self, name, matches, round_id=None, round_index=None, hide_teams=False, event_mode=False, short_names=False, broadcast=False):
matches = list(matches) matches = list(matches)
live_matches = [match.live_match() for match in matches] live_matches = [match.live_match(hide_teams, event_mode, short_names, broadcast) for match in matches]
# Filter out matches that have a start_date of None # Filter out matches that have a start_date of None
valid_matches = [match for match in matches if match.start_date is not None] valid_matches = [match for match in matches if match.start_date is not None]
@ -689,7 +710,7 @@ class Tournament(BaseModel):
else: else:
current_round = self.round_to_show() current_round = self.round_to_show()
if current_round: if current_round:
print(f'current_round = {current_round.index} / parent = {current_round.parent}') #print(f'current_round = {current_round.index} / parent = {current_round.parent}')
all_upper_matches_are_over = current_round.all_matches_are_over() all_upper_matches_are_over = current_round.all_matches_are_over()
if all_upper_matches_are_over is False: if all_upper_matches_are_over is False:
matches.extend(current_round.get_matches_recursive(True)) matches.extend(current_round.get_matches_recursive(True))
@ -716,7 +737,7 @@ class Tournament(BaseModel):
previous_previous_matches = [m for m in previous_previous_matches if m.end_date is None] previous_previous_matches = [m for m in previous_previous_matches if m.end_date is None]
matches.extend(previous_previous_matches) matches.extend(previous_previous_matches)
else: else:
print('group_stages') #print('group_stages')
group_stages = [gs.live_group_stages() for gs in self.last_group_stage_step()] group_stages = [gs.live_group_stages() for gs in self.last_group_stage_step()]
else: else:
first_round = self.first_round() first_round = self.first_round()
@ -837,6 +858,7 @@ class Tournament(BaseModel):
matches = [m for m in matches if m.should_appear() and matches = [m for m in matches if m.should_appear() and
(m.start_date is None or m.start_date <= future_threshold) and # Not starting in more than 1h (m.start_date is None or m.start_date <= future_threshold) and # Not starting in more than 1h
(m.end_date is None or m.end_date >= past_threshold)] # Not finished for more than 1h (m.end_date is None or m.end_date >= past_threshold)] # Not finished for more than 1h
matches.sort(key=lambda m: (m.start_date is None)) # display started matches
matches = matches[:16] matches = matches[:16]
matches.sort(key=lambda m: (m.start_date is None, m.end_date is not None, m.start_date, m.index)) matches.sort(key=lambda m: (m.start_date is None, m.end_date is not None, m.start_date, m.index))
@ -884,6 +906,19 @@ class Tournament(BaseModel):
return self.has_team_registrations() return self.has_team_registrations()
if self.has_started(): if self.has_started():
return self.has_team_registrations() return self.has_team_registrations()
if self.will_start_soon(1):
return True
return False
def display_prog(self):
if self.end_date is not None:
return True
if self.publish_prog:
return True
if self.has_started():
return True
if self.will_start_soon(1):
return True
return False return False
def has_team_registrations(self): def has_team_registrations(self):
@ -896,6 +931,8 @@ class Tournament(BaseModel):
return self.has_summons() return self.has_summons()
if self.has_started(): if self.has_started():
return self.has_summons() return self.has_summons()
if self.will_start_soon(1):
return True
return False return False
def display_group_stages(self): def display_group_stages(self):
@ -973,8 +1010,8 @@ class Tournament(BaseModel):
timezoned_datetime -= timedelta(hours=hour_delta) timezoned_datetime -= timedelta(hours=hour_delta)
return now >= timezoned_datetime return now >= timezoned_datetime
def will_start_soon(self): def will_start_soon(self, hour_delta=2):
return self.has_started(hour_delta=2) return self.has_started(hour_delta=hour_delta)
def supposedly_in_progress(self): def supposedly_in_progress(self):
# end = self.start_date + timedelta(days=self.day_duration + 1) # end = self.start_date + timedelta(days=self.day_duration + 1)
@ -1070,6 +1107,22 @@ class Tournament(BaseModel):
return False return False
return True return True
def options_fee(self):
def format_currency(amount):
"""Format currency amount, removing unnecessary decimals"""
return f"{amount:g}" if amount % 1 == 0 else f"{amount:.2f}"
options = []
# Entry fee
if self.entry_fee is not None and self.entry_fee > 0:
options.append(f"Frais d'inscription: {format_currency(self.entry_fee)} € par joueur")
# Club member fee reduction
if self.club_member_fee_deduction and self.club_member_fee_deduction > 0:
options.append(f"Réduction de {format_currency(self.club_member_fee_deduction)} € pour les membres du club")
return options
def options_online_registration(self): def options_online_registration(self):
options = [] options = []
timezone = self.timezone() timezone = self.timezone()
@ -1084,6 +1137,9 @@ class Tournament(BaseModel):
date = formats.date_format(self.registration_date_limit.astimezone(timezone), format='j F Y H:i') date = formats.date_format(self.registration_date_limit.astimezone(timezone), format='j F Y H:i')
options.append(f"Clôture des inscriptions le {date}") options.append(f"Clôture des inscriptions le {date}")
# Période de désinscription
options.append(f"Désinscription possible jusqu'à {self.unregister_delta_in_hours}h avant le tournoi")
options.append(self.get_selection_status_localized) options.append(self.get_selection_status_localized)
# Cible d'équipes # Cible d'équipes
@ -1109,11 +1165,11 @@ class Tournament(BaseModel):
if self.enable_online_payment_refund and self.refund_date_limit: 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') date = formats.date_format(self.refund_date_limit.astimezone(timezone), format='j F Y H:i')
options.append(f"Remboursement possible jusqu'au {date}") options.append(f"Remboursement en ligne possible jusqu'au {date}")
elif self.enable_online_payment_refund: elif self.enable_online_payment_refund:
options.append("Remboursement possible") options.append("Remboursement en ligne possible")
else: else:
options.append("Remboursement impossible") options.append("Remboursement en ligne impossible")
# Joueurs par équipe # Joueurs par équipe
min_players = self.minimum_player_per_team min_players = self.minimum_player_per_team
@ -1213,6 +1269,9 @@ class Tournament(BaseModel):
if self.supposedly_in_progress(): if self.supposedly_in_progress():
return False return False
if self.will_start_soon(self.unregister_delta_in_hours):
return False
if self.closed_registration_date is not None: if self.closed_registration_date is not None:
return False return False
@ -1818,19 +1877,6 @@ class Tournament(BaseModel):
else: else:
return False 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): def is_free(self):
if self.entry_fee is not None and self.entry_fee == 0: if self.entry_fee is not None and self.entry_fee == 0:
return True return True
@ -1891,7 +1937,176 @@ class Tournament(BaseModel):
return teams_processed return teams_processed
def planned_matches_by_day(self, day=None, all=False, event_mode=False, broadcast=False):
"""
Collect all matches from tournaments and group them by their planned_start_date.
Parameters:
- day: Optional date string in format 'YYYY-MM-DD'. If provided, returns matches for that day only.
Returns:
- days: List of unique days found (datetime.date objects)
- match_groups: Dictionary of match groups by date and hour or just for the selected day
"""
if event_mode is True and self.event.tournaments.count() == 1:
event_mode = False
# Get all matches from rounds and group stages - use a set to avoid duplicates
all_matches = set()
tournaments = [self]
if event_mode is True:
tournaments = self.event.tournaments.all()
for tournament in tournaments:
# Get matches only from top-level rounds to avoid duplicates
for round in tournament.rounds.filter(parent=None).all():
round_matches = round.get_matches_recursive(False)
# Add to set using IDs to avoid duplicates
for match in round_matches:
all_matches.add(match)
# Get matches from group stages
for group_stage in tournament.group_stages.all():
for match in group_stage.matches.all():
all_matches.add(match)
# Filter matches with planned_start_date - convert back to list
planned_matches = [match for match in all_matches if match.planned_start_date and not match.disabled]
if not planned_matches:
return [], []
# Group matches by day
matches_by_day = {}
days = set()
for match in planned_matches:
# Convert to local time zone
local_date = timezone.localtime(match.planned_start_date)
day_key = local_date.date()
days.add(day_key)
if day_key not in matches_by_day:
matches_by_day[day_key] = []
matches_by_day[day_key].append(match)
# Sort days
sorted_days = sorted(list(days))
# Create match groups for the selected day
match_groups = []
if all or day is None:
today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0).date()
if today in days:
selected_day = today
else:
# Default to first day if today is not in the list
if self.has_ended():
selected_day = sorted_days[-1]
else:
selected_day = sorted_days[0]
# Group matches by hour
matches_by_hour = {}
for match in matches_by_day[selected_day]:
local_time = timezone.localtime(match.planned_start_date)
hour_key = local_time.strftime('%H:%M')
if hour_key not in matches_by_hour:
matches_by_hour[hour_key] = []
matches_by_hour[hour_key].append(match)
hide_teams = self.show_teams_in_prog == False
# Create match groups for each hour
for hour, matches in sorted(matches_by_hour.items()):
# Sort matches by court if available
matches.sort(key=lambda m: (m.court_index if m.court_index is not None else 999))
local_date = matches[0].local_planned_start_date()
formatted_name = formats.date_format(local_date, format='l j F à H:i').capitalize()
mg = self.create_match_group(
name=formatted_name,
matches=matches,
round_id=None,
round_index=None,
hide_teams=hide_teams,
event_mode=event_mode,
broadcast=broadcast
)
match_groups.append(mg)
return sorted_days, match_groups
# If specific day requested, filter to that day
selected_day = None
if day:
try:
# Parse the day string to a date object
selected_day = datetime.strptime(day, '%Y-%m-%d').date()
if selected_day not in days:
selected_day = sorted_days[0] if sorted_days else None
except (ValueError, TypeError):
selected_day = sorted_days[0] if sorted_days else None
else:
selected_day = sorted_days[0] if sorted_days else None
if selected_day and selected_day in matches_by_day:
# Group matches by hour
matches_by_hour = {}
for match in matches_by_day[selected_day]:
local_time = timezone.localtime(match.planned_start_date)
hour_key = local_time.strftime('%H:%M')
if hour_key not in matches_by_hour:
matches_by_hour[hour_key] = []
matches_by_hour[hour_key].append(match)
hide_teams = self.show_teams_in_prog == False
# Create match groups for each hour
for hour, matches in sorted(matches_by_hour.items()):
# Sort matches by court if available
matches.sort(key=lambda m: (m.court_index if m.court_index is not None else 999))
local_date = matches[0].local_planned_start_date()
formatted_name = formats.date_format(local_date, format='l j F à H:i').capitalize()
mg = self.create_match_group(
name=formatted_name,
matches=matches,
round_id=None,
round_index=None,
hide_teams=hide_teams,
event_mode=event_mode,
broadcast=broadcast
)
match_groups.append(mg)
return sorted_days, match_groups
def has_sponsors(self):
return self.event.images.exists()
def is_cart_player_from_club(self, player_data):
player_club_code = player_data.get('club_code', None)
if player_club_code is None or len(player_club_code) == 0:
return False
club_code = self.event.club.code
if club_code is None or len(club_code) == 0:
return False
player_club_code = player_club_code.replace(" ", "")
club_code = club_code.replace(" ", "")
return player_club_code.lower() == club_code.lower()
def localized_start_time(self):
return formats.date_format(self.local_start_date(), format='H:i').capitalize()
def court_names(self):
if self.event.club is None:
return [f"Piste {i+1}" for i in range(self.court_count)]
return [f"{self.event.club.court_name(i)}" for i in range(self.court_count)]
class MatchGroup: class MatchGroup:
def __init__(self, name, matches, formatted_schedule, round_id=None, round_index=None): def __init__(self, name, matches, formatted_schedule, round_id=None, round_index=None):

@ -606,6 +606,49 @@ class TournamentEmailService:
# If there's only one player, just send them the notification # If there's only one player, just send them the notification
TournamentEmailService.notify(players[0], None, tournament, message_type) TournamentEmailService.notify(players[0], None, tournament, message_type)
@staticmethod
def notify_umpire(team, tournament, message_type):
# Notify the umpire if needed
umpire_email = tournament.umpire_mail()
if umpire_email:
tournament_details_str = tournament.build_tournament_details_str()
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 = [
"Email automatique suite à une désinscription",
f"\n\n{tournament_details_str} | {tournament.formatted_start_date()} | {tournament.event.club.name}"
"\n\nL'équipe ci-dessous a annulé son inscription via le site PadelClub :",
]
for player in team.players_sorted_by_rank:
body_parts.append(
f"\n{player.name()}"
)
absolute_url = f"https://padelclub.app/tournament/{tournament.id}/info"
link_text = f"informations sur {tournament_prefix_that}{tournament_word} sur https://padelclub.app"
absolute_url = f'<a href="{absolute_url}">{link_text}</a>'
body_parts.append(
f"\n\nVoir les {absolute_url}",
)
body_parts.extend([
"\n\nCeci est un e-mail automatique."
])
email_body = "".join(body_parts)
if email_body is None:
return
topic = message_type.email_topic(tournament.federal_level_category)
email_subject = TournamentEmailService.email_subject(tournament, topic)
TournamentEmailService._send_email(umpire_email, email_subject, email_body)
@staticmethod @staticmethod
def _build_payment_info(tournament, team_registration): def _build_payment_info(tournament, team_registration):
""" """
@ -656,7 +699,7 @@ class TournamentEmailService:
payment_amount = payment['amount'] / 100 payment_amount = payment['amount'] / 100
if payment_amount is None: if payment_amount is None:
payment_amount = tournament.team_fee() payment_amount = team_registration.get_remaining_fee()
federal_level_category = FederalLevelCategory(tournament.federal_level_category) federal_level_category = FederalLevelCategory(tournament.federal_level_category)
tournament_word = federal_level_category.localized_word() tournament_word = federal_level_category.localized_word()
@ -731,7 +774,7 @@ class TournamentEmailService:
if refund_amount is None: if refund_amount is None:
refund_amount = tournament.team_fee() refund_amount = team_registration.get_remaining_fee()
federal_level_category = FederalLevelCategory(tournament.federal_level_category) federal_level_category = FederalLevelCategory(tournament.federal_level_category)
tournament_word = federal_level_category.localized_word() tournament_word = federal_level_category.localized_word()

@ -278,7 +278,7 @@ class PaymentService:
team_registration.confirm_registration(payment_intent_id) team_registration.confirm_registration(payment_intent_id)
return True return True
def process_refund(self, team_registration_id): def process_refund(self, team_registration_id, force_refund=False):
""" """
Process a refund for a tournament registration as part of unregistration Process a refund for a tournament registration as part of unregistration
Returns a tuple (success, message) Returns a tuple (success, message)
@ -291,7 +291,7 @@ class PaymentService:
tournament = team_registration.tournament tournament = team_registration.tournament
# Check if refund is possible for this tournament # Check if refund is possible for this tournament
if not tournament.is_refund_possible(): if not tournament.is_refund_possible() and force_refund == False:
return False, "Les remboursements ne sont plus possibles pour ce tournoi.", None return False, "Les remboursements ne sont plus possibles pour ce tournoi.", None
# Get payment ID from player registrations # Get payment ID from player registrations

@ -8,6 +8,7 @@ from ..models.enums import FederalCategory, RegistrationStatus
from ..models.player_enums import PlayerSexType, PlayerDataSource from ..models.player_enums import PlayerSexType, PlayerDataSource
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.conf import settings from django.conf import settings
from django.utils.dateparse import parse_datetime
class RegistrationCartManager: class RegistrationCartManager:
""" """
@ -110,7 +111,6 @@ class RegistrationCartManager:
if expiry_str: if expiry_str:
try: try:
# Parse the ISO format string to datetime # Parse the ISO format string to datetime
from django.utils.dateparse import parse_datetime
expiry_datetime = parse_datetime(expiry_str) expiry_datetime = parse_datetime(expiry_str)
except (ValueError, TypeError): except (ValueError, TypeError):
# If parsing fails, set a new expiry # If parsing fails, set a new expiry
@ -123,6 +123,7 @@ class RegistrationCartManager:
'players': self.session.get('registration_cart_players', []), 'players': self.session.get('registration_cart_players', []),
'expiry': expiry_datetime, # Now a datetime object, not a string 'expiry': expiry_datetime, # Now a datetime object, not a string
'is_cart_expired': self.is_cart_expired(), 'is_cart_expired': self.is_cart_expired(),
'team_fee_from_cart_players': self.team_fee_from_cart_players(),
'mobile_number': self.session.get('registration_mobile_number', user_phone) 'mobile_number': self.session.get('registration_mobile_number', user_phone)
} }
@ -132,8 +133,30 @@ class RegistrationCartManager:
return cart_data return cart_data
def team_fee_from_cart_players(self):
# Get tournament
tournament_id = self.session.get('registration_tournament_id')
try:
tournament = Tournament.objects.get(id=tournament_id)
except Tournament.DoesNotExist:
return 0
players = self.session.get('registration_cart_players', []),
entry_fee = tournament.entry_fee
if entry_fee is not None and entry_fee > 0 and tournament.enable_online_payment:
fee = entry_fee * tournament.minimum_player_per_team
players = self.session.get('registration_cart_players', [])
club_members = sum(1 for player in players if player.get('club_member', False))
if tournament.club_member_fee_deduction is not None:
return fee - club_members * tournament.club_member_fee_deduction
return fee
else:
return 0
def add_player(self, player_data): def add_player(self, player_data):
"""Add a player to the registration cart""" """Add a player to the registration cart"""
print("add_player", player_data)
if self.is_cart_expired(): if self.is_cart_expired():
return False, "Votre session d'inscription a expiré, veuillez réessayer." return False, "Votre session d'inscription a expiré, veuillez réessayer."
@ -168,21 +191,30 @@ class RegistrationCartManager:
return result # Return the error return result # Return the error
tournament_federal_category = tournament.federal_category 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: if licence_id:
# Get federation data # Get federation data
fed_data, found = get_player_name_from_csv(tournament_federal_category, licence_id) fed_data, found = get_player_name_from_csv(tournament_federal_category, licence_id)
if found is False and fed_data: if found is False and fed_data:
is_woman = fed_data.get('is_woman', False)
if tournament_federal_category == FederalCategory.MIXED and len(players) == 1:
other_player_is_woman = players[0].get('is_woman', False)
if other_player_is_woman == is_woman:
is_woman = not is_woman
player_data.update({ player_data.update({
'rank': fed_data['rank'], 'rank': fed_data['rank'],
'is_woman': fed_data['is_woman'], 'is_woman': is_woman,
}) })
if found and fed_data: if found and fed_data:
# Use federation data (including check for eligibility) # Use federation data (including check for eligibility)
if tournament_federal_category == FederalCategory.MIXED and len(players) == 1:
is_woman = fed_data.get('is_woman', False)
other_player_is_woman = players[0].get('is_woman', False)
if other_player_is_woman == is_woman:
return False, f"En mixte l'équipe doit obligatoirement contenir une joueuse et un joueur. La licence {licence_id} correspond à {'une' if is_woman else 'un'} {'femme' if is_woman else 'homme'}."
player_register_check = tournament.player_register_check(licence_id) player_register_check = tournament.player_register_check(licence_id)
if player_register_check: if player_register_check:
return False, ", ".join(player_register_check) return False, ", ".join(player_register_check)
@ -198,11 +230,14 @@ class RegistrationCartManager:
'tournament_count': fed_data.get('tournament_count'), 'tournament_count': fed_data.get('tournament_count'),
'ligue_name': fed_data.get('ligue_name'), 'ligue_name': fed_data.get('ligue_name'),
'club_name': fed_data.get('club_name'), 'club_name': fed_data.get('club_name'),
'club_code': fed_data.get('club_code'),
'birth_year': fed_data.get('birth_year'), 'birth_year': fed_data.get('birth_year'),
'found_in_french_federation': True, 'found_in_french_federation': True,
'club_member': tournament.is_cart_player_from_club(fed_data)
}) })
elif not first_name or not last_name: elif not first_name or not last_name:
# License not required or not found, but name is needed # License not required or not found, but name is needed
print("Le prénom et le nom sont obligatoires pour les joueurs dont la licence n'a pas été trouvée.")
self.first_tournament = True self.first_tournament = True
return False, "Le prénom et le nom sont obligatoires pour les joueurs dont la licence n'a pas été trouvée." return False, "Le prénom et le nom sont obligatoires pour les joueurs dont la licence n'a pas été trouvée."
elif not tournament.license_is_required: elif not tournament.license_is_required:
@ -379,6 +414,33 @@ class RegistrationCartManager:
except User.DoesNotExist: except User.DoesNotExist:
pass pass
is_woman = player_data.get('is_woman')
if is_woman is not None:
if is_woman:
sex = 0
else:
sex = 1
else:
sex = None
tournament_count = player_data.get('tournament_count', None)
if tournament_count is not None:
try:
tournament_played = int(tournament_count)
except ValueError:
tournament_played = None
else:
tournament_played = None
points = player_data.get('points', None)
if points is not None:
try:
points = float(points)
except ValueError:
points = None
else:
points = None
# Create player registration with all the original fields # Create player registration with all the original fields
PlayerRegistration.objects.create( PlayerRegistration.objects.create(
team_registration=team_registration, team_registration=team_registration,
@ -388,13 +450,15 @@ class RegistrationCartManager:
registered_online=True, registered_online=True,
first_name=player_data.get('first_name'), first_name=player_data.get('first_name'),
last_name=player_data.get('last_name'), last_name=player_data.get('last_name'),
points=player_data.get('points'), points=points,
assimilation=player_data.get('assimilation'), assimilation=player_data.get('assimilation'),
tournament_played=player_data.get('tournament_count'), tournament_played=tournament_played,
ligue_name=player_data.get('ligue_name'), ligue_name=player_data.get('ligue_name'),
club_name=player_data.get('club_name'), club_name=player_data.get('club_name'),
club_code=player_data.get('club_code'),
club_member=tournament.is_cart_player_from_club(player_data),
birthdate=player_data.get('birth_year'), birthdate=player_data.get('birth_year'),
sex=player_data.get('sex'), sex= sex,
rank=player_data.get('rank'), rank=player_data.get('rank'),
computed_rank=player_data.get('computed_rank'), computed_rank=player_data.get('computed_rank'),
licence_id=player_data.get('licence_id'), licence_id=player_data.get('licence_id'),

@ -1,9 +1,8 @@
from django.contrib import messages from django.contrib import messages
from django.utils import timezone from django.utils import timezone
from ..models import PlayerRegistration, UnregisteredTeam, UnregisteredPlayer, PlayerPaymentType from ..models import PlayerRegistration, UnregisteredTeam, UnregisteredPlayer, PlayerPaymentType
from ..utils.licence_validator import LicenseValidator
from ..services.payment_service import PaymentService from ..services.payment_service import PaymentService
from ..services.email_service import TournamentEmailService from ..services.email_service import TournamentEmailService, TeamEmailType
class TournamentUnregistrationService: class TournamentUnregistrationService:
def __init__(self, request, tournament): def __init__(self, request, tournament):
@ -96,6 +95,8 @@ class TournamentUnregistrationService:
registered_online=player.registered_online registered_online=player.registered_online
) )
TournamentEmailService.notify_umpire(team_registration, team_registration.tournament, TeamEmailType.UNREGISTERED)
def _find_player_registration(self): def _find_player_registration(self):
# First check if we can find the player registration directly by user # First check if we can find the player registration directly by user
if self.request.user.is_authenticated: if self.request.user.is_authenticated:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -22,6 +22,51 @@ body {
box-shadow: 0 0 0px 0px #fbead6; box-shadow: 0 0 0px 0px #fbead6;
} }
.bubble-header {
padding: 20px;
background-color: white;
border-radius: 24px;
box-shadow: 0 0 0px 0px #fbead6;
}
.bubble-footer {
background-color: white;
display: flex;
border-radius: 24px;
box-shadow: 0 0 0px 0px #fbead6;
align-items: center;
justify-content: center;
padding: 15px;
margin: 0;
}
.bubble-sponsor {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
align-items: center;
}
.footer-broadcast {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
padding: 0px;
z-index: 100;
margin-bottom: 20px;
}
.sponsor-logo-broadcast {
height: 100px;
width: 100px;
object-fit: contain;
}
.bold { .bold {
font-family: "Montserrat-Bold"; font-family: "Montserrat-Bold";
} }
@ -79,3 +124,77 @@ body {
.center { .center {
align-items: center; align-items: center;
} }
.left-content.bubble-header {
display: flex;
align-items: top; /* Vertically align items (logo and text) */
height: 120px; /* Example height matching QR code */
}
.left-content.bubble-header.screen-size-overlay {
display: flex;
align-items: top; /* Vertically align items (logo and text) */
max-height: 14vh; /* Allow height to adjust based on content */
}
.left-content.bubble-header.screen-size-overlay .left-margin h1.club,
.left-content.bubble-header.screen-size-overlay .left-margin h1.event {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal; /* Allow text to wrap */
}
.logo {
height: 100%; /* Make the logo's height match the parent's height */
width: auto; /* Maintain the logo's aspect ratio */
}
#header {
padding: 30px;
}
@media screen and (max-height: 720px) {
.logo {
object-fit: contain;
max-width: 60px;
}
.sponsor-logo-broadcast {
object-fit: contain;
height: 80px;
width: 80px; /* Example height matching QR code */
}
}
@media screen and (min-height: 1080px) {
.sponsor-logo-broadcast {
object-fit: contain;
height: 120px;
width: 120px; /* Example height matching QR code */
}
#header {
margin-bottom: 40px;
}
.footer-broadcast {
margin-bottom: 40px;
}
}
@media screen and (min-height: 2160px) {
.sponsor-logo-broadcast {
object-fit: contain;
height: 200px;
width: 200px; /* Example height matching QR code */
}
#header {
margin-bottom: 80px;
}
.footer-broadcast {
margin-bottom: 80px;
}
}

@ -46,7 +46,7 @@ h1 {
} }
header { header {
padding: 0px 10px; padding: 0px 0px;
font-size: 1.5em; font-size: 1.5em;
} }
@ -112,7 +112,7 @@ hr {
@media print, screen and (min-width: 40em) { @media print, screen and (min-width: 40em) {
.wrapper { .wrapper {
margin: 0px 40px; margin: 0px 20px;
} }
} }
@ -202,6 +202,11 @@ tr {
font-weight: 600; font-weight: 600;
} }
.mybox.active {
background-color: #f39200;
color: #ffffff;
}
@media (max-width: 80em) { @media (max-width: 80em) {
font-size: 0.8em; font-size: 0.8em;
padding: 0px; padding: 0px;
@ -297,8 +302,6 @@ tr {
.logo { .logo {
height: 80px; height: 80px;
margin: 20px 0px;
/* padding: 5px 10px; */
} }
.padding-bottom-small { .padding-bottom-small {
@ -927,7 +930,7 @@ h-margin {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 20px 40px; padding: 20px;
} }
.left-content { .left-content {
@ -936,7 +939,8 @@ h-margin {
} }
.right-content { .right-content {
margin-left: auto; display: flex;
align-items: center;
} }
/* CRM form */ /* CRM form */
@ -1074,13 +1078,13 @@ h-margin {
.match-status-container-header { .match-status-container-header {
margin-top: -20px; margin-top: -20px;
height: 40px; height: 50px;
text-align: left; text-align: left;
} }
.match-status-container-header-bottom { .match-status-container-header-bottom {
margin-bottom: -20px; margin-bottom: -20px;
height: 40px; height: 50px;
text-align: left; text-align: left;
} }

@ -36,6 +36,14 @@
margin-bottom: 80px; margin-bottom: 80px;
} }
.broadcast-mode .butterfly-bracket {
display: flex;
gap: 40px; /* Increased to account for horizontal lines (20px on each side) */
position: relative;
margin-bottom: 80px;
font-size: clamp(0.6em, 1vw, 1.2em) !important;
}
.round-title { .round-title {
position: absolute; position: absolute;
top: 0px; /* Adjust this value to position the title where you want it */ top: 0px; /* Adjust this value to position the title where you want it */
@ -54,7 +62,7 @@
} }
.round-title.broadcast-mode { .round-title.broadcast-mode {
font-size: 0.9em; font-size: 0.8em;
width: auto; /* Change from 100% to auto */ width: auto; /* Change from 100% to auto */
} }
@ -208,12 +216,13 @@
.broadcast-mode .round-name, .broadcast-mode .round-name,
.broadcast-mode .round-format { .broadcast-mode .round-format {
padding: 0px; padding: 0px;
color: #505050; color: white;
} }
.broadcast-mode .round-title { .broadcast-mode .round-title {
padding: 8px 20px; /* Slightly more horizontal padding */ padding: 8px 20px; /* Slightly more horizontal padding */
background-color: white; background-color: #1a223a;
color: white !important;
align-content: center; align-content: center;
border-radius: 24px; border-radius: 24px;
} }
@ -227,8 +236,12 @@
background-color: #505050 !important; /* Bright yellow - change to your preferred color */ background-color: #505050 !important; /* Bright yellow - change to your preferred color */
} }
/* Broadcast mode styling for all lines */ .broadcast-mode
.broadcast-mode .butterfly-match::before, .butterfly-round:first-child
.butterfly-match.butterfly-match:has(.incoming-line.disabled)::before {
visibility: hidden;
}
.broadcast-mode .butterfly-match.reverse-bracket::before, .broadcast-mode .butterfly-match.reverse-bracket::before,
.broadcast-mode .incoming-line, .broadcast-mode .incoming-line,
.broadcast-mode .outgoing-line, .broadcast-mode .outgoing-line,
@ -252,6 +265,98 @@
border-radius: 0 0 24px 24px; /* Match the bubble's bottom corners */ border-radius: 0 0 24px 24px; /* Match the bubble's bottom corners */
} }
.logo {
max-width: 80px;
}
.match-result.broadcast-mode { .match-result.broadcast-mode {
padding: 0px; font-size: 1.2em;
}
.round-title.broadcast-mode {
font-size: 1em;
}
.sponsor-logo-broadcast.screen-size-overlay {
object-fit: contain;
height: 80px;
width: 80px; /* Example height matching QR code */
}
@media screen and (max-height: 720px) {
.sponsor-logo-broadcast.screen-size-overlay {
object-fit: contain;
height: 80px;
width: 80px; /* Example height matching QR code */
}
.logo {
max-width: 60px !important;
}
.bubble.broadcast-bracket-match {
padding: 12px !important;
}
.match-result.broadcast-mode {
padding: 0px;
}
.match-result.broadcast-mode {
font-size: 1em;
}
.round-title.broadcast-mode {
font-size: 0.6em;
}
#screen-size-overlay {
font-size: clamp(0.4em, 0.6em, 0.8em) !important;
}
}
@media screen and (min-height: 1080px) {
.sponsor-logo-broadcast.screen-size-overlay {
object-fit: contain;
height: 120px;
width: 120px; /* Example height matching QR code */
}
.logo {
max-width: 80px;
}
.match-result.broadcast-mode {
font-size: 1.2em;
}
.round-title.broadcast-mode {
font-size: 1em;
}
#screen-size-overlay {
font-size: clamp(0.6em, 0.8em, 1em) !important;
}
}
@media screen and (min-height: 2160px) {
.sponsor-logo-broadcast.screen-size-overlay {
object-fit: contain;
height: 200px;
width: 200px; /* Example height matching QR code */
}
.logo {
max-width: 120px;
}
.match-result.broadcast-mode {
font-size: 2em;
}
.round-title.broadcast-mode {
font-size: 1.5em;
}
#screen-size-overlay {
font-size: clamp(1.2em, 1.4em, 1.6em) !important;
}
} }

@ -36,21 +36,50 @@ function renderBracket(options) {
const screenWidth = window.innerWidth; const screenWidth = window.innerWidth;
let roundTotalCount = roundCount; let roundTotalCount = roundCount;
if (doubleButterflyMode == true && roundCount > 1) { let initialPadding = 40;
if (doubleButterflyMode == true && roundCount > 4) {
roundTotalCount = roundCount - 1; roundTotalCount = roundCount - 1;
initialPadding = 46;
} }
const padding = 50 * roundTotalCount; // Account for some padding/margin const padding = initialPadding * roundTotalCount; // Account for some padding/margin
const availableWidth = screenWidth - padding; const availableWidth = screenWidth - padding;
responsiveMatchWidth = Math.min( let responsiveMatchWidth = Math.min(
365, 365,
Math.max(365, Math.floor(availableWidth / roundTotalCount)), Math.max(365, Math.floor(availableWidth / roundTotalCount)),
); );
let topMargin = 0;
if (isBroadcast) { if (isBroadcast) {
responsiveMatchWidth = Math.min( responsiveMatchWidth = Math.floor(availableWidth / roundTotalCount);
365, let screenHeight = window.innerHeight;
Math.floor(availableWidth / roundTotalCount), if (roundTotalCount <= 1) {
); topMargin = 240;
} else if (roundTotalCount == 2) {
topMargin = 180;
} else if (roundTotalCount == 3) {
topMargin = 120;
} else if (roundTotalCount == 4) {
topMargin = 60;
} else if (roundTotalCount == 5) {
topMargin = 40;
} else if (roundTotalCount == 6) {
topMargin = -20;
} else {
topMargin = 0;
}
if (screenHeight <= 720) {
} else if (screenHeight <= 1200) {
if (topMargin <= 0) {
topMargin = 40;
}
topMargin = topMargin * 2;
} else {
if (topMargin <= 0) {
topMargin = 120;
}
topMargin = topMargin * 3;
}
} }
rounds.forEach((roundMatches, roundIndex) => { rounds.forEach((roundMatches, roundIndex) => {
@ -61,7 +90,14 @@ function renderBracket(options) {
const roundDiv = document.createElement("div"); const roundDiv = document.createElement("div");
roundDiv.className = "butterfly-round"; roundDiv.className = "butterfly-round";
roundDiv.style.setProperty("--match-width", `${responsiveMatchWidth}px`); roundDiv.style.setProperty("--match-width", `${responsiveMatchWidth}px`);
if (doubleButterflyMode == true && roundCount > 3) {
if (roundIndex >= finalRoundIndex - 1) {
roundDiv.style.transform = `translateX(-50%)`;
if (roundIndex >= finalRoundIndex + 2) {
roundDiv.style.transform = `translateX(-100%)`;
}
}
}
// Create title // Create title
const titleDiv = document.createElement("div"); const titleDiv = document.createElement("div");
titleDiv.className = "round-title"; titleDiv.className = "round-title";
@ -105,14 +141,6 @@ function renderBracket(options) {
// Create matches container // Create matches container
const matchesContainer = document.createElement("div"); const matchesContainer = document.createElement("div");
matchesContainer.className = "matches-container"; matchesContainer.className = "matches-container";
if (doubleButterflyMode == true && roundCount > 3) {
if (roundIndex >= finalRoundIndex - 1) {
matchesContainer.style.transform = `translateX(-50%)`;
if (roundIndex >= finalRoundIndex + 2) {
matchesContainer.style.transform = `translateX(-100%)`;
}
}
}
roundDiv.appendChild(matchesContainer); roundDiv.appendChild(matchesContainer);
if (matchPositions[roundIndex] == undefined) { if (matchPositions[roundIndex] == undefined) {
matchPositions[roundIndex] = {}; matchPositions[roundIndex] = {};
@ -322,7 +350,18 @@ function renderBracket(options) {
"--next-match-distance", "--next-match-distance",
`${nextMatchDistance}px`, `${nextMatchDistance}px`,
); );
matchDiv.style.top = `${top}px`;
let roundTopMargin = 80;
if (doubleButterflyMode) {
if (window.innerHeight <= 720) {
roundTopMargin = 50;
} else if (window.innerHeight > 720 && window.innerHeight <= 1200) {
roundTopMargin = 80;
} else if (window.innerHeight > 1200) {
roundTopMargin = 140;
}
}
matchDiv.style.top = `${top + topMargin}px`;
matchPositions[roundIndex][matchRealIndex] = top; matchPositions[roundIndex][matchRealIndex] = top;
if (matchIndex === 0) { if (matchIndex === 0) {
@ -339,19 +378,27 @@ function renderBracket(options) {
// } // }
// Position title above the first match // Position title above the first match
titleDiv.style.top = `${-80}px`; // Adjust the 60px offset as needed titleDiv.style.top = `${topMargin - roundTopMargin}px`; // Adjust the 60px offset as needed
if ( if (
(roundIndex == finalRoundIndex && realRoundIndex == 0) || (roundIndex == finalRoundIndex && realRoundIndex == 0) ||
isBroadcast == true isBroadcast == true
) { ) {
titleDiv.style.top = `${top - 80}px`; // Adjust the 60px offset as needed titleDiv.style.top = `${top + topMargin - roundTopMargin}px`; // Adjust the 60px offset as needed
} }
titleDiv.style.position = "absolute"; titleDiv.style.position = "absolute";
if (roundCount >= 5 && doubleButterflyMode == true) { if (roundCount >= 5 && doubleButterflyMode == true) {
let offset = 40;
if (window.innerHeight <= 720) {
offset = 40;
} else if (window.innerHeight <= 1200) {
offset = 60;
} else {
offset = 120;
}
if (roundIndex == finalRoundIndex - 1) { if (roundIndex == finalRoundIndex - 1) {
titleDiv.style.marginLeft = "60px"; titleDiv.style.marginLeft = `${offset}px`;
} else if (roundIndex == finalRoundIndex + 1) { } else if (roundIndex == finalRoundIndex + 1) {
titleDiv.style.marginLeft = "-60px"; titleDiv.style.marginLeft = `-${offset}px`;
} }
} }
matchesContainer.appendChild(titleDiv); matchesContainer.appendChild(titleDiv);
@ -375,11 +422,16 @@ function renderBracket(options) {
titleDiv.className = "round-title"; titleDiv.className = "round-title";
titleDiv.appendChild(nameSpan); titleDiv.appendChild(nameSpan);
titleDiv.appendChild(formatSpan); titleDiv.appendChild(formatSpan);
titleDiv.style.top = `${top - 80}px`; // Adjust the 60px offset as needed titleDiv.style.top = `${top + topMargin - 80}px`; // Adjust the 60px offset as needed
titleDiv.style.position = "absolute"; titleDiv.style.position = "absolute";
matchesContainer.appendChild(titleDiv); matchesContainer.appendChild(titleDiv);
} }
if (roundIndex == 0 && roundCount > 3) {
isIncomingLineIsDisabled = true;
} else if (roundIndex == 0 && roundCount < 4) {
isIncomingLineIsDisabled = false;
}
matchDiv.innerHTML = ` matchDiv.innerHTML = `
<div class="incoming-line ${isIncomingLineIsDisabled ? "disabled" : ""}"></div> <div class="incoming-line ${isIncomingLineIsDisabled ? "disabled" : ""}"></div>
<div class="match-content ${isDisabled ? "disabled" : ""}">${matchTemplate.innerHTML}</div> <div class="match-content ${isDisabled ? "disabled" : ""}">${matchTemplate.innerHTML}</div>

@ -10,10 +10,13 @@
<a href="{% url 'clubs' %}" class="orange">Clubs</a> <a href="{% url 'clubs' %}" class="orange">Clubs</a>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<a href="{% url 'my-tournaments' %}" class="orange">Mes tournois</a> <a href="{% url 'my-tournaments' %}" class="orange">Mes tournois</a>
<a href="{% url 'shop:my_orders' %}">Mes commandes</a>
<a href="{% url 'profile' %}">Mon compte</a> <a href="{% url 'profile' %}">Mon compte</a>
<a href="{% url 'shop:product_list' %}">La boutique</a>
<a href="{% url 'custom_logout' %}" class="red">Se déconnecter</a> <a href="{% url 'custom_logout' %}" class="red">Se déconnecter</a>
{% else %} {% else %}
<a href="{% url 'login' %}">Se connecter</a> <a href="{% url 'login' %}">Se connecter</a>
<a href="{% url 'shop:product_list' %}">La boutique</a>
{% endif %} {% endif %}
</nav> </nav>
@ -45,24 +48,26 @@
</div> </div>
{% endif %} {% endif %}
<div class="cell medium-6 large-6 topblock padding10"> <div class="grid-x">
<div class="bubble"> <div class="cell medium-6 large-6 topblock padding10">
<label class="title">Mes informations</label> <div class="bubble">
<form method="post"> <label class="title">Mes informations</label>
{% csrf_token %} <form method="post">
{{ form.as_p }} {% csrf_token %}
<button type="submit" class="rounded-button">Sauver les changements</button> {{ form.as_p }}
</form> <button type="submit" class="rounded-button">Sauver les changements</button>
</form>
</div>
</div> </div>
</div> <div class="cell medium-6 large-6 topblock padding10">
<div class="cell medium-6 large-6 topblock padding10"> <div class="bubble">
<div class="bubble"> <label class="title">Mot de passe</label>
<label class="title">Mot de passe</label> <form method="post" action="{% url 'custom_password_change' %}">
<form method="post" action="{% url 'custom_password_change' %}"> {% csrf_token %}
{% csrf_token %} {{ password_change_form.as_p }}
{{ password_change_form.as_p }} <button type="submit" class="rounded-button">Modifier le mot de passe</button>
<button type="submit" class="rounded-button">Modifier le mot de passe</button> </form>
</form> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

@ -102,7 +102,10 @@
{{ player.first_name }} {{ player.last_name }}{% if player.licence_id %} ({{ player.licence_id }}){% endif %} {{ player.first_name }} {{ player.last_name }}{% if player.licence_id %} ({{ player.licence_id }}){% endif %}
</div> </div>
<div> <div>
{{ player.club_name }} {{ player.club_name }}{% if player.club_member %} | Membre du club{% endif %}{% if player.club_member and tournament.club_member_fee_deduction %} | Tarif réduit{% endif %}
</div>
<div>
{{ player.email }}
</div> </div>
<div> <div>
Classement à ce jour : {% if player.rank %}{{ player.rank }}{% if player.computed_rank and player.rank != player.computed_rank %} ({{ player.computed_rank }}){% endif %}{% else %}Non classé ({{ player.computed_rank }}){% endif %} Classement à ce jour : {% if player.rank %}{{ player.rank }}{% if player.computed_rank and player.rank != player.computed_rank %} ({{ player.computed_rank }}){% endif %}{% else %}Non classé ({{ player.computed_rank }}){% endif %}
@ -194,7 +197,7 @@
Confirmer votre inscription en payant immédiatement : Confirmer votre inscription en payant immédiatement :
</div> </div>
<button type="submit" name="proceed_to_payment" class="rounded-button"> <button type="submit" name="proceed_to_payment" class="rounded-button">
Procéder au paiement ({{ tournament.team_fee }}€) Procéder au paiement de {{ cart_data.team_fee_from_cart_players|floatformat:2 }}€
</button> </button>
{% endif %} {% 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 is False or tournament.online_payment_is_mandatory is False or cart_data.waiting_list_position >= 0 %}

@ -0,0 +1,17 @@
{% extends 'tournaments/base.html' %}
{% block head_title %} Intégration Stripe {% endblock %}
{% block first_title %} Padel Club {% endblock %}
{% block second_title %} Intégration Stripe {% endblock %}
{% block content %}
{% load static %}
{% load tz %}
<div class="grid-x">
<div class="bubble">
<div class="cell medium-6 large-6 padding10">
<label class="title">Intégration Stripe Terminée !</label>
<p>Veuillez retourner dans l'application pour terminer et valider la configuration.</p>
</div>
</div>
{% endblock %}

@ -0,0 +1,19 @@
{% extends 'tournaments/base.html' %}
{% block head_title %} Configuration Stripe {% endblock %}
{% block first_title %} Padel Club {% endblock %}
{% block second_title %} Configuration Stripe {% endblock %}
{% block content %}
{% load static %}
{% load tz %}
<div class="grid-x">
<div class="bubble">
<div class="cell medium-6 large-6 padding10">
<label class="title">Renouvellement du Lien Stripe</label>
<p>Votre lien d'intégration Stripe a expiré. Un nouveau lien doit être généré.</p>
<p>Veuillez retourner dans l'application et ré-essayer.</p>
</div>
</div>
</div>
{% endblock %}

@ -6,6 +6,19 @@
{% block first_title %}{{ tournament.event.display_name }}{% endblock %} {% block first_title %}{{ tournament.event.display_name }}{% endblock %}
{% block second_title %}Broadcast{% endblock %} {% block second_title %}Broadcast{% endblock %}
{% block sponsors %}
{% if tournament.event.images.exists %}
<div class="bubble-footer">
<div class="bubble-sponsor">
{% for image in tournament.event.images.all %}
<img src="{{ image.image.url }}" alt="{{ image.title|default:'Sponsor' }}"
class="sponsor-logo-broadcast">
{% endfor %}
</div>
</div>
{% endif %}
{% endblock %}
{% block content %} {% block content %}
<div class="grid-x"> <div class="grid-x">
@ -17,6 +30,7 @@
<div><a href="{% url 'broadcasted-summons' tournament.id %}">Convocations</a></div> <div><a href="{% url 'broadcasted-summons' tournament.id %}">Convocations</a></div>
<div><a href="{% url 'broadcasted-rankings' tournament.id %}">Classement</a></div> <div><a href="{% url 'broadcasted-rankings' tournament.id %}">Classement</a></div>
<div><a href="{% url 'broadcasted-prog' tournament.id %}">(beta) Programmation</a></div> <div><a href="{% url 'broadcasted-prog' tournament.id %}">(beta) Programmation</a></div>
<div><a href="{% url 'broadcasted-planning' tournament.id %}">(beta) Planning</a></div>
<div><a href="{% url 'broadcasted-bracket' tournament.id %}">(beta) Tableau</a></div> <div><a href="{% url 'broadcasted-bracket' tournament.id %}">(beta) Tableau</a></div>
</div> </div>

@ -34,7 +34,7 @@
<header> <header>
<div id="header"> <div id="header">
<div class="left-content bubble"> <div class="left-content bubble-header">
<img src="{% static 'tournaments/images/PadelClub_logo_512.png' %}" alt="logo" class="logo"> <img src="{% static 'tournaments/images/PadelClub_logo_512.png' %}" alt="logo" class="logo">
<div class="left-margin"> <div class="left-margin">
<h1 class="club">{% block first_title %}Page Title{% endblock %}</h1> <h1 class="club">{% block first_title %}Page Title{% endblock %}</h1>
@ -58,4 +58,7 @@
</div> </div>
</body> </body>
<footer class="footer-broadcast">
{% block sponsors %}{% endblock %}
</footer>
</html> </html>

@ -32,6 +32,7 @@
<div class="table-cell"> <div class="table-cell">
<span><a href="{% url 'automatic-broadcast' tournament.id %}">Automatic</a></span> | <span><a href="{% url 'automatic-broadcast' tournament.id %}">Automatic</a></span> |
<span><a href="{% url 'broadcasted-bracket' tournament.id %}">(beta) Tableau</a></span> | <span><a href="{% url 'broadcasted-bracket' tournament.id %}">(beta) Tableau</a></span> |
<span><a href="{% url 'broadcasted-planning' tournament.id %}">(beta) Planning</a></span> |
<span><a href="{% url 'broadcasted-prog' tournament.id %}">(beta) Programmation</a></span> | <span><a href="{% url 'broadcasted-prog' tournament.id %}">(beta) Programmation</a></span> |
<span><a href="{% url 'broadcasted-matches' tournament.id %}">Matchs</a></span> | <span><a href="{% url 'broadcasted-matches' tournament.id %}">Matchs</a></span> |
<span><a href="{% url 'broadcasted-group-stages' tournament.id %}">Poules</a></span> | <span><a href="{% url 'broadcasted-group-stages' tournament.id %}">Poules</a></span> |

@ -39,6 +39,7 @@
paginatedRankings: null, paginatedRankings: null,
active: 1, active: 1,
hide_weight: {{ tournament.hide_weight|lower }}, hide_weight: {{ tournament.hide_weight|lower }},
has_sponsors: {{ tournament.has_sponsors|lower }},
prefixTitle: '', prefixTitle: '',
retrieveData() { retrieveData() {
fetch('/tournament/{{ tournament.id }}/broadcast/json/') fetch('/tournament/{{ tournament.id }}/broadcast/json/')
@ -58,6 +59,17 @@
}, },
paginateSummons(array) { paginateSummons(array) {
let pageSize = 16 let pageSize = 16
if (window.innerHeight <= 720) {
pageSize = 12;
if (this.has_sponsors) {
pageSize = 10
}
} else if (window.innerHeight <= 1080) {
if (this.has_sponsors) {
pageSize = 12
}
}
pages = this.paginate(array, pageSize) pages = this.paginate(array, pageSize)
const splitGroups = [] const splitGroups = []
@ -74,6 +86,17 @@
}, },
paginateRankings(array) { paginateRankings(array) {
let pageSize = 16 let pageSize = 16
if (window.innerHeight <= 720) {
pageSize = 12;
if (this.has_sponsors) {
pageSize = 10
}
} else if (window.innerHeight <= 1080) {
if (this.has_sponsors) {
pageSize = 12
}
}
pages = this.paginate(array, pageSize) pages = this.paginate(array, pageSize)
const splitGroups = [] const splitGroups = []
@ -121,7 +144,7 @@
}" x-init="loop()"> }" x-init="loop()">
<header> <header>
<div id="header"> <div id="header">
<div class="left-content bubble"> <div class="left-content bubble-header">
<img src="{% static 'tournaments/images/PadelClub_logo_512.png' %}" alt="logo" class="logo"> <img src="{% static 'tournaments/images/PadelClub_logo_512.png' %}" alt="logo" class="logo">
<div class="left-margin"> <div class="left-margin">
<h1 class="club">{{ tournament.broadcast_event_display_name }}</h1> <h1 class="club">{{ tournament.broadcast_event_display_name }}</h1>
@ -177,4 +200,17 @@
</div> </div>
</body> </body>
<footer class="footer-broadcast">
{% if tournament.event.images.exists %}
<div class="bubble-footer">
<div class="bubble-sponsor">
{% for image in tournament.event.images.all %}
<img src="{{ image.image.url }}" alt="{{ image.title|default:'Sponsor' }}"
class="sponsor-logo-broadcast">
{% endfor %}
</div>
</div>
{% endif %}
</footer>
</html> </html>

@ -38,6 +38,7 @@
paginatedGroupStages: null, paginatedGroupStages: null,
paginatedSummons: null, paginatedSummons: null,
paginatedRankings: null, paginatedRankings: null,
has_sponsors: {{ tournament.has_sponsors|lower }},
active: 1, active: 1,
hide_weight: {{ tournament.hide_weight|lower }}, hide_weight: {{ tournament.hide_weight|lower }},
prefixTitle: '', prefixTitle: '',
@ -84,7 +85,18 @@
}, },
paginateSummons(array) { paginateSummons(array) {
let pageSize = 16; let pageSize = 16
if (window.innerHeight <= 720) {
pageSize = 12;
if (this.has_sponsors) {
pageSize = 10
}
} else if (window.innerHeight <= 1080) {
if (this.has_sponsors) {
pageSize = 12
}
}
pages = this.paginate(array, pageSize); pages = this.paginate(array, pageSize);
const splitGroups = []; const splitGroups = [];
@ -101,7 +113,17 @@
}, },
paginateRankings(array) { paginateRankings(array) {
let pageSize = 16; let pageSize = 16
if (window.innerHeight <= 720) {
pageSize = 12;
if (this.has_sponsors) {
pageSize = 10
}
} else if (window.innerHeight <= 1080) {
if (this.has_sponsors) {
pageSize = 12
}
}
pages = this.paginate(array, pageSize); pages = this.paginate(array, pageSize);
const splitGroups = []; const splitGroups = [];
@ -202,7 +224,7 @@
<template x-for="i in paginatedSummons.length"> <template x-for="i in paginatedSummons.length">
<template x-for="column in paginatedSummons[i-1]"> <template x-for="column in paginatedSummons[i-1]">
<div class="cell medium-6 large-6 topblock padding10" x-show="active === i"> <div class="cell medium-6 large-6 topblock" x-show="active === i">
{% include 'tournaments/broadcast/broadcasted_summon.html' %} {% include 'tournaments/broadcast/broadcasted_summon.html' %}
</div> </div>
</template> </template>
@ -210,7 +232,7 @@
<template x-for="i in paginatedMatches.length" > <template x-for="i in paginatedMatches.length" >
<template x-for="match in paginatedMatches[i-1]" > <template x-for="match in paginatedMatches[i-1]" >
<div class="cell medium-6 large-3 padding10" x-show="active === i + paginatedSummons.length"> <div class="cell medium-6 large-3" x-show="active === i + paginatedSummons.length">
{% include 'tournaments/broadcast/broadcasted_match.html' %} {% include 'tournaments/broadcast/broadcasted_match.html' %}
</div> </div>
</template> </template>
@ -218,7 +240,7 @@
<template x-for="i in paginatedGroupStages.length"> <template x-for="i in paginatedGroupStages.length">
<template x-for="group_stage in paginatedGroupStages[i-1]"> <template x-for="group_stage in paginatedGroupStages[i-1]">
<div class="cell medium-6 large-3 padding10" x-show="active === i + paginatedSummons.length + paginatedMatches.length"> <div class="cell medium-6 large-3" x-show="active === i + paginatedSummons.length + paginatedMatches.length">
{% include 'tournaments/broadcast/broadcasted_group_stage.html' %} {% include 'tournaments/broadcast/broadcasted_group_stage.html' %}
</div> </div>
</template> </template>

@ -4,6 +4,7 @@
<html> <html>
<head> <head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8" /> <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<link rel="stylesheet" href="{% static 'tournaments/css/foundation.min.css' %}" /> <link rel="stylesheet" href="{% static 'tournaments/css/foundation.min.css' %}" />
<link rel="stylesheet" href="{% static 'tournaments/css/basics.css' %}" /> <link rel="stylesheet" href="{% static 'tournaments/css/basics.css' %}" />
<link rel="stylesheet" href="{% static 'tournaments/css/style.css' %}" /> <link rel="stylesheet" href="{% static 'tournaments/css/style.css' %}" />
@ -32,19 +33,29 @@
})(); })();
</script> </script>
<!-- End Matomo Code --> <!-- End Matomo Code -->
<style>
#screen-size-overlay {
position: fixed;
left: 50%;
transform: translateX(-50%); /* Center it exactly */
color: white;
padding: 20px;
max-width: 45%;
}
</style>
</head> </head>
<body> <body>
<header> <header>
<div id="header"> <div id="screen-size-overlay">
<div class="left-content bubble"> <div class="left-content bubble-header screen-size-overlay">
<img src="{% static 'tournaments/images/PadelClub_logo_512.png' %}" alt="logo" class="logo"> <img src="{% static 'tournaments/images/PadelClub_logo_512.png' %}" alt="logo" class="logo" style="object-fit: contain;">
<div class="left-margin"> <div class="left-margin">
<h1 class="club">{{ tournament.broadcast_event_display_name }}</h1> <h1 class="club">{{ tournament.broadcast_event_display_name }}</h1>
<h1 class="event">Tableau {{ tournament.broadcast_display_name }}</h1> <h1 class="event">Tableau {{ tournament.broadcast_display_name }}</h1>
</div> </div>
</div> </div>
<div class="right-content">{% qr_from_text qr_code_url options=qr_code_options %}</div>
</div> </div>
</header> </header>
@ -157,4 +168,16 @@
setInterval(fetchAndRenderBracket, 15000); setInterval(fetchAndRenderBracket, 15000);
</script> </script>
</body> </body>
<footer class="footer-broadcast">
{% if tournament.event.images.exists %}
<div class="bubble-footer">
<div class="bubble-sponsor">
{% for image in tournament.event.images.all %}
<img src="{{ image.image.url }}" alt="{{ image.title|default:'Sponsor' }}"
class="sponsor-logo-broadcast screen-size-overlay">
{% endfor %}
</div>
</div>
{% endif %}
</footer>
</html> </html>

@ -39,7 +39,7 @@
</div> </div>
</div> </div>
<div x-show="group_stage.teams[i-1].match_count > 0 && group_stage.started === true"> <div x-show="group_stage.started === true">
<div class="score ws numbers"><span x-text="group_stage.teams[i-1].win_loss"></span></div> <div class="score ws numbers"><span x-text="group_stage.teams[i-1].win_loss"></span></div>
<div class="ws numbers"><span x-text="group_stage.teams[i-1].diff"></span></div> <div class="ws numbers"><span x-text="group_stage.teams[i-1].diff"></span></div>
</div> </div>

@ -65,7 +65,7 @@
<header> <header>
<div id="header"> <div id="header">
<div class="left-content bubble"> <div class="left-content bubble-header">
<img src="{% static 'tournaments/images/PadelClub_logo_512.png' %}" alt="logo" class="logo"> <img src="{% static 'tournaments/images/PadelClub_logo_512.png' %}" alt="logo" class="logo">
<div class="left-margin"> <div class="left-margin">
<h1 class="club">{{ tournament.broadcast_event_display_name }}</h1> <h1 class="club">{{ tournament.broadcast_event_display_name }}</h1>
@ -101,4 +101,16 @@
</div> </div>
</body> </body>
<footer class="footer-broadcast">
{% if tournament.event.images.exists %}
<div class="bubble-footer">
<div class="bubble-sponsor">
{% for image in tournament.event.images.all %}
<img src="{{ image.image.url }}" alt="{{ image.title|default:'Sponsor' }}"
class="sponsor-logo-broadcast">
{% endfor %}
</div>
</div>
{% endif %}
</footer>
</html> </html>

@ -64,7 +64,7 @@
<header> <header>
<div id="header"> <div id="header">
<div class="left-content bubble"> <div class="left-content bubble-header">
<img src="{% static 'tournaments/images/PadelClub_logo_512.png' %}" alt="logo" class="logo"> <img src="{% static 'tournaments/images/PadelClub_logo_512.png' %}" alt="logo" class="logo">
<div class="left-margin"> <div class="left-margin">
<h1 class="club">{{ tournament.broadcast_event_display_name }}</h1> <h1 class="club">{{ tournament.broadcast_event_display_name }}</h1>
@ -97,4 +97,17 @@
</main> </main>
</div> </div>
</body> </body>
<footer class="footer-broadcast">
{% if tournament.event.images.exists %}
<div class="bubble-footer">
<div class="bubble-sponsor">
{% for image in tournament.event.images.all %}
<img src="{{ image.image.url }}" alt="{{ image.title|default:'Sponsor' }}"
class="sponsor-logo-broadcast">
{% endfor %}
</div>
</div>
{% endif %}
</footer>
</html> </html>

@ -0,0 +1,375 @@
<!DOCTYPE html>
{% load static %}
{% load qr_code %}
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<link rel="stylesheet" href="{% static 'tournaments/css/foundation.min.css' %}" />
<link rel="stylesheet" href="{% static 'tournaments/css/basics.css' %}" />
<link rel="stylesheet" href="{% static 'tournaments/css/style.css' %}" />
<link rel="stylesheet" href="{% static 'tournaments/css/broadcast.css' %}" />
<style>
.bubble {
padding: 10px;
background-color: white;
border-radius: 24px;
box-shadow: 0 0 0px 0px #fbead6;
}
.running {
background-color: #90ee90 !important;
}
.timeslot {
font-family: "Anybody-ExtraBold";
font-size: clamp(0.4rem, 1.5vw - 0.2rem, 3rem); /* Adjust these values as needed */
color: white;
}
.match-cell {
box-sizing: border-box;
padding: 0;
flex-grow: 0;
flex-shrink: 0;
}
.match-cell .bubble {
height: 13vh;
display: flex;
flex-direction: column;
justify-content: center;
box-sizing: border-box;
/* Dynamic font size based on viewport width */
font-size: clamp(0.6rem, 1.2vw - 0.2rem, 5rem); /* Adjust these values as needed */
overflow: hidden;
text-overflow: ellipsis;
color: black;
background-color: white;
margin: 0;
width: 100%;
}
.match-cell .bubble.even {
background-color: white;
}
.match-cell .bubble.empty {
background-color: rgba(173, 216, 230, 0.3);
color: white;
}
.match-cell .bubble.ended {
background-color: rgba(173, 216, 230, 0.3);
}
.court-label {
box-sizing: border-box;
padding: 0;
flex-grow: 0;
flex-shrink: 0;
}
.court-label .bubble {
height: 4vh;
font-weight: bold;
margin: 0;
width: 100%;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
/* Slightly smaller dynamic font size for court labels */
font-size: clamp(0.7rem, 0.8vw + 0.1rem, 3rem); /* Adjust these values as needed */
color: white;
background: #1a223a;
}
.bubble-timeslot {
color: white;
background: #1a223a;
display: flex;
border-radius: 24px;
box-shadow: 0 0 0px 0px #fbead6;
align-items: center;
justify-content: center;
padding: 0px 20px;
margin: 0;
}
.courts-row,
.matches-row {
display: flex;
width: 100%;
gap: 10px;
}
.grid-x {
display: flex;
flex-wrap: wrap;
}
.matchtitle {
font-size: inherit; /* Inherit the dynamic font size from .bubble */
font-weight: bold;
margin-bottom: 0.2em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.minor-info {
font-size: inherit; /* Inherit the dynamic font size from .bubble */
font-weight: normal;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.score {
font-size: inherit; /* Inherit the dynamic font size from .bubble */
}
.bold {
font-weight: bold;
}
.ws {
white-space: nowrap;
}
</style>
<link rel="icon" type="image/png" href="{% static 'tournaments/images/favicon.png' %}" />
<title>Programmation</title>
<script src="{% static 'tournaments/js/alpine.min.js' %}"></script>
<script>
var _paq = window._paq = window._paq || [];
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
_paq.push(["setDocumentTitle", document.domain + "/" + document.title]);
_paq.push(["setDoNotTrack", true]);
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u="//matomo.padelclub.app/";
_paq.push(['setTrackerUrl', u+'matomo.php']);
_paq.push(['setSiteId', '1']);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
})();
</script>
</head>
<body x-data="{
days: [],
currentDayIndex: 0,
currentPageIndex: 0,
has_sponsors: {{ tournament.has_sponsors|lower }},
matchGroups: [],
courtCount: {{ tournament.court_count|default:1 }},
courtNames: {{ tournament.court_names }},
retrieveData() {
fetch('/tournament/{{ tournament.id }}/planning/json/')
.then(res => res.json())
.then((data) => {
this.days = data.days || [];
this.matchGroups = data.match_groups || [];
this.currentPageIndex = 0;
if (this.days.length > 0 && this.currentDayIndex >= this.days.length) {
this.currentDayIndex = 0;
}
});
},
getMatchGroupsForDay(day) {
let groupsPerPage = 12;
if (window.innerHeight <=720) {
groupsPerPage = 8;
} else if (window.innerHeight <=1080) {
groupsPerPage = 10;
}
if (this.has_sponsors) {
groupsPerPage = groupsPerPage - 2;
}
const formattedDay = day;
const filteredGroups = this.matchGroups.filter(group => {
if (!group.matches || group.matches.length === 0) return false;
return group.name && formattedDay && group.name.includes(formattedDay);
});
let groupsPerPageThreshold = this.courtCount >= 5 ? Math.ceil(groupsPerPage / 2) : groupsPerPage;
let columns = this.courtCount >= 5 ? 1 : 2; // Number of columns to display
const paginatedGroups = [];
for (let i = 0; i < Math.ceil(filteredGroups.length / groupsPerPageThreshold); i++) {
// For each page
const pageGroups = filteredGroups.slice(i * groupsPerPageThreshold, (i + 1) * groupsPerPageThreshold);
// Rearrange groups in vertical order
if (columns === 2) {
const rearrangedGroups = [];
const halfPageLength = Math.ceil(pageGroups.length / 2);
// Create groups with alternating order: [0, halfPageLength, 1, halfPageLength+1, ...]
for (let col = 0; col < halfPageLength; col++) {
rearrangedGroups.push(pageGroups[col]); // First column
if (col + halfPageLength < pageGroups.length) {
rearrangedGroups.push(pageGroups[col + halfPageLength]); // Second column
}
}
paginatedGroups.push(rearrangedGroups);
} else {
paginatedGroups.push(pageGroups);
}
}
return paginatedGroups;
},
getCourtNumber(courtIndex) {
if (courtIndex == null) return 999;
return courtIndex;
},
organizeMatchesByCourt(matches) {
const courtMatches = Array(this.courtCount).fill(null);
if (matches && matches.length > 0) {
matches.forEach(match => {
if (match && match.court) {
const courtNum = this.getCourtNumber(match.court_index);
if (courtNum >= 0 && courtNum < this.courtCount) {
courtMatches[courtNum] = match;
}
}
});
}
return courtMatches;
},
loop() {
this.retrieveData();
setInterval(() => {
if (this.days.length > 0) {
const currentDay = this.days[this.currentDayIndex];
const pagesForDay = this.getMatchGroupsForDay(currentDay);
if (pagesForDay && pagesForDay.length > 1) {
const _currentPageIndex = this.currentPageIndex;
this.currentPageIndex = (this.currentPageIndex + 1) % pagesForDay.length;
if (_currentPageIndex >= 1 && this.currentPageIndex === 0) {
this.currentDayIndex = (this.currentDayIndex + 1) % this.days.length;
}
} else {
this.currentPageIndex = 0;
this.currentDayIndex = (this.currentDayIndex + 1) % this.days.length;
}
} else {
this.currentDayIndex = 0;
this.currentPageIndex = 0;
}
}, 15000);
},
calculateFractionWidth() {
if (this.courtCount >= 5) {
const reductionFactor = 0.94; // Adjust this value
return `calc((100% / ${this.courtCount}) * ${reductionFactor})`;
} else {
const reductionFactor = 0.90; // Adjust this value
return `calc((100% / (${this.courtCount})) * ${reductionFactor})`;
}
}
}" x-init="loop()">
<header>
<div id="header" class="header-broadcast">
<div class="left-content bubble-header">
<img src="{% static 'tournaments/images/PadelClub_logo_512.png' %}" alt="logo" class="logo">
<div class="left-margin">
<h1 class="club">{{ tournament.broadcast_event_display_name }}</h1>
<h1 class="event" x-text="days[currentDayIndex]"></h1>
</div>
</div>
<div class="right-content">{% qr_from_text qr_code_url options=qr_code_options %}</div>
</div>
</header>
<div class="wrapper">
<main>
<div class="grid-x">
<div class="cell" :class="{'large-12': courtCount >= 5, 'large-6': courtCount < 5}">
<div style="display: flex; margin-bottom: 10px;">
<div class="bubble-timeslot" style="visibility: hidden; align-items: center; justify-content: center; margin-right: 10px; width: 6vw;">
<h1 class="timeslot">00:00</h1>
</div>
<div class="courts-row" style="margin-left: 10px; margin-bottom: -10px;">
<template x-for="courtName in courtNames" :key="courtName">
<div class="court-label" :style="{'width': calculateFractionWidth()}">
<div class="bubble">
<div class="score ws bold"><span x-text="courtName"></span></div>
</div>
</div>
</template>
</div>
</div>
</div>
<div class="cell" :class="{'large-12': courtCount >= 5, 'large-6': courtCount < 5}" x-show="courtCount < 5">
<div style="display: flex; margin-bottom: 10px;">
<div class="bubble-timeslot" style="visibility: hidden; align-items: center; justify-content: center; margin-right: 10px; width: 6vw;">
<h1 class="timeslot">00:00</h1>
</div>
<div class="courts-row" style="margin-bottom: -10px;">
<template x-for="courtName in courtNames" :key="courtName">
<div class="court-label" :style="{'width': calculateFractionWidth()}">
<div class="bubble">
<div class="score ws bold"><span x-text="courtName"></span></div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
<template x-for="(day, dayIndex) in days" :key="day">
<div class="padding10" x-show="currentDayIndex === dayIndex">
<template x-for="(groupPage, pageIndex) in getMatchGroupsForDay(day)" :key="'page-' + pageIndex">
<div x-show="currentPageIndex === pageIndex">
<div class="grid-x">
<template x-for="(group, groupIndex) in groupPage" :key="groupIndex">
<div class="cell" :class="{'large-12': courtCount >= 5, 'large-6': courtCount < 5}">
<div style="display: flex; margin-bottom: 10px;">
<div class="bubble-timeslot" style="align-items: center; justify-content: center; margin-right: 10px; width: 6vw;">
<h1 class="timeslot" x-text="group.name.slice(-5)"></h1>
</div>
<div class="matches-row">
<template x-for="(match, courtIndex) in organizeMatchesByCourt(group.matches)" :key="courtIndex">
<div class="match-cell" :style="{'width': calculateFractionWidth()}">
<template x-if="match">
<div class="bubble" :class="{'running': !match.ended && match.started, 'even': courtIndex % 2 === 1, 'ended': match.ended}" style="text-align: center;">
<template x-if="match.tournament_title">
<div class="minor-info semibold" x-text="match.tournament_title"></div>
</template>
<div class="bold" x-text="match.group_stage_name ? match.group_stage_name : match.title"></div>
<div class="minor-info" x-text="match.format"></div>
</div>
</template>
<template x-if="!match">
<div class="bubble empty" style="text-align: center;">
</div>
</template>
</div>
</template>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
</template>
</main>
</div>
<footer class="footer-broadcast">
{% if tournament.event.images.exists %}
<div class="bubble-footer">
<div class="bubble-sponsor">
{% for image in tournament.event.images.all %}
<img src="{{ image.image.url }}" alt="{{ image.title|default:'Sponsor' }}"
class="sponsor-logo-broadcast">
{% endfor %}
</div>
</div>
{% endif %}
</footer>
</body>
</html>

@ -64,7 +64,7 @@
<header> <header>
<div id="header"> <div id="header">
<div class="left-content bubble"> <div class="left-content bubble-header">
<img src="{% static 'tournaments/images/PadelClub_logo_512.png' %}" alt="logo" class="logo"> <img src="{% static 'tournaments/images/PadelClub_logo_512.png' %}" alt="logo" class="logo">
<div class="left-margin"> <div class="left-margin">
<h1 class="club">{{ tournament.broadcast_event_display_name }}</h1> <h1 class="club">{{ tournament.broadcast_event_display_name }}</h1>
@ -97,4 +97,16 @@
</main> </main>
</div> </div>
</body> </body>
<footer class="footer-broadcast">
{% if tournament.event.images.exists %}
<div class="bubble-footer">
<div class="bubble-sponsor">
{% for image in tournament.event.images.all %}
<img src="{{ image.image.url }}" alt="{{ image.title|default:'Sponsor' }}"
class="sponsor-logo-broadcast">
{% endfor %}
</div>
</div>
{% endif %}
</footer>
</html> </html>

@ -3,7 +3,18 @@
{% block head_title %}Classement{% endblock %} {% block head_title %}Classement{% endblock %}
{% block first_title %}{{ tournament.broadcast_event_display_name }}{% endblock %} {% block first_title %}{{ tournament.broadcast_event_display_name }}{% endblock %}
{% block second_title %}Classement {{ tournament.broadcast_display_name }}{% endblock %} {% block second_title %}Classement {{ tournament.broadcast_display_name }}{% endblock %}
{% block sponsors %}
{% if tournament.event.images.exists %}
<div class="bubble-footer">
<div class="bubble-sponsor">
{% for image in tournament.event.images.all %}
<img src="{{ image.image.url }}" alt="{{ image.title|default:'Sponsor' }}"
class="sponsor-logo-broadcast">
{% endfor %}
</div>
</div>
{% endif %}
{% endblock %}
{% block content %} {% block content %}
{% load static %} {% load static %}
@ -11,12 +22,24 @@
<div x-data="{ <div x-data="{
paginatedRankings: null, paginatedRankings: null,
active: 1, active: 1,
has_sponsors: {{ tournament.has_sponsors|lower }},
retrieveRankings() { retrieveRankings() {
fetch('/tournament/{{ tournament.id }}/rankings/json/') fetch('/tournament/{{ tournament.id }}/rankings/json/')
.then(res => res.json()) .then(res => res.json())
.then((data) => { .then((data) => {
let pageSize = 16 let pageSize = 16
if (window.innerHeight <= 720) {
pageSize = 12;
if (this.has_sponsors) {
pageSize = 10
}
} else if (window.innerHeight <= 1080) {
if (this.has_sponsors) {
pageSize = 12
}
}
this.paginatedRankings = this.paginate(data, pageSize) this.paginatedRankings = this.paginate(data, pageSize)
const splitGroups = []; const splitGroups = [];

@ -3,7 +3,18 @@
{% block head_title %}Convocations{% endblock %} {% block head_title %}Convocations{% endblock %}
{% block first_title %}{{ tournament.broadcast_event_display_name }}{% endblock %} {% block first_title %}{{ tournament.broadcast_event_display_name }}{% endblock %}
{% block second_title %}Convocations {{ tournament.broadcast_display_name }}{% endblock %} {% block second_title %}Convocations {{ tournament.broadcast_display_name }}{% endblock %}
{% block sponsors %}
{% if tournament.event.images.exists %}
<div class="bubble-footer">
<div class="bubble-sponsor">
{% for image in tournament.event.images.all %}
<img src="{{ image.image.url }}" alt="{{ image.title|default:'Sponsor' }}"
class="sponsor-logo-broadcast">
{% endfor %}
</div>
</div>
{% endif %}
{% endblock %}
{% block content %} {% block content %}
{% load static %} {% load static %}
@ -11,12 +22,24 @@
<div x-data="{ <div x-data="{
paginatedMatches: null, paginatedMatches: null,
active: 1, active: 1,
has_sponsors: {{ tournament.has_sponsors|lower }},
retrieveMatches() { retrieveMatches() {
fetch('/tournament/{{ tournament.id }}/summons/json/') fetch('/tournament/{{ tournament.id }}/summons/json/')
.then(res => res.json()) .then(res => res.json())
.then((data) => { .then((data) => {
let pageSize = 16 let pageSize = 16
if (window.innerHeight <= 720) {
pageSize = 12;
if (this.has_sponsors) {
pageSize = 10
}
} else if (window.innerHeight <= 1080) {
if (this.has_sponsors) {
pageSize = 12
}
}
this.paginatedMatches = this.paginate(data, pageSize) this.paginatedMatches = this.paginate(data, pageSize)
const splitGroups = []; const splitGroups = [];

@ -1,12 +1,19 @@
{% load static %} {% load static %}
<div class="cell medium-12 large-3 padding10"> <div class="cell medium-12 large-3 padding10">
<div class="bubble"> {% if match.tournament_title %}
<label class="minor-info bold padding10">{{ match.tournament_title }}</label>
{% endif %}
<div class="bubble">
<div class="match-status-container-header flex-row"> <div class="match-status-container-header flex-row">
{% if prog_mode and match.group_stage_name %}
<label class="matchtitle">{{ match.group_stage_name }}</label>
{% else %}
<label class="matchtitle">{{ match.title }}</label> <label class="matchtitle">{{ match.title }}</label>
{% if not match.ended %} {% endif %}
<label class="right-label minor-info bold">{{ match.court }}</label> {% if match.court %}
<label class="right-label minor-info">{{ match.court }}</label>
{% endif %} {% endif %}
</div> </div>

@ -4,9 +4,11 @@
<a href="{% url 'clubs' %}" class="orange">Clubs</a> <a href="{% url 'clubs' %}" class="orange">Clubs</a>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<a href="{% url 'my-tournaments' %}" class="orange">Mes tournois</a> <a href="{% url 'my-tournaments' %}" class="orange">Mes tournois</a>
<a href="{% url 'shop:my_orders' %}">Mes commandes</a>
<a href="{% url 'profile' %}">Mon compte</a> <a href="{% url 'profile' %}">Mon compte</a>
{% else %} {% else %}
<a href="{% url 'custom-login' %}">Se connecter</a> <a href="{% url 'custom-login' %}">Se connecter</a>
{% endif %} {% endif %}
<a href="{% url 'shop:product_list' %}">La boutique</a>
<a href="{% url 'download' %}" class="download-button">Ajouter vos tournois</a> <a href="{% url 'download' %}" class="download-button">Ajouter vos tournois</a>
</nav> </nav>

@ -1,7 +1,13 @@
<nav class="margin10"> <nav class="margin10">
<a href="{% url 'index' %}" class="topmargin5 orange">Accueil</a>
<a href="{% url 'tournament-info' tournament.id %}" class="topmargin5 orange">Informations</a> <a href="{% url 'tournament-info' tournament.id %}" class="topmargin5 orange">Informations</a>
{% if tournament.display_prog %}
<a href="{% url 'tournament-prog' tournament.id %}" class="topmargin5 orange">Programmation</a>
{% endif %}
{% if tournament.display_matches and tournament.has_bracket %} {% if tournament.display_matches and tournament.has_bracket %}
<a href="{% url 'tournament-bracket' tournament.id %}" class="topmargin5 orange">Tableau</a> <a href="{% url 'tournament-bracket' tournament.id %}" class="topmargin5 orange">Tableau</a>
{% endif %} {% endif %}

@ -1,40 +1,36 @@
{% extends 'tournaments/base.html' %} {% extends 'tournaments/base.html' %}
{% block head_title %}Matchs du {{ tournament.display_name }}{% endblock %} {% block head_title %}Programmation{% endblock %}
{% block first_title %}{{ tournament.event.display_name }}{% endblock %} {% block first_title %}{{ tournament.event.display_name }}{% endblock %}
{% block second_title %}{{ tournament.display_name }}{% endblock %} {% block second_title %}Programmation{% endblock %}
{% block content %} {% block content %}
{% if tournament.display_prog %}
{% if tournament.display_matches %}
{% include 'tournaments/navigation_tournament.html' %} {% include 'tournaments/navigation_tournament.html' %}
{% if tournament.display_matches or tournament.display_group_stages %} {% if days %}
<nav class="margin10">
{% regroup match_groups.matches by start_date|date:"l d F Y" as matches_by_date %} {% for day in days %}
<a href="{% url 'tournament-prog' tournament.id %}?day={{ day|date:'Y-m-d' }}" class="mybox topmargin5 {% if selected_day == day|date:'Y-m-d' %}active{% endif %}">
{% for date in matches_by_date %} {{ day|date:"l d F"|capfirst }}
</a>
{% regroup date.list by start_date|date:"H:i" as matches_by_hour %} {% endfor %}
</nav>
{% for hour_group in matches_by_hour %}
<h1 class="club padding10 topmargin20">{{ date.grouper }} {{ hour_group.grouper }}</h1>
{% regroup hour_group.list by court_index as matches_by_court %}
{% if match_groups %}
{% for match_group in match_groups %}
<h1 class="club padding10 topmargin20">{{ match_group.name }}</h1>
<div class="grid-x"> <div class="grid-x">
{% for court in matches_by_court|dictsort:"grouper" %} {% for match in match_group.matches %}
{% for match_data in court.list %} {% include 'tournaments/match_cell.html' %}
{% with match=match_data.match %}
{% include 'tournaments/match_cell.html' %}
{% endwith %}
{% endfor %}
{% endfor %} {% endfor %}
</div> </div>
{% endfor %} {% endfor %}
{% endfor %} {% else %}
<p class="padding10 topmargin20">Aucun match planifié pour cette journée.</p>
{% endif %}
{% else %}
<p class="padding10 topmargin20">Aucun match planifié pour ce tournoi.</p>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}

@ -78,6 +78,19 @@
</div> </div>
</div> </div>
{% if stats.time_played %}
<div class="match-result top-border">
<div class="player">
<div class="semibold">
<strong>Heures jouées</strong>
</div>
</div>
<div class="scores">
<span class="score ws numbers">{{ stats.time_played }}</span>
</div>
</div>
{% endif %}
{% if stats.victory_ratio %} {% if stats.victory_ratio %}
<div class="match-result top-border"> <div class="match-result top-border">
<div class="player"> <div class="player">

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save