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.
#.idea/
# Media files
media/
*/media/

@ -1,6 +1,6 @@
from rest_framework import serializers
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.conf import settings
@ -238,3 +238,17 @@ class UnregisteredPlayerSerializer(serializers.ModelSerializer):
model = UnregisteredPlayer
fields = '__all__'
# ['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'clubs', views.ClubViewSet)
router.register(r'tournaments', views.TournamentViewSet)
router.register(r'images', views.ImageViewSet)
router.register(r'events', views.EventViewSet)
router.register(r'rounds', views.RoundViewSet)
router.register(r'group-stages', views.GroupStageViewSet)
@ -50,5 +51,7 @@ urlpatterns = [
# forgotten password
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 tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer
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, Image
from rest_framework import viewsets
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
import os
from django.http import HttpResponse
import logging
logger = logging.getLogger(__name__)
@api_view(['GET'])
def user_by_token(request):
@ -303,6 +306,34 @@ class ShortUserViewSet(viewsets.ModelViewSet):
def get_queryset(self):
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'])
@permission_classes([IsAuthenticated])
def process_refund(request, team_registration_id):
@ -317,7 +348,7 @@ def process_refund(request, team_registration_id):
payment_service = PaymentService(request)
players_serializer = PlayerRegistrationSerializer(team_registration.players_sorted_by_rank, many=True)
success, message, refund = payment_service.process_refund(team_registration_id)
success, message, refund = payment_service.process_refund(team_registration_id, force_refund=True)
return Response({
'success': success,
'message': message,
@ -329,46 +360,6 @@ def process_refund(request, team_registration_id):
'message': str(e)
}, status=400)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def validate_stripe_account(request):
stripe.api_key = settings.STRIPE_SECRET_KEY
# Parse the request body
data = json.loads(request.body)
account_id = data.get('account_id')
if not account_id:
return Response({
'valid': False,
'error': 'Account ID is required'
}, status=400)
# Try to retrieve the account from Stripe
try:
# Basic account verification
account = stripe.Account.retrieve(account_id)
# Only check if the account can receive payments
is_valid = account.id is not None
return Response({
'valid': is_valid,
'account': {
'id': account.id
}
})
except stripe.error.PermissionError:
return Response({
'valid': False,
'error': 'No permission to access this account'
}, status=403)
except stripe.error.InvalidRequestError:
return Response({
'valid': False,
'error': 'Invalid account ID'
}, status=400)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def xls_to_csv(request):
@ -381,8 +372,12 @@ def xls_to_csv(request):
file_path = os.path.join(directory, uploaded_file.name)
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'
xls = pd.ExcelFile(file_name)
xls = pd.ExcelFile(full_path)
sheet_names = xls.sheet_names
# Determine which sheet to use
@ -394,13 +389,14 @@ def xls_to_csv(request):
break
# 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')
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
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['Content-Disposition'] = f'attachment; filename="players.csv"'
@ -431,3 +427,144 @@ def get_payment_config(request):
return Response({
'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_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
# 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': {},
'tournaments': { 'exclude': ['Log', 'FailedApiCall', 'DeviceToken'] }
'tournaments': { 'exclude': ['Log', 'FailedApiCall', 'DeviceToken', 'Image'] }
}
SYNC_MODEL_CHILDREN_SHARING = {

@ -15,6 +15,8 @@ Including another URLconf
"""
from django.contrib import admin
from django.urls import include, path
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
@ -27,3 +29,7 @@ urlpatterns = [
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
stripe==11.6.0
django-background-tasks==1.2.8
Pillow==10.2.0

@ -1,11 +1,19 @@
from django.contrib import admin
from django.shortcuts import render
from .models import Product, Color, Size, Order, OrderItem, GuestUser, Coupon, CouponUsage, OrderStatus
from django.shortcuts import render, redirect
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)
class ProductAdmin(admin.ModelAdmin):
list_display = ("title", "ordering_value", "price", "cut")
search_fields = ["title", "description"] # Enable search for autocomplete
@admin.register(Color)
class ColorAdmin(admin.ModelAdmin):
@ -34,14 +42,94 @@ class SizeAdmin(admin.ModelAdmin):
class OrderItemInline(admin.TabularInline):
model = OrderItem
extra = 0
readonly_fields = ('product', 'quantity', 'color', 'size', 'price')
extra = 1 # Show one extra row for adding new items
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)
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]
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):
# If 'show_preparation' parameter is in the request, show the preparation view
@ -103,6 +191,73 @@ class OrderAdmin(admin.ModelAdmin):
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):
model = Order
extra = 0

@ -1,5 +1,6 @@
from django import forms
from .models import Coupon
from .models import ShippingAddress
class GuestCheckoutForm(forms.Form):
email = forms.EmailField(required=True)
@ -7,3 +8,15 @@ class GuestCheckoutForm(forms.Form):
class CouponApplyForm(forms.Form):
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': 'Corail / Noir', 'hex': '#FF7F50', 'secondary_hex': '#000000', 'ordering': 40},
{'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': 'Noir', 'hex': '#000000', 'secondary_hex': None, 'ordering': 60},
{'name': 'Noir / Corail', 'hex': '#000000', 'secondary_hex': '#FF7F50', 'ordering': 61},
{'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 = {}
@ -137,6 +140,28 @@ class Command(BaseCommand):
'sizes': ['S', 'M', 'L', 'XL', 'XXL', '3XL'],
'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:

@ -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'
DELIVERED = 'DELIVERED', 'Delivered'
CANCELED = 'CANCELED', 'Canceled'
REFUNDED = 'REFUNDED', 'Refunded'
PREPARED = 'PREPARED', 'Prepared'
READY = 'READY', 'Ready'
class CutChoices(models.IntegerChoices):
UNISEX = 0, 'Unisex'
@ -71,6 +74,14 @@ class CartItem(models.Model):
def get_total_price(self):
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):
email = models.EmailField()
phone = models.CharField(max_length=20)
@ -112,6 +123,7 @@ class Coupon(models.Model):
class Order(models.Model):
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)
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)
@ -122,6 +134,7 @@ class Order(models.Model):
('UNPAID', 'Unpaid'),
('PAID', 'Paid'),
('FAILED', 'Failed'),
('REFUNDED', 'Refunded')
])
webhook_processed = models.BooleanField(default=False)
stripe_mode = models.CharField(max_length=10, default='test', choices=[
@ -137,6 +150,33 @@ class Order(models.Model):
def get_total_after_discount(self):
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):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items')
product = models.ForeignKey(Product, on_delete=models.CASCADE)

@ -70,11 +70,11 @@ def _get_order_details(instance):
# Translate statuses
status_fr_map = {
"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 = {
"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
@ -106,7 +106,8 @@ def _get_order_details(instance):
'customer_info': customer_info,
'customer_email': customer_email,
'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):
@ -153,6 +154,8 @@ Statut de paiement: {order_details['payment_status_fr']}
{order_details['customer_info']}
{order_details['shipping_address']}
Articles:
{items_list}
@ -195,7 +198,8 @@ def _send_customer_notification(instance, order_details, items_list):
order_details['final_price'],
items_list,
contact_email,
shop_url
shop_url,
order_details['shipping_address']
)
# 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,
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."""
# 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",
'message': _build_payment_confirmation_email(order_id, date, status_fr,
price_info, items_list,
contact_email, shop_url)
contact_email, shop_url, shipping_address)
}
# 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 = {
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.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."
}.get(status, "")
return {
'subject': f"Mise à jour de votre commande #{order_id} - Padel Club",
'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
@ -266,7 +273,7 @@ Montant payé: {final_price}€"""
# No email needed
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."""
return f"""
Bonjour,
@ -282,7 +289,10 @@ Détail de votre commande :
{items_list}
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 :
{contact_email}
@ -295,7 +305,7 @@ Merci de votre confiance et à bientôt sur 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."""
return f"""
Bonjour,
@ -303,6 +313,7 @@ Bonjour,
Mise à jour concernant votre commande Padel Club #{order_id} du {date} :
{status_message}
{shipping_address}
Statut actuel: {status_fr}
{price_info}

@ -546,3 +546,229 @@ v .cart-table {
border: 3px solid #90ee90 !important; /* Use your light-green color */
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"""
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
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 items to prepare: {{ total_items }}</p>
<button onclick="window.print()" style="margin-bottom: 20px">Print This Page</button>
<a href="?" class="button" style="margin-left: 10px">Back to Orders</a>
<div style="margin-bottom: 20px;">
<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>
<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;">
{% 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 %}
</td>
</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;">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;">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;">Actions</th>
</tr>
</thead>
<tbody>
{% for order in orders %}
<tr>
<td style="padding: 8px; border-bottom: 1px solid #ddd;"><a href="../{{ order.id }}/change/">Order #{{ order.id }}</a></td>
<td style="padding: 8px; border-bottom: 1px solid #ddd;"><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;">
{% if order.user %}
@ -71,6 +85,16 @@
Unknown
{% endif %}
</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;">
{% for item in order.items.all %}
{{ item.quantity }}x {{ item.product.title }}
@ -79,10 +103,28 @@
<br>
{% endfor %}
</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>
{% empty %}
<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>
{% endfor %}
</tbody>

@ -5,17 +5,7 @@
{% block second_title %}La Boutique{% endblock %}
{% block content %}
<nav class="margin10">
<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>
{% include 'shop/partials/navigation_base.html' %}
{% 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;">
<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="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">
{% 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>
<p>Cette boutique fonctionne entre amis 'Padel Club'. Les commandes sont :</p>
<ol style="padding-left: 20px; margin-bottom: 0;">
<li>Passées en ligne via notre système</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>
<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>
{% 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 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">
{% if user.is_authenticated %}
@ -124,6 +132,24 @@
const discountAmount = document.getElementById('discount-amount');
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
const originalTotal = parseFloat('{{ display_data.total_price }}');
@ -244,6 +270,9 @@
checkoutButton.textContent = 'Chargement...';
checkoutButton.disabled = true;
// Get shipping data
const shippingData = getShippingData();
// Create order and get checkout session
fetch('{% url "shop:create_checkout_session" %}', {
method: 'POST',
@ -251,6 +280,9 @@
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({
shipping_address: shippingData
}),
credentials: 'same-origin',
})
.then(function(response) {

@ -6,18 +6,9 @@
{% block content %}
<nav class="margin10">
<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>
<h1 class="club padding10 topmargin20">Validation de la commande</h1>
{% include 'shop/partials/navigation_base.html' %}
<h1 class="club padding10">Validation de la commande</h1>
<div class="grid-x">
<div class="small-12 medium-6 large-6 padding10">
<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">
<tbody>
{% for item in items %}
<tr class="{% cycle 'odd-row' 'even-row' %}">
<td class="text-left product-name" data-label="Produit">{{ item.product_title }}</td>
{% if item.product_description %}
<td class="text-left product-description" data-label="Description">{{ item.product_description }}</td>
{% endif %}
<td class="product-color" data-label="Couleur">
<div class="color-display">
<div class="color-sample-cart"
{% if item.secondary_color_hex %}
style="background-image: linear-gradient(to right, {{ item.color_hex }} 50%, {{ item.secondary_color_hex }} 50%);"
{% else %}
style="background-color: {{ item.color_hex }};"
{% endif %}
></div>
{{ item.color_name }} | {{ item.size_name }}
</div>
</td>
<td class="product-quantity" data-label="Quantité">
{% if edit_mode %}
<div class="quantity-controls">
<form method="post" action="{% url 'shop:update_cart_item' %}" class="quantity-form">
{% csrf_token %}
<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>
<span class="quantity-value">{{ item.quantity }}</span>
<button type="submit" name="action" value="increase" class="quantity-btn">+</button>
</form>
</div>
{% else %}
<span>x {{ item.quantity }}</span>
{% endif %}
</td>
<td class="price-column product-price" data-label="Prix">{{ item.total_price }} €</td>
{% if edit_mode %}
<td class="product-actions">
<form method="post" action="{% url 'shop:remove_from_cart' %}" class="remove-form">
{% csrf_token %}
<input type="hidden" name="item_id" value="{{ item.id }}">
<button type="submit" class="remove-btn">retirer</button>
</form>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<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 %}
<td class="total-label text-left"></td>
{% endif %}
</tr>
</tfoot>
<tbody>
{% for item in items %}
<tr class="{% cycle 'odd-row' 'even-row' %}">
<td class="text-left product-name" data-label="Produit">{{ item.product_title }}</td>
{% if item.product_description %}
<td class="text-left product-description" data-label="Description">{{ item.product_description }}</td>
{% endif %}
<td class="product-color" data-label="Couleur">
<div class="color-display">
<div class="color-sample-cart"
{% if item.secondary_color_hex %}
style="background-image: linear-gradient(to right, {{ item.color_hex }} 50%, {{ item.secondary_color_hex }} 50%);"
{% else %}
style="background-color: {{ item.color_hex }};"
{% endif %}
></div>
{{ item.color_name }} | {{ item.size_name }}
</div>
</td>
<td class="product-quantity" data-label="Quantité">
{% if edit_mode %}
<div class="quantity-controls">
<form method="post" action="{% url 'shop:update_cart_item' %}" class="quantity-form">
{% csrf_token %}
<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>
<span class="quantity-value">{{ item.quantity }}</span>
<button type="submit" name="action" value="increase" class="quantity-btn">+</button>
</form>
</div>
{% else %}
<span>x {{ item.quantity }}</span>
{% endif %}
</td>
<td class="price-column product-price" data-label="Prix">{{ item.total_price }} €</td>
{% if edit_mode %}
<td class="product-actions">
<form method="post" action="{% url 'shop:remove_from_cart' %}" class="remove-form">
{% csrf_token %}
<input type="hidden" name="item_id" value="{{ item.id }}">
<button type="submit" class="remove-btn">retirer</button>
</form>
</td>
{% elif cancel_mode and items.count > 1 %}
<td class="product-actions">
<form method="post" action="{% url 'shop:cancel_order_item' order.id item.id %}" class="remove-form"
onsubmit="return confirm('Êtes-vous sûr de vouloir annuler cet article ? {% if order.status == 'PAID' %}Un remboursement sera effectué.{% endif %}');">
{% csrf_token %}
<button type="submit" class="remove-btn">annuler</button>
</form>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<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>

@ -12,21 +12,12 @@
<small>Use test card: 4242 4242 4242 4242 with any future date and any CVC.</small>
</div>
{% endif %}
<nav class="margin10">
<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>
{% include 'shop/partials/navigation_base.html' %}
<div class="grid-x">
<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">
{% 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 %}
<nav class="margin10">
<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>
{% include 'shop/partials/navigation_base.html' %}
<div class="grid-x">
<div class="cell medium-6 large-6 padding10">
<h1 class="club padding10 topmargin20">Paiement</h1>
<h1 class="club padding10">Paiement</h1>
<div class="bubble">
<h2>Le paiement a été annulé</h2>
<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 content %}
<nav class="margin10">
<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>
{% include 'shop/partials/navigation_base.html' %}
<div class="grid-x">
<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">
<h2>Merci pour votre commande !</h2>
<p>Votre paiement a été traité avec succès.</p>

@ -6,22 +6,12 @@
{% block content %}
<nav class="margin10">
<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>
{% include 'shop/partials/navigation_base.html' %}
<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>
<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>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>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. La livraison peut être possible !</p>
</div>
<nav class="margin10">

@ -24,5 +24,9 @@ urlpatterns = [
path('debug/simulate-payment-failure/', views.simulate_payment_failure, name='simulate_payment_failure'),
path('apply-coupon/', views.apply_coupon, name='apply_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.views.decorators.csrf import csrf_exempt
from django.utils import timezone
from .forms import ShippingAddressForm
import json # Add this with your other imports
from . import cart
@ -143,16 +145,20 @@ def view_cart(request):
total = cart.get_cart_total(request)
total_quantity = cart_items.aggregate(total_quantity=Sum('quantity'))['total_quantity']
display_data = prepare_item_display_data(cart_items, is_cart=True)
context = {
'display_data': display_data,
'total': total,
'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:
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)
@ -457,19 +463,32 @@ def create_checkout_session(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'User must be authenticated'}, status=403)
# Create the order
order = create_order(request)
shipping_address = None
# 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:
return JsonResponse({'error': 'Could not create order from cart'}, status=400)
# Get order items
order_items = order.items.all()
# Attach shipping address to order
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)
# Create checkout session
try:
checkout_session = _create_stripe_checkout_session(request, order, line_items)
return JsonResponse({'id': checkout_session.id})
@ -752,3 +771,227 @@ def remove_coupon(request):
if 'coupon_id' in request.session:
del request.session['coupon_id']
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.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 .filters import TeamScoreTournamentListFilter, MatchTournamentListFilter, SimpleTournamentListFilter, MatchTypeListFilter, SimpleIndexListFilter, StartDateRangeFilter
@ -45,13 +45,42 @@ class CustomUserAdmin(UserAdmin):
super().save_model(request, obj, form, change)
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']
raw_id_fields = ['creator']
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):
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']
ordering = ['-start_date']
search_fields = ['id']
@ -157,6 +186,39 @@ class UnregisteredPlayerAdmin(admin.ModelAdmin):
list_filter = []
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 = {
ADDITION: 'Addition',
@ -220,3 +282,4 @@ admin.site.register(DeviceToken, DeviceTokenAdmin)
admin.site.register(DrawLog, DrawLogAdmin)
admin.site.register(UnregisteredTeam, UnregisteredTeamAdmin)
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 .court import Court
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 .event import Event
from .tournament import Tournament, TeamSummon, TeamSortingType, TeamItem
@ -22,3 +22,4 @@ from .device_token import DeviceToken
from .draw_log import DrawLog
from .unregistered_team import UnregisteredTeam
from .unregistered_player import UnregisteredPlayer
from .image import Image

@ -9,7 +9,7 @@ class Club(BaseModel):
name = models.CharField(max_length=50)
acronym = models.CharField(max_length=50)
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)
address = models.CharField(max_length=200, null=True, blank=True)
@ -42,7 +42,7 @@ class Club(BaseModel):
return court.name
if index is not None:
return f"Terrain {index + 1}"
return f"Piste {index + 1}"
return ""

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

@ -303,3 +303,9 @@ class RegistrationPaymentMode(models.IntegerChoices):
CORPORATE = 1, 'Corporate'
NO_FEE = 2, 'No Service Fee'
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)
format = models.IntegerField(default=FederalMatchCategory.NINE_GAMES, choices=FederalMatchCategory.choices, 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)
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')
name = models.CharField(max_length=200, 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)
index = models.IntegerField(default=0)
#order = models.IntegerField(default=0)
@ -66,7 +67,7 @@ class Match(TournamentSubModel):
if club:
return club.court_name(index)
elif index is not None:
return f"Terrain {index + 1}"
return f"Piste {index + 1}"
return ""
def backup_name(self):
@ -76,7 +77,7 @@ class Match(TournamentSubModel):
if self.round.index > 0:
items.append(f" #{self.index_in_round() + 1}")
elif self.group_stage:
items.append(self.group_stage.name())
items.append(self.group_stage.display_name())
items.append(f"Match #{self.index + 1}")
return " ".join(items)
@ -182,7 +183,7 @@ class Match(TournamentSubModel):
def is_ready(self):
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')
##return map(lambda ts: ts.player_names(), self.team_scores.all())
# List to hold the names of the teams
@ -195,7 +196,7 @@ class Match(TournamentSubModel):
loser_top_match = self.loser_precedent_match(True)
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):
names = ["Qualifié"]
@ -245,7 +246,7 @@ class Match(TournamentSubModel):
teams.append(team)
elif len(team_scores) == 1:
# 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):
teams.append(existing_team)
names = ["Équipe de poule"]
@ -285,19 +286,19 @@ class Match(TournamentSubModel):
elif len(team_scores) == 2:
# 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:
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
if pos1 is not None and pos2 is not None and pos1 // 2 == self.index and pos2 // 2 == self.index:
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:
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:
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
@ -305,6 +306,10 @@ class Match(TournamentSubModel):
timezone = self.get_tournament().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):
if self.start_date:
local_start = self.local_start_date()
@ -339,13 +344,25 @@ class Match(TournamentSubModel):
return 'À venir...'
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()
average_duration = self.average_seconds_duration()
if (average_duration / 2) > seconds or seconds > (average_duration * 2):
seconds = average_duration
return format_seconds(seconds)
return seconds
def average_seconds_duration(self):
return 3 * 60 * self.total_number_of_games()
@ -418,7 +435,19 @@ class Match(TournamentSubModel):
# _minutes = int((_seconds % 3600) / 60)
# 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()
date = self.formatted_start_date()
time_indication = self.time_indication()
@ -433,9 +462,13 @@ class Match(TournamentSubModel):
ended = self.end_date is not None
live_format = "Format " + FederalMatchCategory(self.format).format_label_short
livematch = LiveMatch(self.index, title, date, time_indication, court, self.started(), ended, group_stage_name, live_format, self.start_date, self.court_index, self.disabled, bracket_name, 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)
return livematch
@ -500,7 +533,7 @@ class Team:
}
class LiveMatch:
def __init__(self, index, title, date, time_indication, court, started, ended, group_stage_name, format, start_date, court_index, disabled, bracket_name, 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.title = title
self.date = date
@ -517,6 +550,7 @@ class LiveMatch:
self.court_index = court_index
self.bracket_name = bracket_name
self.should_show_lucky_loser_status = should_show_lucky_loser_status
self.tournament_title = tournament_title
def add_team(self, team):
self.teams.append(team)
@ -540,6 +574,7 @@ class LiveMatch:
"court_index": self.court_index,
"bracket_name": self.bracket_name,
"should_show_lucky_loser_status": self.should_show_lucky_loser_status,
"tournament_title": self.tournament_title,
}
def show_time_indication(self):

@ -27,6 +27,8 @@ class PlayerRegistration(TournamentSubModel):
club_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)
club_code = models.CharField(max_length=20, null=True, blank=True)
club_member = models.BooleanField(default=False)
#beachpadel
phone_number = models.CharField(max_length=50, null=True, blank=True)
@ -70,11 +72,11 @@ class PlayerRegistration(TournamentSubModel):
def name(self):
return f"{self.first_name} {self.last_name}"
def shortened_name(self):
def shortened_name(self, forced=False):
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}"
if len(name) > 20:
if len(name) > 20 or forced:
name_parts = self.last_name.split(" ")
name = f"{self.first_name[0]}. {name_parts[0]}"
return name
@ -119,6 +121,16 @@ class PlayerRegistration(TournamentSubModel):
tournament = self.team_registration.tournament
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 = {
'header': "Équipes",
'position': tournament_status_team_count,
@ -173,3 +185,15 @@ class PlayerRegistration(TournamentSubModel):
def has_paid(self):
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')
format = models.IntegerField(default=FederalMatchCategory.NINE_GAMES, choices=FederalMatchCategory.choices, 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)
loser_bracket_mode = models.IntegerField(default=0)
@ -171,7 +172,8 @@ class Round(TournamentSubModel):
name=name,
matches=first_half_matches,
round_id=self.id,
round_index=self.index
round_index=self.index,
short_names=double_butterfly_mode
)
return match_group

@ -5,6 +5,7 @@ from . import TournamentSubModel, Tournament, GroupStage, Match, CustomUser
from .enums import RegistrationStatus
from .player_enums import PlayerPaymentType
from ..services.email_service import TournamentEmailService, TeamEmailType
from ..utils.extensions import format_seconds
import uuid
@ -34,6 +35,7 @@ class TeamRegistration(TournamentSubModel):
final_ranking = models.IntegerField(null=True, blank=True)
points_earned = models.IntegerField(null=True, blank=True)
unique_random_index = models.IntegerField(default=0)
def delete_dependencies(self):
for player_registration in self.player_registrations.all():
@ -67,7 +69,7 @@ class TeamRegistration(TournamentSubModel):
else:
return self.player_names_as_list()
def shortened_team_names(self):
def shortened_team_names(self, forced=False):
if self.name:
return [self.name] #add an empty line if it's a team name
else:
@ -80,9 +82,9 @@ class TeamRegistration(TournamentSubModel):
else:
return ['Place réservée']
elif len(players) == 1:
return [players[0].shortened_name()]
return [players[0].shortened_name(forced=forced)]
else:
return [pr.shortened_name() for pr in players]
return [pr.shortened_name(forced=forced) for pr in players]
@property
def players_sorted_by_rank(self):
@ -181,13 +183,13 @@ class TeamRegistration(TournamentSubModel):
matches = self.get_matches()
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]
def get_completed_matches(self):
matches = self.get_matches()
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]
def get_statistics(self):
@ -199,6 +201,7 @@ class TeamRegistration(TournamentSubModel):
'initial_stage': self.get_initial_stage(),
'matches_played': self.count_matches_played(),
'victory_ratio': self.calculate_victory_ratio(),
'time_played': self.calculate_time_played(),
'team_rank': self.team_rank_label(),
'total_teams': self.total_teams(),
}
@ -281,6 +284,13 @@ class TeamRegistration(TournamentSubModel):
def count_matches_played(self):
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):
matches = self.get_matches().filter(end_date__isnull=False)
total_matches = matches.count()
@ -396,14 +406,11 @@ class TeamRegistration(TournamentSubModel):
return status == 'PAID'
def get_remaining_fee(self):
"""Get the remaining fee for this team"""
status = self.get_payment_status()
if status == 'PAID':
return 0
elif status == 'UNPAID':
return self.tournament.team_fee()
elif status == 'MIXED':
return self.tournament.player_fee()
# Get all player registrations for this team
player_registrations = self.players_sorted_by_rank
# Check payment status for each player
payment_statuses = [player.get_remaining_fee() for player in player_registrations]
return sum(payment_statuses)
def is_confirmation_expired(self):
"""
@ -495,3 +502,12 @@ class TeamRegistration(TournamentSubModel):
tournament,
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:
return "--"
def shortened_team_names(self):
def shortened_team_names(self, forced=False):
names = []
if self.team_registration:
names = self.team_registration.shortened_team_names()
names = self.team_registration.shortened_team_names(forced=forced)
return names
def team_names(self):
@ -117,7 +117,7 @@ class TeamScore(TournamentSubModel):
scores = self.scores()
return sum(scores)
def live_team(self, match):
def live_team(self, match, short_names=False):
if self.team_registration:
id = self.team_registration.id
image = self.team_registration.logo
@ -131,7 +131,7 @@ class TeamScore(TournamentSubModel):
image = None
weight= None
is_winner = False
names = self.shortened_team_names()
names = self.shortened_team_names(forced=short_names)
scores = self.parsed_scores()
walk_out = self.walk_out
is_lucky_loser = self.lucky_loser is not None

@ -1,6 +1,6 @@
from zoneinfo import ZoneInfo
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
from django.utils import timezone, formats
@ -89,6 +89,11 @@ class Tournament(BaseModel):
enable_time_to_confirm = models.BooleanField(default=False)
is_corporate_tournament = 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):
for team_registration in self.team_registrations.all():
@ -159,6 +164,13 @@ class Tournament(BaseModel):
str = f"{str} {age}"
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):
category = self.category()
if len(category) > 0 and self.federal_level_category > 1:
@ -218,6 +230,12 @@ class Tournament(BaseModel):
def in_progress(self):
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):
if self.event and self.event.creator:
return self.event.creator.username
@ -422,20 +440,22 @@ class Tournament(BaseModel):
t.registration_date is None,
t.registration_date or datetime.min,
t.initial_weight,
t.team_registration.unique_random_index,
t.team_registration.id
))
waiting_teams.sort(key=lambda t: (
t.registration_date is None,
t.registration_date or datetime.min,
t.initial_weight,
t.team_registration.unique_random_index,
t.team_registration.id
))
else:
complete_teams.sort(key=lambda t: (t.initial_weight, t.team_registration.id))
waiting_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.unique_random_index, t.team_registration.id))
wildcard_group_stage.sort(key=lambda t: (t.initial_weight, t.team_registration.id))
wildcard_bracket.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.unique_random_index, t.team_registration.id))
# Split teams into main bracket and waiting list
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]
excess_teams = complete_teams[computed_team_count:]
qualified_teams.sort(key=lambda t: (t.initial_weight, t.team_registration.id))
excess_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.unique_random_index, t.team_registration.id))
# Combine all waiting list teams
waiting_list = excess_teams + waiting_teams
@ -454,16 +474,17 @@ class Tournament(BaseModel):
t.registration_date is None,
t.registration_date or datetime.min,
t.initial_weight,
t.team_registration.unique_random_index,
t.team_registration.id
))
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
bracket_teams = qualified_teams[:bracket_seeds] + wildcard_bracket
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))
gs_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.unique_random_index, t.team_registration.id))
all_teams = bracket_teams + gs_teams
for team in bracket_teams:
team.set_stage("Tableau")
@ -561,9 +582,9 @@ class Tournament(BaseModel):
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)
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
valid_matches = [match for match in matches if match.start_date is not None]
@ -689,7 +710,7 @@ class Tournament(BaseModel):
else:
current_round = self.round_to_show()
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()
if all_upper_matches_are_over is False:
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]
matches.extend(previous_previous_matches)
else:
print('group_stages')
#print('group_stages')
group_stages = [gs.live_group_stages() for gs in self.last_group_stage_step()]
else:
first_round = self.first_round()
@ -837,6 +858,7 @@ class Tournament(BaseModel):
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.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.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()
if self.has_started():
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
def has_team_registrations(self):
@ -896,6 +931,8 @@ class Tournament(BaseModel):
return self.has_summons()
if self.has_started():
return self.has_summons()
if self.will_start_soon(1):
return True
return False
def display_group_stages(self):
@ -973,8 +1010,8 @@ class Tournament(BaseModel):
timezoned_datetime -= timedelta(hours=hour_delta)
return now >= timezoned_datetime
def will_start_soon(self):
return self.has_started(hour_delta=2)
def will_start_soon(self, hour_delta=2):
return self.has_started(hour_delta=hour_delta)
def supposedly_in_progress(self):
# end = self.start_date + timedelta(days=self.day_duration + 1)
@ -1070,6 +1107,22 @@ class Tournament(BaseModel):
return False
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):
options = []
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')
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)
# Cible d'équipes
@ -1109,11 +1165,11 @@ class Tournament(BaseModel):
if self.enable_online_payment_refund and self.refund_date_limit:
date = formats.date_format(self.refund_date_limit.astimezone(timezone), format='j F Y H:i')
options.append(f"Remboursement possible jusqu'au {date}")
options.append(f"Remboursement en ligne possible jusqu'au {date}")
elif self.enable_online_payment_refund:
options.append("Remboursement possible")
options.append("Remboursement en ligne possible")
else:
options.append("Remboursement impossible")
options.append("Remboursement en ligne impossible")
# Joueurs par équipe
min_players = self.minimum_player_per_team
@ -1213,6 +1269,9 @@ class Tournament(BaseModel):
if self.supposedly_in_progress():
return False
if self.will_start_soon(self.unregister_delta_in_hours):
return False
if self.closed_registration_date is not None:
return False
@ -1818,19 +1877,6 @@ class Tournament(BaseModel):
else:
return False
def player_fee(self):
if self.entry_fee is not None and self.entry_fee > 0 and self.enable_online_payment:
return self.entry_fee
else:
return 0
def team_fee(self):
entry_fee = self.entry_fee
if entry_fee is not None and entry_fee > 0 and self.enable_online_payment:
return self.entry_fee * self.minimum_player_per_team
else:
return 0
def is_free(self):
if self.entry_fee is not None and self.entry_fee == 0:
return True
@ -1891,7 +1937,176 @@ class Tournament(BaseModel):
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:
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
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
def _build_payment_info(tournament, team_registration):
"""
@ -656,7 +699,7 @@ class TournamentEmailService:
payment_amount = payment['amount'] / 100
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)
tournament_word = federal_level_category.localized_word()
@ -731,7 +774,7 @@ class TournamentEmailService:
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)
tournament_word = federal_level_category.localized_word()

@ -278,7 +278,7 @@ class PaymentService:
team_registration.confirm_registration(payment_intent_id)
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
Returns a tuple (success, message)
@ -291,7 +291,7 @@ class PaymentService:
tournament = team_registration.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
# Get payment ID from player registrations

@ -8,6 +8,7 @@ from ..models.enums import FederalCategory, RegistrationStatus
from ..models.player_enums import PlayerSexType, PlayerDataSource
from django.contrib.auth import get_user_model
from django.conf import settings
from django.utils.dateparse import parse_datetime
class RegistrationCartManager:
"""
@ -110,7 +111,6 @@ class RegistrationCartManager:
if expiry_str:
try:
# Parse the ISO format string to datetime
from django.utils.dateparse import parse_datetime
expiry_datetime = parse_datetime(expiry_str)
except (ValueError, TypeError):
# If parsing fails, set a new expiry
@ -123,6 +123,7 @@ class RegistrationCartManager:
'players': self.session.get('registration_cart_players', []),
'expiry': expiry_datetime, # Now a datetime object, not a string
'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)
}
@ -132,8 +133,30 @@ class RegistrationCartManager:
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):
"""Add a player to the registration cart"""
print("add_player", player_data)
if self.is_cart_expired():
return False, "Votre session d'inscription a expiré, veuillez réessayer."
@ -168,21 +191,30 @@ class RegistrationCartManager:
return result # Return the error
tournament_federal_category = tournament.federal_category
if tournament_federal_category == FederalCategory.MIXED and len(players) == 1:
other_player_is_woman = players[0].get('is_woman', False)
if other_player_is_woman is False:
tournament_federal_category = FederalCategory.WOMEN
if licence_id:
# Get federation data
fed_data, found = get_player_name_from_csv(tournament_federal_category, licence_id)
if found is False and fed_data:
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({
'rank': fed_data['rank'],
'is_woman': fed_data['is_woman'],
'is_woman': is_woman,
})
if found and fed_data:
# 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)
if player_register_check:
return False, ", ".join(player_register_check)
@ -198,11 +230,14 @@ class RegistrationCartManager:
'tournament_count': fed_data.get('tournament_count'),
'ligue_name': fed_data.get('ligue_name'),
'club_name': fed_data.get('club_name'),
'club_code': fed_data.get('club_code'),
'birth_year': fed_data.get('birth_year'),
'found_in_french_federation': True,
'club_member': tournament.is_cart_player_from_club(fed_data)
})
elif not first_name or not last_name:
# 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
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:
@ -379,6 +414,33 @@ class RegistrationCartManager:
except User.DoesNotExist:
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
PlayerRegistration.objects.create(
team_registration=team_registration,
@ -388,13 +450,15 @@ class RegistrationCartManager:
registered_online=True,
first_name=player_data.get('first_name'),
last_name=player_data.get('last_name'),
points=player_data.get('points'),
points=points,
assimilation=player_data.get('assimilation'),
tournament_played=player_data.get('tournament_count'),
tournament_played=tournament_played,
ligue_name=player_data.get('ligue_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'),
sex=player_data.get('sex'),
sex= sex,
rank=player_data.get('rank'),
computed_rank=player_data.get('computed_rank'),
licence_id=player_data.get('licence_id'),

@ -1,9 +1,8 @@
from django.contrib import messages
from django.utils import timezone
from ..models import PlayerRegistration, UnregisteredTeam, UnregisteredPlayer, PlayerPaymentType
from ..utils.licence_validator import LicenseValidator
from ..services.payment_service import PaymentService
from ..services.email_service import TournamentEmailService
from ..services.email_service import TournamentEmailService, TeamEmailType
class TournamentUnregistrationService:
def __init__(self, request, tournament):
@ -96,6 +95,8 @@ class TournamentUnregistrationService:
registered_online=player.registered_online
)
TournamentEmailService.notify_umpire(team_registration, team_registration.tournament, TeamEmailType.UNREGISTERED)
def _find_player_registration(self):
# First check if we can find the player registration directly by user
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;
}
.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 {
font-family: "Montserrat-Bold";
}
@ -79,3 +124,77 @@ body {
.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 {
padding: 0px 10px;
padding: 0px 0px;
font-size: 1.5em;
}
@ -112,7 +112,7 @@ hr {
@media print, screen and (min-width: 40em) {
.wrapper {
margin: 0px 40px;
margin: 0px 20px;
}
}
@ -202,6 +202,11 @@ tr {
font-weight: 600;
}
.mybox.active {
background-color: #f39200;
color: #ffffff;
}
@media (max-width: 80em) {
font-size: 0.8em;
padding: 0px;
@ -297,8 +302,6 @@ tr {
.logo {
height: 80px;
margin: 20px 0px;
/* padding: 5px 10px; */
}
.padding-bottom-small {
@ -927,7 +930,7 @@ h-margin {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 40px;
padding: 20px;
}
.left-content {
@ -936,7 +939,8 @@ h-margin {
}
.right-content {
margin-left: auto;
display: flex;
align-items: center;
}
/* CRM form */
@ -1074,13 +1078,13 @@ h-margin {
.match-status-container-header {
margin-top: -20px;
height: 40px;
height: 50px;
text-align: left;
}
.match-status-container-header-bottom {
margin-bottom: -20px;
height: 40px;
height: 50px;
text-align: left;
}

@ -36,6 +36,14 @@
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 {
position: absolute;
top: 0px; /* Adjust this value to position the title where you want it */
@ -54,7 +62,7 @@
}
.round-title.broadcast-mode {
font-size: 0.9em;
font-size: 0.8em;
width: auto; /* Change from 100% to auto */
}
@ -208,12 +216,13 @@
.broadcast-mode .round-name,
.broadcast-mode .round-format {
padding: 0px;
color: #505050;
color: white;
}
.broadcast-mode .round-title {
padding: 8px 20px; /* Slightly more horizontal padding */
background-color: white;
background-color: #1a223a;
color: white !important;
align-content: center;
border-radius: 24px;
}
@ -227,8 +236,12 @@
background-color: #505050 !important; /* Bright yellow - change to your preferred color */
}
/* Broadcast mode styling for all lines */
.broadcast-mode .butterfly-match::before,
.broadcast-mode
.butterfly-round:first-child
.butterfly-match.butterfly-match:has(.incoming-line.disabled)::before {
visibility: hidden;
}
.broadcast-mode .butterfly-match.reverse-bracket::before,
.broadcast-mode .incoming-line,
.broadcast-mode .outgoing-line,
@ -252,6 +265,98 @@
border-radius: 0 0 24px 24px; /* Match the bubble's bottom corners */
}
.logo {
max-width: 80px;
}
.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;
let roundTotalCount = roundCount;
if (doubleButterflyMode == true && roundCount > 1) {
let initialPadding = 40;
if (doubleButterflyMode == true && roundCount > 4) {
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;
responsiveMatchWidth = Math.min(
let responsiveMatchWidth = Math.min(
365,
Math.max(365, Math.floor(availableWidth / roundTotalCount)),
);
let topMargin = 0;
if (isBroadcast) {
responsiveMatchWidth = Math.min(
365,
Math.floor(availableWidth / roundTotalCount),
);
responsiveMatchWidth = Math.floor(availableWidth / roundTotalCount);
let screenHeight = window.innerHeight;
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) => {
@ -61,7 +90,14 @@ function renderBracket(options) {
const roundDiv = document.createElement("div");
roundDiv.className = "butterfly-round";
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
const titleDiv = document.createElement("div");
titleDiv.className = "round-title";
@ -105,14 +141,6 @@ function renderBracket(options) {
// Create matches container
const matchesContainer = document.createElement("div");
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);
if (matchPositions[roundIndex] == undefined) {
matchPositions[roundIndex] = {};
@ -322,7 +350,18 @@ function renderBracket(options) {
"--next-match-distance",
`${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;
if (matchIndex === 0) {
@ -339,19 +378,27 @@ function renderBracket(options) {
// }
// 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 (
(roundIndex == finalRoundIndex && realRoundIndex == 0) ||
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";
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) {
titleDiv.style.marginLeft = "60px";
titleDiv.style.marginLeft = `${offset}px`;
} else if (roundIndex == finalRoundIndex + 1) {
titleDiv.style.marginLeft = "-60px";
titleDiv.style.marginLeft = `-${offset}px`;
}
}
matchesContainer.appendChild(titleDiv);
@ -375,11 +422,16 @@ function renderBracket(options) {
titleDiv.className = "round-title";
titleDiv.appendChild(nameSpan);
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";
matchesContainer.appendChild(titleDiv);
}
if (roundIndex == 0 && roundCount > 3) {
isIncomingLineIsDisabled = true;
} else if (roundIndex == 0 && roundCount < 4) {
isIncomingLineIsDisabled = false;
}
matchDiv.innerHTML = `
<div class="incoming-line ${isIncomingLineIsDisabled ? "disabled" : ""}"></div>
<div class="match-content ${isDisabled ? "disabled" : ""}">${matchTemplate.innerHTML}</div>

@ -10,10 +10,13 @@
<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>
<a href="{% url 'shop:product_list' %}">La boutique</a>
<a href="{% url 'custom_logout' %}" class="red">Se déconnecter</a>
{% else %}
<a href="{% url 'login' %}">Se connecter</a>
<a href="{% url 'shop:product_list' %}">La boutique</a>
{% endif %}
</nav>
@ -45,24 +48,26 @@
</div>
{% endif %}
<div class="cell medium-6 large-6 topblock padding10">
<div class="bubble">
<label class="title">Mes informations</label>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="rounded-button">Sauver les changements</button>
</form>
<div class="grid-x">
<div class="cell medium-6 large-6 topblock padding10">
<div class="bubble">
<label class="title">Mes informations</label>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="rounded-button">Sauver les changements</button>
</form>
</div>
</div>
</div>
<div class="cell medium-6 large-6 topblock padding10">
<div class="bubble">
<label class="title">Mot de passe</label>
<form method="post" action="{% url 'custom_password_change' %}">
{% csrf_token %}
{{ password_change_form.as_p }}
<button type="submit" class="rounded-button">Modifier le mot de passe</button>
</form>
<div class="cell medium-6 large-6 topblock padding10">
<div class="bubble">
<label class="title">Mot de passe</label>
<form method="post" action="{% url 'custom_password_change' %}">
{% csrf_token %}
{{ password_change_form.as_p }}
<button type="submit" class="rounded-button">Modifier le mot de passe</button>
</form>
</div>
</div>
</div>
{% endblock %}

@ -102,7 +102,10 @@
{{ player.first_name }} {{ player.last_name }}{% if player.licence_id %} ({{ player.licence_id }}){% endif %}
</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>
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 :
</div>
<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>
{% endif %}
{% 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 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 %}
<div class="grid-x">
@ -17,6 +30,7 @@
<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-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>

@ -34,7 +34,7 @@
<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">
<div class="left-margin">
<h1 class="club">{% block first_title %}Page Title{% endblock %}</h1>
@ -58,4 +58,7 @@
</div>
</body>
<footer class="footer-broadcast">
{% block sponsors %}{% endblock %}
</footer>
</html>

@ -32,6 +32,7 @@
<div class="table-cell">
<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-planning' tournament.id %}">(beta) Planning</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-group-stages' tournament.id %}">Poules</a></span> |

@ -39,6 +39,7 @@
paginatedRankings: null,
active: 1,
hide_weight: {{ tournament.hide_weight|lower }},
has_sponsors: {{ tournament.has_sponsors|lower }},
prefixTitle: '',
retrieveData() {
fetch('/tournament/{{ tournament.id }}/broadcast/json/')
@ -58,6 +59,17 @@
},
paginateSummons(array) {
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)
const splitGroups = []
@ -74,6 +86,17 @@
},
paginateRankings(array) {
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)
const splitGroups = []
@ -121,7 +144,7 @@
}" x-init="loop()">
<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">
<div class="left-margin">
<h1 class="club">{{ tournament.broadcast_event_display_name }}</h1>
@ -177,4 +200,17 @@
</div>
</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>

@ -38,6 +38,7 @@
paginatedGroupStages: null,
paginatedSummons: null,
paginatedRankings: null,
has_sponsors: {{ tournament.has_sponsors|lower }},
active: 1,
hide_weight: {{ tournament.hide_weight|lower }},
prefixTitle: '',
@ -84,7 +85,18 @@
},
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);
const splitGroups = [];
@ -101,7 +113,17 @@
},
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);
const splitGroups = [];
@ -202,7 +224,7 @@
<template x-for="i in paginatedSummons.length">
<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' %}
</div>
</template>
@ -210,7 +232,7 @@
<template x-for="i in paginatedMatches.length" >
<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' %}
</div>
</template>
@ -218,7 +240,7 @@
<template x-for="i in paginatedGroupStages.length">
<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' %}
</div>
</template>

@ -4,6 +4,7 @@
<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' %}" />
@ -32,19 +33,29 @@
})();
</script>
<!-- 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>
<body>
<header>
<div id="header">
<div class="left-content bubble">
<img src="{% static 'tournaments/images/PadelClub_logo_512.png' %}" alt="logo" class="logo">
<div id="screen-size-overlay">
<div class="left-content bubble-header screen-size-overlay">
<img src="{% static 'tournaments/images/PadelClub_logo_512.png' %}" alt="logo" class="logo" style="object-fit: contain;">
<div class="left-margin">
<h1 class="club">{{ tournament.broadcast_event_display_name }}</h1>
<h1 class="event">Tableau {{ tournament.broadcast_display_name }}</h1>
</div>
</div>
<div class="right-content">{% qr_from_text qr_code_url options=qr_code_options %}</div>
</div>
</header>
@ -157,4 +168,16 @@
setInterval(fetchAndRenderBracket, 15000);
</script>
</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>

@ -39,7 +39,7 @@
</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="ws numbers"><span x-text="group_stage.teams[i-1].diff"></span></div>
</div>

@ -65,7 +65,7 @@
<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">
<div class="left-margin">
<h1 class="club">{{ tournament.broadcast_event_display_name }}</h1>
@ -101,4 +101,16 @@
</div>
</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>

@ -64,7 +64,7 @@
<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">
<div class="left-margin">
<h1 class="club">{{ tournament.broadcast_event_display_name }}</h1>
@ -97,4 +97,17 @@
</main>
</div>
</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>

@ -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>
<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">
<div class="left-margin">
<h1 class="club">{{ tournament.broadcast_event_display_name }}</h1>
@ -97,4 +97,16 @@
</main>
</div>
</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>

@ -3,7 +3,18 @@
{% block head_title %}Classement{% endblock %}
{% block first_title %}{{ tournament.broadcast_event_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 %}
{% load static %}
@ -11,12 +22,24 @@
<div x-data="{
paginatedRankings: null,
active: 1,
has_sponsors: {{ tournament.has_sponsors|lower }},
retrieveRankings() {
fetch('/tournament/{{ tournament.id }}/rankings/json/')
.then(res => res.json())
.then((data) => {
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)
const splitGroups = [];

@ -3,7 +3,18 @@
{% block head_title %}Convocations{% endblock %}
{% block first_title %}{{ tournament.broadcast_event_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 %}
{% load static %}
@ -11,12 +22,24 @@
<div x-data="{
paginatedMatches: null,
active: 1,
has_sponsors: {{ tournament.has_sponsors|lower }},
retrieveMatches() {
fetch('/tournament/{{ tournament.id }}/summons/json/')
.then(res => res.json())
.then((data) => {
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)
const splitGroups = [];

@ -1,12 +1,19 @@
{% load static %}
<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">
{% if prog_mode and match.group_stage_name %}
<label class="matchtitle">{{ match.group_stage_name }}</label>
{% else %}
<label class="matchtitle">{{ match.title }}</label>
{% if not match.ended %}
<label class="right-label minor-info bold">{{ match.court }}</label>
{% endif %}
{% if match.court %}
<label class="right-label minor-info">{{ match.court }}</label>
{% endif %}
</div>

@ -4,9 +4,11 @@
<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>
<a href="{% url 'download' %}" class="download-button">Ajouter vos tournois</a>
</nav>

@ -1,7 +1,13 @@
<nav class="margin10">
<a href="{% url 'index' %}" class="topmargin5 orange">Accueil</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 %}
<a href="{% url 'tournament-bracket' tournament.id %}" class="topmargin5 orange">Tableau</a>
{% endif %}

@ -1,40 +1,36 @@
{% 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 second_title %}{{ tournament.display_name }}{% endblock %}
{% block second_title %}Programmation{% endblock %}
{% block content %}
{% if tournament.display_matches %}
{% if tournament.display_prog %}
{% include 'tournaments/navigation_tournament.html' %}
{% if tournament.display_matches or tournament.display_group_stages %}
{% regroup match_groups.matches by start_date|date:"l d F Y" as matches_by_date %}
{% for date in matches_by_date %}
{% regroup date.list by start_date|date:"H:i" as matches_by_hour %}
{% 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 days %}
<nav class="margin10">
{% 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 %}">
{{ day|date:"l d F"|capfirst }}
</a>
{% endfor %}
</nav>
{% if match_groups %}
{% for match_group in match_groups %}
<h1 class="club padding10 topmargin20">{{ match_group.name }}</h1>
<div class="grid-x">
{% for court in matches_by_court|dictsort:"grouper" %}
{% for match_data in court.list %}
{% with match=match_data.match %}
{% include 'tournaments/match_cell.html' %}
{% endwith %}
{% endfor %}
{% for match in match_group.matches %}
{% include 'tournaments/match_cell.html' %}
{% endfor %}
</div>
{% 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 %}
{% endblock %}

@ -78,6 +78,19 @@
</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 %}
<div class="match-result top-border">
<div class="player">

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

Loading…
Cancel
Save