sync
Laurent 8 months ago
commit ebcbcffc05
  1. 12
      padelclub_backend/settings.py
  2. 11
      padelclub_backend/settings_app.py
  3. 4
      padelclub_backend/settings_local.py.dist
  4. 1
      padelclub_backend/urls.py
  5. 1
      requirements.txt
  6. 0
      shop/__init__.py
  7. 37
      shop/admin.py
  8. 8
      shop/apps.py
  9. 76
      shop/cart.py
  10. 10
      shop/context_processors.py
  11. 5
      shop/forms.py
  12. 0
      shop/management/commands/__init__.py
  13. 180
      shop/management/commands/create_initial_shop_data.py
  14. 53
      shop/migrations/0001_initial.py
  15. 39
      shop/migrations/0002_rename_color_productcolor_rename_size_productsize_and_more.py
  16. 39
      shop/migrations/0003_rename_productcolor_color_rename_productsize_size_and_more.py
  17. 24
      shop/migrations/0004_cartitem_color_cartitem_size.py
  18. 23
      shop/migrations/0005_alter_color_name_alter_size_name.py
  19. 22
      shop/migrations/0006_alter_product_options_product_order.py
  20. 18
      shop/migrations/0007_product_cut.py
  21. 22
      shop/migrations/0008_alter_product_options_and_more.py
  22. 38
      shop/migrations/0009_order_orderitem.py
  23. 21
      shop/migrations/0010_guestuser.py
  24. 19
      shop/migrations/0011_order_guest_user.py
  25. 28
      shop/migrations/0012_order_payment_status_and_more.py
  26. 23
      shop/migrations/0013_color_colorhex_alter_color_name.py
  27. 18
      shop/migrations/0014_alter_size_name.py
  28. 18
      shop/migrations/0015_alter_product_image.py
  29. 18
      shop/migrations/0016_order_webhook_processed.py
  30. 18
      shop/migrations/0017_order_stripe_mode.py
  31. 18
      shop/migrations/0018_color_secondary_hex_color.py
  32. 18
      shop/migrations/0019_product_description.py
  33. 18
      shop/migrations/0020_product_sku.py
  34. 23
      shop/migrations/0021_alter_color_name_alter_product_sku.py
  35. 0
      shop/migrations/__init__.py
  36. 113
      shop/models.py
  37. 309
      shop/signals.py
  38. 482
      shop/static/shop/css/shop.css
  39. BIN
      shop/static/shop/images/products/PS_K473_WHITE.png.avif
  40. BIN
      shop/static/shop/images/products/PS_K476_WHITE.png.avif
  41. BIN
      shop/static/shop/images/products/PS_PA1030_WHITE-SPORTYNAVY.png.avif
  42. BIN
      shop/static/shop/images/products/PS_PA1031_WHITE-SPORTYNAVY.png.avif
  43. BIN
      shop/static/shop/images/products/PS_PA4030_SPORTYNAVY-WHITE.png.avif
  44. BIN
      shop/static/shop/images/products/PS_PA4031_WHITE-SPORTYNAVY.png.avif
  45. BIN
      shop/static/shop/images/products/hat.jpg
  46. BIN
      shop/static/shop/images/products/tshirt_h.png
  47. 90
      shop/stripe_utils.py
  48. 133
      shop/templates/shop/cart.html
  49. 55
      shop/templates/shop/checkout.html
  50. 60
      shop/templates/shop/partials/order_items_display.html
  51. 63
      shop/templates/shop/payment.html
  52. 35
      shop/templates/shop/payment_cancel.html
  53. 38
      shop/templates/shop/payment_success.html
  54. 128
      shop/templates/shop/product_item.html
  55. 39
      shop/templates/shop/product_list.html
  56. 3
      shop/tests.py
  57. 26
      shop/urls.py
  58. 584
      shop/views.py
  59. 4
      tournaments/admin.py
  60. 28
      tournaments/custom_views.py
  61. 19
      tournaments/middleware.py
  62. 6
      tournaments/models/group_stage.py
  63. 14
      tournaments/models/team_registration.py
  64. 5
      tournaments/models/team_score.py
  65. 6
      tournaments/models/tournament.py
  66. 18
      tournaments/templates/register_tournament.html
  67. 3
      tournaments/templates/registration/login.html
  68. 4
      tournaments/templates/tournaments/base.html
  69. 2
      tournaments/templates/tournaments/broadcast/broadcasted_group_stage.html
  70. 6
      tournaments/templates/tournaments/group_stage_cell.html
  71. 4
      tournaments/templates/tournaments/match_cell.html
  72. 3
      tournaments/templates/tournaments/team_details.html
  73. 4
      tournaments/templates/tournaments/team_row.html
  74. 10
      tournaments/urls.py

@ -16,7 +16,6 @@ import os
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/
@ -36,6 +35,7 @@ INSTALLED_APPS = [
'authentication',
'sync',
'tournaments',
'shop',
# 'crm',
'django.contrib.admin',
'django.contrib.auth',
@ -61,6 +61,8 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'tournaments.middleware.ReferrerMiddleware', # Add this line
]
ROOT_URLCONF = 'padelclub_backend.urls'
@ -68,7 +70,7 @@ ROOT_URLCONF = 'padelclub_backend.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'DIRS': [os.path.join(BASE_DIR, 'templates')], # Project-level templates
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@ -76,6 +78,8 @@ TEMPLATES = [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'shop.context_processors.stripe_context',
],
},
},
@ -155,6 +159,10 @@ AUTHENTICATION_BACKENDS = [
]
CSRF_COOKIE_SECURE = True # if using HTTPS
if DEBUG: # Development environment
SESSION_COOKIE_SECURE = False
else: # Production environment
SESSION_COOKIE_SECURE = True
from .settings_local import *
from .settings_app import *

@ -48,3 +48,14 @@ SYNC_APPS = {
'sync': {},
'tournaments': { 'exclude': ['Log', 'FailedApiCall', 'DeviceToken'] }
}
STRIPE_CURRENCY = 'eur'
# Add managers who should receive internal emails
SHOP_MANAGERS = [
('Razmig Sarkissian', 'razmig@padelclub.app'),
# ('Shop Admin', 'shop-admin@padelclub.app'),
# ('Laurent Morvillier', 'laurent@padelclub.app'),
# ('Xavier Rousset', 'xavier@padelclub.app'),
]
SHOP_SITE_ROOT_URL = 'https://padelclub.app'
SHOP_SUPPORT_EMAIL = 'shop@padelclub.app'

@ -37,3 +37,7 @@ DATABASES = {
# },
# },
# }
STRIPE_MODE = 'test'
STRIPE_PUBLISHABLE_KEY = ''
STRIPE_SECRET_KEY = ''
STRIPE_WEBHOOK_SECRET = ''

@ -19,6 +19,7 @@ from django.urls import include, path
urlpatterns = [
path("", include("tournaments.urls")),
path('shop/', include('shop.urls')),
# path("crm/", include("crm.urls")),
path('roads/', include("api.urls")),
path('kingdom/', admin.site.urls),

@ -15,3 +15,4 @@ xlrd==2.0.1
openpyxl==3.1.5
django-filter==24.3
cryptography==41.0.7
stripe==11.6.0

@ -0,0 +1,37 @@
from django.contrib import admin
from .models import Product, Color, Size, Order, OrderItem, GuestUser
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ("title", "ordering_value", "price", "cut")
@admin.register(Color)
class ColorAdmin(admin.ModelAdmin):
list_display = ("name",)
@admin.register(Size)
class SizeAdmin(admin.ModelAdmin):
list_display = ("name",)
class OrderItemInline(admin.TabularInline):
model = OrderItem
extra = 0
readonly_fields = ('product', 'quantity', 'color', 'size', 'price')
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ('id', 'date_ordered', 'status', 'total_price')
inlines = [OrderItemInline]
class GuestUserOrderInline(admin.TabularInline):
model = Order
extra = 0
readonly_fields = ('date_ordered', 'total_price')
can_delete = False
show_change_link = True
exclude = ('user',) # Exclude the user field from the inline display
@admin.register(GuestUser)
class GuestUserAdmin(admin.ModelAdmin):
list_display = ('email', 'phone')
inlines = [GuestUserOrderInline]

@ -0,0 +1,8 @@
from django.apps import AppConfig
class ShopConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'shop'
def ready(self):
import shop.signals # Import signals to ensure they're connected

@ -0,0 +1,76 @@
from .models import CartItem, Product, Color, Size
def get_or_create_cart_id(request):
"""Get the cart ID from the session or create a new one"""
if 'cart_id' not in request.session:
request.session['cart_id'] = request.session.session_key or request.session.create()
return request.session['cart_id']
def get_cart_items(request):
"""Get all cart items for the current session"""
cart_id = get_or_create_cart_id(request)
return CartItem.objects.filter(session_id=cart_id)
def add_to_cart(request, product_id, quantity=1, color_id=None, size_id=None):
"""Add a product to the cart or update its quantity"""
product = Product.objects.get(id=product_id)
cart_id = get_or_create_cart_id(request)
color = Color.objects.get(id=color_id) if color_id else None
size = Size.objects.get(id=size_id) if size_id else None
try:
# Try to get existing cart item with the same product, color, and size
cart_item = CartItem.objects.get(
product=product,
session_id=cart_id,
color=color,
size=size
)
cart_item.quantity += quantity
cart_item.save()
except CartItem.DoesNotExist:
# Create new cart item
cart_item = CartItem.objects.create(
product=product,
quantity=quantity,
session_id=cart_id,
color=color,
size=size
)
return cart_item
def remove_from_cart(request, product_id):
"""Remove a product from the cart"""
cart_id = get_or_create_cart_id(request)
CartItem.objects.filter(product_id=product_id, session_id=cart_id).delete()
def update_cart_item(request, product_id, quantity):
"""Update the quantity of a cart item"""
cart_id = get_or_create_cart_id(request)
cart_item = CartItem.objects.get(product_id=product_id, session_id=cart_id)
if quantity > 0:
cart_item.quantity = quantity
cart_item.save()
else:
cart_item.delete()
def get_cart_total(request):
"""Calculate the total price of all items in the cart"""
return sum(item.product.price * item.quantity for item in get_cart_items(request))
def clear_cart(request):
"""Clear the cart"""
cart_id = get_or_create_cart_id(request)
CartItem.objects.filter(session_id=cart_id).delete()
# Add this function to your cart.py file
def get_cart_item(request, item_id):
"""Get a specific cart item by its ID"""
cart_id = get_or_create_cart_id(request)
try:
return CartItem.objects.get(id=item_id, session_id=cart_id)
except CartItem.DoesNotExist:
raise Exception("Cart item not found")

@ -0,0 +1,10 @@
from django.conf import settings
def stripe_context(request):
"""Add Stripe-related context variables to templates"""
stripe_mode = getattr(settings, 'STRIPE_MODE', 'test')
return {
'STRIPE_PUBLISHABLE_KEY': settings.STRIPE_PUBLISHABLE_KEY,
'STRIPE_MODE': stripe_mode,
'STRIPE_IS_TEST_MODE': stripe_mode == 'test',
}

@ -0,0 +1,5 @@
from django import forms
class GuestCheckoutForm(forms.Form):
email = forms.EmailField(required=True)
phone = forms.CharField(max_length=20, required=True, label="Téléphone portable")

@ -0,0 +1,180 @@
from django.core.management.base import BaseCommand
from shop.models import Color, Size, Product
from django.conf import settings
class Command(BaseCommand):
help = 'Creates initial data for the shop'
def handle(self, *args, **kwargs):
# Create colors
self.stdout.write('Creating colors...')
colors = [
{'name': 'Noir', 'hex': '#333333', 'secondary_hex': None},
{'name': 'Noir / Gris Foncé Chiné', 'hex': '#000000', 'secondary_hex': '#4D4D4D'},
{'name': 'Bleu Sport', 'hex': '#112B44', 'secondary_hex': None},
{'name': 'Bleu Sport / Bleu Sport Chiné', 'hex': '#112B44', 'secondary_hex': '#16395A'},
{'name': 'Bleu Sport / Blanc', 'hex': '#112B44', 'secondary_hex': '#FFFFFF'},
{'name': 'Blanc / Gris Clair', 'hex': '#FFFFFF', 'secondary_hex': '#D3D3D3'},
{'name': 'Fuchsia', 'hex': '#C1366B', 'secondary_hex': None},
{'name': 'Corail / Noir', 'hex': '#FF7F50', 'secondary_hex': '#000000'},
{'name': 'Blanc / Bleu Sport', 'hex': '#FFFFFF', 'secondary_hex': '#112B44'},
{'name': 'Blanc', 'hex': '#FFFFFF', 'secondary_hex': None},
]
color_objects = {}
for color_data in colors:
color, created = Color.objects.get_or_create(
name=color_data['name'],
defaults={
'colorHex': color_data['hex'],
'secondary_hex_color': color_data['secondary_hex']
}
)
color_objects[color_data['name']] = color
if created:
self.stdout.write(f'Created color: {color_data["name"]}')
else:
# Update existing colors with secondary color if needed
if color.colorHex != color_data['hex'] or color.secondary_hex_color != color_data['secondary_hex']:
color.colorHex = color_data['hex']
color.secondary_hex_color = color_data['secondary_hex']
color.save()
self.stdout.write(f'Updated color: {color_data["name"]}')
else:
self.stdout.write(f'Color already exists: {color_data["name"]}')
# Create sizes
self.stdout.write('Creating sizes...')
sizes = ['Taille Unique', 'XS', 'S', 'M', 'L', 'XL', 'XXL', '3XL', '4XL']
size_objects = {}
for name in sizes:
size, created = Size.objects.get_or_create(name=name)
size_objects[name] = size
if created:
self.stdout.write(f'Created size: {name}')
else:
self.stdout.write(f'Size already exists: {name}')
# Create products
self.stdout.write('Creating products...')
products = [
{
'sku': 'PC001',
'title': 'Padel Club Cap',
'description': 'Casquette logo centre',
'price': 25.00,
'ordering_value': 1,
'cut': 0, # Unisex
'colors': ['Blanc', 'Bleu Sport', 'Noir'],
'sizes': ['Taille Unique'],
'image_filename': 'hat.jpg'
},
{
'sku': 'PC002',
'title': 'Padel Club Hoodie Femme',
'description': 'Hoodie femme logo cœur et dos',
'price': 50.00,
'ordering_value': 10,
'cut': 1,
'colors': ['Blanc', 'Bleu Sport', 'Noir', 'Fuchsia'],
'sizes': ['XS', 'S', 'M', 'L', 'XL', 'XXL'],
'image_filename': 'PS_K473_WHITE.png.avif'
},
{
'sku': 'PC003',
'title': 'Padel Club Hoodie Homme',
'description': 'Hoodie homme logo cœur et dos',
'price': 50.00,
'ordering_value': 11,
'cut': 2,
'colors': ['Blanc', 'Bleu Sport', 'Noir', 'Fuchsia'],
'sizes': ['XS', 'S', 'M', 'L', 'XL', 'XXL', '3XL', '4XL'],
'image_filename': 'PS_K476_WHITE.png.avif'
},
{
'sku': 'PC004',
'title': 'Débardeur Femme',
'description': 'Débardeur femme avec logo coeur.',
'price': 25.00,
'ordering_value': 20,
'cut': 1, # Women
'colors': ['Blanc / Bleu Sport', 'Noir', 'Noir / Gris Foncé Chiné'],
'sizes': ['XS', 'S', 'M', 'L', 'XL'],
'image_filename': 'PS_PA4031_WHITE-SPORTYNAVY.png.avif'
},
{
'sku': 'PC005',
'title': 'Jupe bicolore Femme',
'description': 'Avec short intégré logo jambe (sauf corail)',
'price': 30.00,
'ordering_value': 30,
'cut': 1, # Women
'colors': ['Blanc / Bleu Sport', 'Bleu Sport / Blanc', 'Corail / Noir', 'Noir / Gris Foncé Chiné'],
'sizes': ['XS', 'S', 'M', 'L', 'XL'],
'image_filename': 'PS_PA1031_WHITE-SPORTYNAVY.png.avif'
},
{
'sku': 'PC006',
'title': 'T-shirt Bicolore Homme',
'description': 'T-shirt bicolore avec logo coeur.',
'price': 25.00,
'ordering_value': 40,
'cut': 2, # Men
'colors': ['Blanc / Bleu Sport', 'Noir', 'Noir / Gris Foncé Chiné'],
'sizes': ['S', 'M', 'L', 'XL', 'XXL', '3XL'],
'image_filename': 'tshirt_h.png'
},
{
'sku': 'PC007',
'title': 'Short Bicolore Homme',
'description': 'Short bicolore avec logo jambe.',
'price': 30.00,
'ordering_value': 50,
'cut': 2, # Men
'colors': ['Blanc / Bleu Sport', 'Noir', 'Noir / Gris Foncé Chiné'],
'sizes': ['S', 'M', 'L', 'XL', 'XXL', '3XL'],
'image_filename': 'PS_PA1030_WHITE-SPORTYNAVY.png.avif'
},
]
for product_data in products:
product, created = Product.objects.update_or_create(
sku=product_data['sku'],
defaults={
'title': product_data['title'],
'description': product_data.get('description', ''),
'price': product_data['price'],
'ordering_value': product_data['ordering_value'],
'cut': product_data['cut']
}
)
if created:
self.stdout.write(f'Created product: {product_data["sku"]} - {product_data["title"]}')
else:
self.stdout.write(f'Updated product: {product_data["sku"]} - {product_data["title"]}')
# Handle the image path
if 'image_filename' in product_data and product_data['image_filename']:
image_path = f"{settings.STATIC_URL}shop/images/products/{product_data['image_filename']}"
if product.image != image_path:
product.image = image_path
product.save()
self.stdout.write(f'Updated image path to "{image_path}" for: {product_data["sku"]}')
# Update colors - first clear existing then add new ones
product.colors.clear()
for color_name in product_data['colors']:
if color_name in color_objects:
product.colors.add(color_objects[color_name])
self.stdout.write(f'Updated colors for: {product_data["sku"]}')
# Update sizes - first clear existing then add new ones
product.sizes.clear()
for size_name in product_data['sizes']:
if size_name in size_objects:
product.sizes.add(size_objects[size_name])
self.stdout.write(f'Updated sizes for: {product_data["sku"]}')
self.stdout.write(self.style.SUCCESS('Successfully created/updated shop data'))

@ -0,0 +1,53 @@
# Generated by Django 4.2.11 on 2025-03-17 17:27
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Color',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(choices=[('Red', 'Red'), ('Blue', 'Blue'), ('Green', 'Green'), ('Black', 'Black'), ('White', 'White')], max_length=10, unique=True)),
],
),
migrations.CreateModel(
name='Size',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(choices=[('S', 'Small'), ('M', 'Medium'), ('L', 'Large'), ('XL', 'X-Large')], max_length=5, unique=True)),
],
),
migrations.CreateModel(
name='Product',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('image', models.ImageField(blank=True, null=True, upload_to='products/')),
('price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10)),
('colors', models.ManyToManyField(blank=True, related_name='products', to='shop.color')),
('sizes', models.ManyToManyField(blank=True, related_name='products', to='shop.size')),
],
),
migrations.CreateModel(
name='CartItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1)),
('session_id', models.CharField(blank=True, max_length=255, null=True)),
('date_added', models.DateTimeField(auto_now_add=True)),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shop.product')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

@ -0,0 +1,39 @@
# Generated by Django 4.2.11 on 2025-03-17 17:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0001_initial'),
]
operations = [
migrations.RenameModel(
old_name='Color',
new_name='ProductColor',
),
migrations.RenameModel(
old_name='Size',
new_name='ProductSize',
),
migrations.RemoveField(
model_name='product',
name='colors',
),
migrations.RemoveField(
model_name='product',
name='sizes',
),
migrations.AddField(
model_name='product',
name='product_colors',
field=models.ManyToManyField(blank=True, to='shop.productcolor'),
),
migrations.AddField(
model_name='product',
name='product_sizes',
field=models.ManyToManyField(blank=True, to='shop.productsize'),
),
]

@ -0,0 +1,39 @@
# Generated by Django 4.2.11 on 2025-03-17 17:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0002_rename_color_productcolor_rename_size_productsize_and_more'),
]
operations = [
migrations.RenameModel(
old_name='ProductColor',
new_name='Color',
),
migrations.RenameModel(
old_name='ProductSize',
new_name='Size',
),
migrations.RemoveField(
model_name='product',
name='product_colors',
),
migrations.RemoveField(
model_name='product',
name='product_sizes',
),
migrations.AddField(
model_name='product',
name='colors',
field=models.ManyToManyField(blank=True, related_name='products', to='shop.color'),
),
migrations.AddField(
model_name='product',
name='sizes',
field=models.ManyToManyField(blank=True, related_name='products', to='shop.size'),
),
]

@ -0,0 +1,24 @@
# Generated by Django 4.2.11 on 2025-03-18 08:00
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('shop', '0003_rename_productcolor_color_rename_productsize_size_and_more'),
]
operations = [
migrations.AddField(
model_name='cartitem',
name='color',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.color'),
),
migrations.AddField(
model_name='cartitem',
name='size',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.size'),
),
]

@ -0,0 +1,23 @@
# Generated by Django 4.2.11 on 2025-03-18 08:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0004_cartitem_color_cartitem_size'),
]
operations = [
migrations.AlterField(
model_name='color',
name='name',
field=models.CharField(choices=[('Red', 'Red'), ('Blue', 'Blue'), ('Green', 'Green'), ('Black', 'Black'), ('White', 'White')], max_length=20, unique=True),
),
migrations.AlterField(
model_name='size',
name='name',
field=models.CharField(choices=[('S', 'Small'), ('M', 'Medium'), ('L', 'Large'), ('XL', 'X-Large'), ('SINGLE', 'Unique')], max_length=20, unique=True),
),
]

@ -0,0 +1,22 @@
# Generated by Django 4.2.11 on 2025-03-18 13:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0005_alter_color_name_alter_size_name'),
]
operations = [
migrations.AlterModelOptions(
name='product',
options={'ordering': ['order']},
),
migrations.AddField(
model_name='product',
name='order',
field=models.IntegerField(default=0),
),
]

@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2025-03-18 13:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0006_alter_product_options_product_order'),
]
operations = [
migrations.AddField(
model_name='product',
name='cut',
field=models.IntegerField(choices=[(1, 'Women'), (2, 'Men'), (3, 'Kids')], default=2),
),
]

@ -0,0 +1,22 @@
# Generated by Django 4.2.11 on 2025-03-18 14:24
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('shop', '0007_product_cut'),
]
operations = [
migrations.AlterModelOptions(
name='product',
options={'ordering': ['ordering_value', 'cut']},
),
migrations.RenameField(
model_name='product',
old_name='order',
new_name='ordering_value',
),
]

@ -0,0 +1,38 @@
# Generated by Django 4.2.11 on 2025-03-18 14:36
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('shop', '0008_alter_product_options_and_more'),
]
operations = [
migrations.CreateModel(
name='Order',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date_ordered', models.DateTimeField(auto_now_add=True)),
('status', models.CharField(choices=[('PENDING', 'Pending'), ('PAID', 'Paid'), ('SHIPPED', 'Shipped'), ('DELIVERED', 'Delivered'), ('CANCELED', 'Canceled')], default='PENDING', max_length=20)),
('total_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='OrderItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1)),
('price', models.DecimalField(decimal_places=2, max_digits=10)),
('color', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.color')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='shop.order')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shop.product')),
('size', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.size')),
],
),
]

@ -0,0 +1,21 @@
# Generated by Django 4.2.11 on 2025-03-18 14:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0009_order_orderitem'),
]
operations = [
migrations.CreateModel(
name='GuestUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(max_length=254)),
('phone', models.CharField(max_length=20)),
],
),
]

@ -0,0 +1,19 @@
# Generated by Django 4.2.11 on 2025-03-18 17:59
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('shop', '0010_guestuser'),
]
operations = [
migrations.AddField(
model_name='order',
name='guest_user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='shop.guestuser'),
),
]

@ -0,0 +1,28 @@
# Generated by Django 4.2.11 on 2025-03-19 12:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0011_order_guest_user'),
]
operations = [
migrations.AddField(
model_name='order',
name='payment_status',
field=models.CharField(choices=[('UNPAID', 'Unpaid'), ('PAID', 'Paid'), ('FAILED', 'Failed')], default='UNPAID', max_length=20),
),
migrations.AddField(
model_name='order',
name='stripe_checkout_session_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='order',
name='stripe_payment_intent_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

@ -0,0 +1,23 @@
# Generated by Django 4.2.11 on 2025-03-19 19:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0012_order_payment_status_and_more'),
]
operations = [
migrations.AddField(
model_name='color',
name='colorHex',
field=models.CharField(default='#FFFFFF', help_text='Color in hex format (e.g. #FF0000)', max_length=7),
),
migrations.AlterField(
model_name='color',
name='name',
field=models.CharField(max_length=20, unique=True),
),
]

@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2025-03-20 09:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0013_color_colorhex_alter_color_name'),
]
operations = [
migrations.AlterField(
model_name='size',
name='name',
field=models.CharField(max_length=20, unique=True),
),
]

@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2025-03-20 17:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0014_alter_size_name'),
]
operations = [
migrations.AlterField(
model_name='product',
name='image',
field=models.CharField(blank=True, max_length=200, null=True),
),
]

@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2025-03-21 05:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0015_alter_product_image'),
]
operations = [
migrations.AddField(
model_name='order',
name='webhook_processed',
field=models.BooleanField(default=False),
),
]

@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2025-03-21 08:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0016_order_webhook_processed'),
]
operations = [
migrations.AddField(
model_name='order',
name='stripe_mode',
field=models.CharField(choices=[('test', 'Test Mode'), ('live', 'Live Mode')], default='test', max_length=10),
),
]

@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2025-03-21 12:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0017_order_stripe_mode'),
]
operations = [
migrations.AddField(
model_name='color',
name='secondary_hex_color',
field=models.CharField(blank=True, help_text='Secondary color in hex format for split color display', max_length=7, null=True),
),
]

@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2025-03-21 12:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0018_color_secondary_hex_color'),
]
operations = [
migrations.AddField(
model_name='product',
name='description',
field=models.TextField(blank=True, help_text='Product description text', null=True),
),
]

@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2025-03-21 12:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0019_product_description'),
]
operations = [
migrations.AddField(
model_name='product',
name='sku',
field=models.CharField(default='PC000', help_text='Product SKU (unique identifier)', max_length=50, unique=True),
),
]

@ -0,0 +1,23 @@
# Generated by Django 4.2.11 on 2025-03-21 14:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0020_product_sku'),
]
operations = [
migrations.AlterField(
model_name='color',
name='name',
field=models.CharField(max_length=40, unique=True),
),
migrations.AlterField(
model_name='product',
name='sku',
field=models.CharField(help_text='Product SKU (unique identifier)', max_length=50, unique=True),
),
]

@ -0,0 +1,113 @@
from django.db import models
from django.conf import settings
class OrderStatus(models.TextChoices):
PENDING = 'PENDING', 'Pending'
PAID = 'PAID', 'Paid'
SHIPPED = 'SHIPPED', 'Shipped'
DELIVERED = 'DELIVERED', 'Delivered'
CANCELED = 'CANCELED', 'Canceled'
class CutChoices(models.IntegerChoices):
WOMEN = 1, 'Women'
MEN = 2, 'Men'
KIDS = 3, 'Kids'
class Color(models.Model):
name = models.CharField(max_length=40, unique=True)
colorHex = models.CharField(max_length=7, default="#FFFFFF", help_text="Color in hex format (e.g. #FF0000)")
secondary_hex_color = models.CharField(max_length=7, null=True, blank=True,
help_text="Secondary color in hex format for split color display")
def __str__(self):
return self.name
class Size(models.Model):
name = models.CharField(max_length=20, unique=True)
def __str__(self):
return self.name
class Product(models.Model):
sku = models.CharField(max_length=50, unique=True, help_text="Product SKU (unique identifier)")
title = models.CharField(max_length=200)
description = models.TextField(blank=True, null=True, help_text="Product description text")
image = models.CharField(max_length=200, null=True, blank=True)
price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00)
# Use string references to prevent circular imports
colors = models.ManyToManyField("shop.Color", blank=True, related_name="products")
sizes = models.ManyToManyField("shop.Size", blank=True, related_name="products")
ordering_value = models.IntegerField(default=0, blank=False)
cut = models.IntegerField(choices=CutChoices.choices, default=CutChoices.MEN)
class Meta:
ordering = ['ordering_value', 'cut'] # Add this line to sort by title
def __str__(self):
return f"{self.sku} - {self.title}"
class CartItem(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True)
product = models.ForeignKey(Product, on_delete=models.CASCADE)
quantity = models.PositiveIntegerField(default=1)
color = models.ForeignKey(Color, on_delete=models.SET_NULL, null=True, blank=True)
size = models.ForeignKey(Size, on_delete=models.SET_NULL, null=True, blank=True)
session_id = models.CharField(max_length=255, null=True, blank=True)
date_added = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['product__ordering_value', 'product__cut'] # Sort by product's ordering_value
def __str__(self):
return f"{self.quantity} x {self.product.title}"
def get_total_price(self):
return self.product.price * self.quantity
class GuestUser(models.Model):
email = models.EmailField()
phone = models.CharField(max_length=20)
def __str__(self):
return f"{self.email}"
class Order(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, 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)
guest_user = models.ForeignKey(GuestUser, on_delete=models.CASCADE, null=True, blank=True)
stripe_payment_intent_id = models.CharField(max_length=255, blank=True, null=True)
stripe_checkout_session_id = models.CharField(max_length=255, blank=True, null=True)
payment_status = models.CharField(max_length=20, default='UNPAID', choices=[
('UNPAID', 'Unpaid'),
('PAID', 'Paid'),
('FAILED', 'Failed'),
])
webhook_processed = models.BooleanField(default=False)
stripe_mode = models.CharField(max_length=10, default='test', choices=[
('test', 'Test Mode'),
('live', 'Live Mode'),
])
def __str__(self):
return f"Order #{self.id} - {self.status}"
class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items')
product = models.ForeignKey(Product, on_delete=models.CASCADE)
quantity = models.PositiveIntegerField(default=1)
color = models.ForeignKey(Color, on_delete=models.SET_NULL, null=True, blank=True)
size = models.ForeignKey(Size, on_delete=models.SET_NULL, null=True, blank=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
class Meta:
ordering = ['product__ordering_value', 'product__cut'] # Sort by product's ordering_value
def __str__(self):
return f"{self.quantity} x {self.product.title}"
def get_total_price(self):
return self.price * self.quantity

@ -0,0 +1,309 @@
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.core.mail import send_mail
from django.conf import settings
from django.urls import reverse
from .models import Order, OrderItem, OrderStatus
from django.db import transaction
@receiver([post_save, post_delete], sender=Order)
def send_order_notification(sender, instance, **kwargs):
"""Send an email notification when an order is created, updated, or deleted."""
transaction.on_commit(lambda: _send_order_email(instance, **kwargs))
def _send_order_email(instance, **kwargs):
# Skip processing for PENDING orders
if instance.status == OrderStatus.PENDING:
return
# Determine action type
action = _determine_action_type(kwargs)
if action in ["DELETED", "CREATED"]:
return # No emails for these actions
# Build common email components
order_details = _get_order_details(instance)
items_list = _build_items_list(instance.id, action)
# Send internal notification
_send_internal_notification(instance, action, order_details, items_list)
# Send customer notification if applicable
if order_details['customer_email']:
_send_customer_notification(instance, order_details, items_list)
def _determine_action_type(kwargs):
"""Determine the action type from signal kwargs."""
if 'signal' in kwargs and kwargs['signal'] == post_delete:
return "DELETED"
elif kwargs.get('created', False):
return "CREATED"
else:
return "UPDATED"
def _get_order_details(instance):
"""Extract and build order details dictionary."""
# Get customer info
customer_email = None
if instance.user:
customer_info = f"Utilisateur: {instance.user.email}"
customer_email = instance.user.email
elif instance.guest_user:
customer_info = f"Invité: {instance.guest_user.email} ({instance.guest_user.phone})"
customer_email = instance.guest_user.email
else:
customer_info = "Client inconnu"
# Translate statuses
status_fr_map = {
"PENDING": "EN ATTENTE", "PAID": "PAYÉE",
"SHIPPED": "EXPÉDIÉE", "DELIVERED": "LIVRÉE", "CANCELED": "ANNULÉE"
}
payment_status_fr_map = {
"UNPAID": "NON PAYÉE", "PAID": "PAYÉE", "FAILED": "ÉCHOUÉE"
}
return {
'order_id': instance.id,
'status': instance.status,
'status_fr': status_fr_map.get(instance.status, instance.status),
'payment_status': instance.payment_status,
'payment_status_fr': payment_status_fr_map.get(instance.payment_status, instance.payment_status),
'total_price': instance.total_price,
'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])}"
}
def _build_items_list(order_id, action):
"""Build the list of order items."""
items_list = ""
if action != "DELETED":
order_items = OrderItem.objects.filter(order_id=order_id).select_related('product', 'color', 'size')
for item in order_items:
color = item.color.name if item.color else "N/A"
size = item.size.name if item.size else "N/A"
items_list += f"- {item.quantity}x {item.product.title} (Couleur: {color}, Taille: {size}, Prix: {item.price}€)\n"
return items_list
def _translate_action(action):
"""Translate action to French."""
translations = {
"CREATED": "CRÉÉE", "UPDATED": "MISE À JOUR", "DELETED": "SUPPRIMÉE"
}
return translations.get(action, action)
def _send_internal_notification(instance, action, order_details, items_list):
"""Send notification email to shop managers."""
action_fr = _translate_action(action)
subject = f"Commande #{order_details['order_id']} {action_fr}: {order_details['status_fr']}"
message = f"""
La commande #{order_details['order_id']} a été {action_fr.lower()}
Statut: {order_details['status_fr']}
Statut de paiement: {order_details['payment_status_fr']}
Prix total: {order_details['total_price']}
{order_details['customer_info']}
Articles:
{items_list}
Voir la commande dans le panneau d'administration: {order_details['admin_url']}
Ceci est un message automatique. Merci de ne pas répondre.
"""
# Send internal email
recipient_list = [email for name, email in settings.SHOP_MANAGERS]
if not recipient_list:
recipient_list = [settings.DEFAULT_FROM_EMAIL]
send_mail(
subject=subject,
message=message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=recipient_list,
fail_silently=False,
)
def _send_customer_notification(instance, order_details, items_list):
"""Send appropriate notification email to customer based on order status."""
# Common email variables
contact_email = settings.SHOP_SUPPORT_EMAIL
shop_url = f"{settings.SHOP_SITE_ROOT_URL}/shop"
date_formatted = order_details['date_ordered'].strftime('%d/%m/%Y')
# Determine email content based on status and payment status
email_content = _get_customer_email_content(
instance.status,
order_details['payment_status'],
order_details['order_id'],
date_formatted,
order_details['status_fr'],
order_details['total_price'],
items_list,
contact_email,
shop_url
)
# Skip if no email content returned
if not email_content:
return
# Send email to customer
send_mail(
subject=email_content['subject'],
message=email_content['message'],
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[order_details['customer_email']],
fail_silently=False,
)
def _get_customer_email_content(status, payment_status, order_id, date, status_fr,
total_price, items_list, contact_email, shop_url):
"""Get the appropriate customer email content based on order status."""
# Payment confirmation email
if status == OrderStatus.PAID and payment_status == "PAID":
return {
'subject': f"Confirmation de votre commande #{order_id} - PadelClub",
'message': _build_payment_confirmation_email(order_id, date, status_fr,
total_price, items_list,
contact_email, shop_url)
}
# Order status update email
elif status in [OrderStatus.SHIPPED, OrderStatus.DELIVERED, OrderStatus.CANCELED]:
status_message = {
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.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} - PadelClub",
'message': _build_status_update_email(order_id, date, status_message, status_fr,
total_price, items_list, contact_email)
}
# Payment issue notification
elif payment_status == "FAILED":
return {
'subject': f"Problème de paiement pour votre commande #{order_id} - PadelClub",
'message': _build_payment_issue_email(order_id, date, total_price,
items_list, contact_email, shop_url)
}
# Payment reminder for unpaid orders
elif payment_status == "UNPAID" and status != OrderStatus.PENDING:
return {
'subject': f"Rappel de paiement pour votre commande #{order_id} - PadelClub",
'message': _build_payment_reminder_email(order_id, date, total_price,
items_list, contact_email)
}
# No email needed
return None
def _build_payment_confirmation_email(order_id, date, status_fr, total_price, items_list, contact_email, shop_url):
"""Build payment confirmation email message."""
return f"""
Bonjour,
Nous vous remercions pour votre commande sur PadelClub !
Récapitulatif de votre commande #{order_id} du {date} :
Statut: {status_fr}
Prix total: {total_price}
Détail de votre commande :
{items_list}
Nous nous occupons de préparer votre commande dans les plus brefs délais.
Pour toute question concernant votre commande, n'hésitez pas à contacter notre service client :
{contact_email}
Visitez notre boutique pour découvrir d'autres produits :
{shop_url}
Merci de votre confiance et à bientôt sur PadelClub !
L'équipe PadelClub
"""
def _build_status_update_email(order_id, date, status_message, status_fr, total_price, items_list, contact_email):
"""Build status update email message."""
return f"""
Bonjour,
Mise à jour concernant votre commande PadelClub #{order_id} du {date} :
{status_message}
Statut actuel: {status_fr}
Prix total: {total_price}
Détail de votre commande :
{items_list}
Pour toute question concernant votre commande, n'hésitez pas à contacter notre service client :
{contact_email}
Merci de votre confiance et à bientôt sur PadelClub !
L'équipe PadelClub
"""
def _build_payment_issue_email(order_id, date, total_price, items_list, contact_email, shop_url):
"""Build payment issue email message."""
return f"""
Bonjour,
Nous avons rencontré un problème lors du traitement du paiement de votre commande PadelClub #{order_id}.
Détails de la commande :
Date: {date}
Prix total: {total_price}
Articles:
{items_list}
Veuillez vérifier vos informations de paiement et réessayer. Si le problème persiste, n'hésitez pas à contacter notre service client :
{contact_email}
Vous pouvez également visiter notre boutique pour finaliser votre achat :
{shop_url}
Merci de votre compréhension.
L'équipe PadelClub
"""
def _build_payment_reminder_email(order_id, date, total_price, items_list, contact_email):
"""Build payment reminder email message."""
return f"""
Bonjour,
Nous vous rappelons que votre commande PadelClub #{order_id} du {date} n'a pas encore été payée.
Détails de la commande :
Prix total: {total_price}
Articles:
{items_list}
Pour finaliser votre commande, veuillez procéder au paiement dès que possible.
Si vous rencontrez des difficultés ou si vous avez des questions, n'hésitez pas à contacter notre service client :
{contact_email}
Merci de votre confiance.
L'équipe PadelClub
"""

@ -0,0 +1,482 @@
/* Product Display */
.options-container {
display: grid;
grid-template-columns: 1fr 30%;
align-content: center;
}
.no-image,
.product-image {
height: 240px;
width: 100%;
object-fit: contain; /* This will maintain the aspect ratio of the image */
background-color: white;
border-radius: 12px;
}
.no-image {
background-color: white;
}
.product-title {
font-size: 16px;
font-weight: bold;
}
.product-price {
font-size: 16px;
font-weight: bold;
}
/* Grid Layout */
.option-element {
align-content: center;
height: 39px;
background-color: white !important;
}
.option-element.product-title {
grid-column: 1 / span 2;
grid-row: 1;
background-color: blue;
}
.option-element.product-description {
grid-column: 1 / span 2;
grid-row: 2;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2; /* limit to 2 lines */
-webkit-box-orient: vertical;
}
.option-element.product-price {
grid-column: 2;
grid-row: 3;
text-align: center;
background-color: green;
}
.option-element.color-label {
grid-column: 1;
grid-row: 3;
font-size: 14px;
background-color: yellow;
}
.option-element.color-selector {
grid-column: 1;
grid-row: 4;
background-color: red;
}
.option-element.size-selector {
grid-column: 2;
grid-row: 4;
font-size: 12px;
text-align: center;
background-color: purple;
}
.option-element.quantity-selector {
grid-column: 1;
grid-row: 5;
}
.option-element.total-price {
grid-column: 2;
grid-row: 5;
text-align: center;
}
/* Buttons */
.add-to-cart-button,
.checkout-button {
background-color: #90ee90;
color: #707070;
border: none;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-decoration: none;
transition: background-color 0.3s ease;
cursor: pointer;
}
.add-to-cart-button {
margin-top: 10px;
width: 100%;
text-align: center;
display: inline-block;
height: 36px;
}
.checkout-button {
padding: 10px 20px;
display: inline-block;
height: 36px;
}
.confirm-nav-button {
background-color: #90ee90;
}
.cancel-nav-button {
background-color: #e84039;
color: white;
}
.remove-btn {
background-color: #e84039;
color: white;
border: none;
padding: 5px 10px;
border-radius: 12px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: background-color 0.3s ease;
}
.add-to-cart-button:hover,
.confirm-nav-button:hover,
.cancel-nav-button:hover,
.remove-btn:hover {
background-color: #f39200;
color: white;
}
/* Cart Table */
.cart-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
border-radius: 12px;
overflow: hidden;
padding: 0px;
}
.cart-table tbody tr.odd-row {
background-color: #f0f0f0;
}
.cart-table tbody tr.even-row {
background-color: #e8e8e8;
}
.cart-table th,
.cart-table td {
text-align: center;
}
.text-left {
text-align: left !important;
}
.price-column {
text-align: right;
}
.cart-table tfoot {
background-color: #f5f5f5;
}
.cart-table tfoot .total-label {
font-weight: bold;
}
.cart-table tfoot .total-price {
font-weight: bold;
font-size: 1.2em;
}
/* Quantity Controls */
.quantity-controls {
display: flex;
align-items: center;
justify-content: left;
}
.quantity-form {
display: flex;
align-items: center;
}
.quantity-btn {
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid #ddd;
background-color: #f8f8f8;
cursor: pointer;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
font-size: 16px;
transition: background-color 0.3s ease;
}
.quantity-btn:hover:not([disabled]) {
background-color: #f39200;
color: white;
border-color: #f39200;
}
.quantity-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.quantity-value {
width: 44px;
text-align: center;
font-weight: bold;
font-size: 14px;
}
/* Cart Summary & Checkout */
.cart-summary {
margin-top: 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
.guest-checkout-notice {
margin-top: 10px;
font-size: 0.9em;
background-color: #f8f9fa;
padding: 10px;
border-radius: 10px;
width: 100%;
}
/* Checkout Page */
.checkout-container {
padding: 25px;
}
.checkout-section {
margin-bottom: 25px;
}
.checkout-title {
color: #333;
font-size: 1.4em;
margin-bottom: 15px;
text-align: center;
}
.checkout-description {
text-align: center;
margin-bottom: 20px;
color: #666;
}
.guest-checkout-form {
background-color: #f8f9fa;
padding: 20px;
border-radius: 10px;
margin-bottom: 20px;
}
.form-fields p {
margin-bottom: 15px;
}
.form-fields label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-fields input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 1em;
}
.button-container {
display: flex;
justify-content: center;
margin-top: 20px;
}
.checkout-options {
text-align: center;
}
.checkout-options p {
margin: 15px 0;
}
/* Links */
.styled-link {
color: #f39200;
text-decoration: none;
font-weight: bold;
transition: color 0.3s;
}
.styled-link:hover {
color: #e84039;
text-decoration: underline;
}
.color-samples {
display: flex;
gap: 0px 8px;
vertical-align: middle;
align-items: center;
}
.color-sample {
width: 28px;
aspect-ratio: 1;
border-radius: 50%;
cursor: pointer;
border: 1px solid #ddd;
}
.color-sample:hover {
transform: scale(1.1);
}
.color-display {
display: flex;
align-items: center;
gap: 8px;
}
.color-sample-cart {
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid #ddd;
display: inline-block;
}
/* Fix for white + sign */
.quantity-btn {
color: #333; /* Darker text color to ensure visibility */
}
.quantity-btn:hover:not([disabled]) {
background-color: #f39200;
color: white;
border-color: #f39200;
}
v .cart-table {
display: block;
width: 100%;
}
.cart-table thead {
display: none; /* Hide header on small screens */
}
.cart-table tbody,
.cart-table tr,
.cart-table tfoot {
display: block;
width: 100%;
}
.cart-table td {
display: flex;
}
.cart-table td.product-name {
grid-column: 1 / span 2;
grid-row: 1;
font-weight: bold;
font-size: 1.1em;
}
.cart-table td.product-description {
grid-column: 1 / span 3;
grid-row: 2;
}
.cart-table td.product-color {
grid-column: 1 / span 2;
grid-row: 3;
}
.cart-table td.product-quantity {
grid-column: 1;
grid-row: 4;
}
.cart-table td.product-price {
grid-column: 3;
grid-row: 4;
justify-content: right;
}
.cart-table td.product-actions {
grid-column: 3;
grid-row: 1;
justify-content: right;
}
/* Make each product a grid container */
.cart-table tbody tr {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
}
/* Cart summary buttons */
.cart-summary {
flex-direction: column;
gap: 10px;
}
.checkout-button {
width: 100%;
text-align: center;
height: 44px; /* Fixed height */
line-height: 24px; /* Vertically center text */
display: flex;
align-items: center;
justify-content: center;
}
.cart-table tfoot tr {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
}
.cart-table tfoot td {
display: block;
width: 100%;
}
.cart-table tfoot td.total-quantity {
grid-column: 1;
grid-row: 1;
text-align: left;
}
.cart-table tfoot td.total-price {
grid-column: 3;
grid-row: 1;
text-align: right;
}
.cart-table tfoot td:empty {
display: none;
}
.color-sample,
.color-sample-cart {
position: relative;
overflow: hidden;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 909 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

@ -0,0 +1,90 @@
import stripe
from django.conf import settings
import logging
logger = logging.getLogger(__name__)
class StripeService:
"""Service class to manage Stripe operations with mode awareness"""
def __init__(self):
# Determine if we're in test mode
self.mode = getattr(settings, 'STRIPE_MODE', 'test')
self.is_test_mode = self.mode == 'test'
# Get appropriate keys based on mode
self.api_key = settings.STRIPE_SECRET_KEY
self.publishable_key = settings.STRIPE_PUBLISHABLE_KEY
self.webhook_secret = settings.STRIPE_WEBHOOK_SECRET
self.currency = getattr(settings, 'STRIPE_CURRENCY', 'eur')
# Configure Stripe library
stripe.api_key = self.api_key
# Log initialization in debug mode
mode_str = "TEST" if self.is_test_mode else "LIVE"
logger.debug(f"Initialized StripeService in {mode_str} mode")
def create_checkout_session(self, line_items, success_url, cancel_url, metadata=None):
"""Create a Stripe Checkout Session for one-time payments"""
if self.is_test_mode:
logger.info(f"Creating checkout session in TEST mode with metadata: {metadata}")
session = stripe.checkout.Session.create(
payment_method_types=['card'],
line_items=line_items,
mode='payment',
success_url=success_url,
cancel_url=cancel_url,
metadata=metadata or {},
)
return session
def verify_webhook_signature(self, payload, signature):
"""Verify webhook signature using mode-appropriate secret"""
try:
event = stripe.Webhook.construct_event(
payload, signature, self.webhook_secret
)
return event
except stripe.error.SignatureVerificationError as e:
logger.error(f"Webhook signature verification failed: {str(e)}")
raise
def construct_event_for_testing(self, payload, signature):
"""Helper method to construct events during testing"""
return stripe.Webhook.construct_event(
payload, signature, self.webhook_secret
)
def get_checkout_session(self, session_id):
"""Retrieve a checkout session by ID"""
return stripe.checkout.Session.retrieve(session_id)
def get_payment_intent(self, payment_intent_id):
"""Retrieve a payment intent by ID"""
return stripe.PaymentIntent.retrieve(payment_intent_id)
# Create a singleton instance for import and use throughout the app
stripe_service = StripeService()
# For backward compatibility, expose some functions directly
def create_payment_intent(amount, currency=None, metadata=None):
"""Legacy function for backward compatibility"""
if currency is None:
currency = stripe_service.currency
return stripe.PaymentIntent.create(
amount=amount,
currency=currency,
metadata=metadata or {},
)
def create_checkout_session(line_items, success_url, cancel_url, metadata=None):
"""Legacy function for backward compatibility"""
return stripe_service.create_checkout_session(
line_items=line_items,
success_url=success_url,
cancel_url=cancel_url,
metadata=metadata or {},
)

@ -0,0 +1,133 @@
{% extends 'tournaments/base.html' %}
{% block head_title %}La Boutique{% endblock %}
{% block first_title %}Padel Club{% endblock %}
{% 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>
{% 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.
<br>
<small>Use test card: 4242 4242 4242 4242 with any future date and any CVC.</small>
</div>
{% endif %}
<div class="grid-x">
<div class="small-12 medium-9 large-6 my-block">
<h1 class="club my-block topmargin20">Votre panier</h1>
<div class="bubble">
{% if display_data.items %}
{% 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 %}
<div class="cart-summary">
{% if user.is_authenticated %}
<!-- For authenticated users: Direct Stripe payment button -->
<button id="checkout-button" class="confirm-nav-button checkout-button">Procéder au paiement</button>
{% else %}
<!-- For guest users: Regular checkout path -->
<a class="confirm-nav-button checkout-button" href="{% url 'shop:checkout' %}">Passer la commande</a>
<div class="guest-checkout-notice">
<p>Connectez-vous pour un paiement plus rapide.</p>
<a class="styled-link" href="{% url 'login' %}?next={% url 'shop:view_cart' %}">Se connecter</a>
</div>
{% endif %}
<hr>
{% if display_data.items %}
<a class="cancel-nav-button checkout-button" href="{% url 'shop:clear_cart' %}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer votre panier ?');">Vider le panier</a>
{% endif %}
<!-- Debug buttons -->
{% if settings.DEBUG %}
<div style="margin-top: 20px; padding-top: 20px; border-top: 1px dashed #ccc;">
<h3>Debug Payment Simulation</h3>
<div style="display: flex; gap: 10px;">
<a class="confirm-nav-button checkout-button" href="{% url 'shop:simulate_payment_success' %}" style="background-color: #28a745;">
Simuler Paiement Réussi
</a>
<a class="cancel-nav-button checkout-button" href="{% url 'shop:simulate_payment_failure' %}" style="background-color: #dc3545;">
Simuler Paiement Échoué
</a>
</div>
</div>
{% endif %}
</div>
{% else %}
<p>Votre panier est vide.</p>
<a class="confirm-nav-button checkout-button" href="{% url 'shop:product_list' %}">Retourner à la boutique</a>
{% endif %}
</div>
</div>
</div>
{% if user.is_authenticated and display_data.items %}
<!-- Stripe JavaScript for authenticated users -->
<script src="https://js.stripe.com/v3/"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const checkoutButton = document.getElementById('checkout-button');
checkoutButton.addEventListener('click', function() {
// Show a loading indicator
checkoutButton.textContent = 'Chargement...';
checkoutButton.disabled = true;
// Create order and get checkout session
fetch('{% url "shop:create_checkout_session" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
credentials: 'same-origin',
})
.then(function(response) {
return response.json();
})
.then(function(session) {
if (session.error) {
// Handle error
alert(session.error);
checkoutButton.textContent = 'Procéder au paiement';
checkoutButton.disabled = false;
return;
}
// Initialize Stripe
const stripe = Stripe('{{ stripe_publishable_key }}');
// Redirect to Stripe Checkout
return stripe.redirectToCheckout({ sessionId: session.id });
})
.then(function(result) {
// If redirectToCheckout fails
if (result && result.error) {
alert(result.error.message);
checkoutButton.textContent = 'Procéder au paiement';
checkoutButton.disabled = false;
}
})
.catch(function(error) {
console.error('Error:', error);
alert('Une erreur est survenue. Veuillez réessayer.');
checkoutButton.textContent = 'Procéder au paiement';
checkoutButton.disabled = false;
});
});
});
</script>
{% endif %}
{% endblock %}

@ -0,0 +1,55 @@
{% extends 'tournaments/base.html' %}
{% block head_title %}La Boutique{% endblock %}
{% block first_title %}Padel Club{% endblock %}
{% 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>
<h1 class="club my-block topmargin20">Validation de la commande</h1>
<div class="grid-x">
<div class="small-12 medium-6 large-6 my-block">
<div class="bubble checkout-container">
{% if request.user.is_authenticated %}
<div class="checkout-section">
<p>Vous êtes déjà connecté en tant que <strong>{{ request.user.email }}</strong>.</p>
<a href="{% url 'shop:checkout' %}" class="checkout-button confirm-nav-button">Passer à la commande</a>
</div>
{% else %}
<div class="checkout-section">
<h3 class="checkout-title">Finaliser votre commande</h3>
<p class="checkout-description">Vous n'êtes pas connecté. Veuillez choisir une option :</p>
</div>
<div class="checkout-section">
<form method="post" class="guest-checkout-form">
{% csrf_token %}
<div class="form-fields">
{{ form.as_p }}
</div>
<div class="button-container">
<button class="checkout-button confirm-nav-button" type="submit" name="guest_checkout">Continuer sans créer de compte</button>
</div>
</form>
</div>
<div class="checkout-section checkout-options">
<p>Ou <a class="styled-link" href="{% url 'login' %}?next={% url 'shop:checkout' %}">connectez-vous</a> si vous avez déjà un compte.</p>
<p>Pas encore de compte ? <a class="styled-link" href="{% url 'signup' %}?next={% url 'shop:checkout' %}">Créez-en un maintenant</a></p>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

@ -0,0 +1,60 @@
<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>
</table>

@ -0,0 +1,63 @@
{% extends 'tournaments/base.html' %}
{% block head_title %}La Boutique{% endblock %}
{% block first_title %}Padel Club{% endblock %}
{% block second_title %}La Boutique{% endblock %}
{% block content %}
{% 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.
<br>
<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>
<div class="grid-x">
<div class="cell medium-6 large-6 my-block">
<h1 class="club my-block topmargin20">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 %}
<!-- Stripe checkout button -->
<div class="cart-summary">
<button id="checkout-button" class="confirm-nav-button checkout-button">Procéder au paiement</button>
</div>
</div>
</div>
</div>
<!-- Stripe JavaScript -->
<script src="https://js.stripe.com/v3/"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize Stripe
const stripe = Stripe('{{ stripe_publishable_key }}');
const checkoutButton = document.getElementById('checkout-button');
checkoutButton.addEventListener('click', function() {
// Redirect to Stripe Checkout
stripe.redirectToCheckout({
sessionId: '{{ checkout_session_id }}'
}).then(function(result) {
// If `redirectToCheckout` fails due to a browser or network
// error, display the localized error message to your customer
if (result.error) {
alert(result.error.message);
}
});
});
});
</script>
{% endblock %}

@ -0,0 +1,35 @@
<!-- padelclub_backend/shop/templates/shop/payment_cancel.html -->
{% extends 'tournaments/base.html' %}
{% block head_title %}La Boutique{% endblock %}
{% block first_title %}Padel Club{% endblock %}
{% 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>
<div class="grid-x">
<div class="cell medium-6 large-6 my-block">
<h1 class="club my-block topmargin20">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>
<div class="cart-summary">
<a class="confirm-nav-button checkout-button" href="{% url 'shop:view_cart' %}">Retour au panier</a>
</div>
</div>
</div>
</div>
{% endblock %}

@ -0,0 +1,38 @@
{% extends 'tournaments/base.html' %}
{% block head_title %}La Boutique{% endblock %}
{% block first_title %}Padel Club{% endblock %}
{% 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>
<div class="grid-x">
<div class="cell medium-6 large-6 my-block">
<h1 class="club my-block topmargin20">Paiement réussi</h1>
<div class="bubble">
<h2>Merci pour votre commande !</h2>
<p>Votre paiement a été traité avec succès.</p>
<p>Numéro de commande: {{ order.id }}</p>
<!-- Order details -->
<h3>Détails de la commande</h3>
{% 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 %}
<div class="cart-summary">
<a class="confirm-nav-button checkout-button" href="{% url 'shop:product_list' %}">Retour à la boutique</a>
</div>
</div>
</div>
</div>
{% endblock %}

@ -0,0 +1,128 @@
<div class="small-12 medium-6 large-3 my-block">
<div class="bubble">
{% if product.image %}
<img src="{{ product.image }}" alt="{{ product.title }}" class="product-image">
{% else %}
<div class="no-image">No Image Available</div>
{% endif %}
<form method="post" action="{% url 'shop:add_to_cart' product.id %}" class="add-to-cart-form">
{% csrf_token %}
<div class="options-container">
<div class="option-element product-title">
{{ product.title }}
</div>
{% if product.description %}
<div class="option-element product-description">
{{ product.description }}
</div>
{% endif %}
<div class="option-element product-price">{{ product.price }} €</div>
<div class="option-element color-label"><span id="selected-color-name-{{ product.id }}">{{ product.colors.all.0.name }}</span></div>
<div class="option-element color-selector form-group">
{% if product.colors.exists %}
<input type="hidden" name="color" id="color-{{ product.id }}" value="{{ product.colors.all.0.id }}" required>
<div class="color-samples">
{% for color in product.colors.all %}
<div class="color-sample {% if forloop.first %}selected{% endif %}"
{% if color.secondary_hex_color %}
style="background-image: linear-gradient(to right, {{ color.colorHex }} 50%, {{ color.secondary_hex_color }} 50%);"
{% else %}
style="background-color: {{ color.colorHex }};"
{% endif %}
title="{{ color.name }}"
data-color-id="{{ color.id }}"
data-color-name="{{ color.name }}"
onclick="selectColor('{{ product.id }}', '{{ color.id }}', '{{ color.name }}', this)"></div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="option-element size-selector form-group">
{% if product.sizes.exists %}
{% if product.sizes.all|length == 1 %}
<input type="hidden" name="size" value="{{ product.sizes.all.0.id }}">
<div>{{ product.sizes.all.0.name }}</div>
{% else %}
<select name="size" id="size-{{ product.id }}" class="form-control" required>
<option value="" disabled selected>Taille</option>
{% for size in product.sizes.all %}
<option value="{{ size.id }}" {% if forloop.first %}selected{% endif %}>{{ size.name }}</option>
{% endfor %}
</select>
{% endif %}
{% endif %}
</div>
<div class="option-element quantity-selector form-group">
<div class="quantity-controls">
<input type="hidden" name="quantity" id="quantity-{{ product.id }}" value="1">
<button type="button" class="quantity-btn decrease-btn" onclick="adjustQuantity('{{ product.id }}', -1, {{ product.price }})" disabled>-</button>
<span class="quantity-value" id="quantity-display-{{ product.id }}">1</span>
<button type="button" class="quantity-btn increase-btn" onclick="adjustQuantity('{{ product.id }}', 1, {{ product.price }})">+</button>
</div>
</div>
<div class="option-element total-price form-group"><span id="total-price-{{ product.id }}">{{ product.price }}</span></div>
</div>
<button type="submit" class="add-to-cart-button">Ajouter au panier</button>
</form>
</div>
</div>
<script>
function adjustQuantity(productId, change, price) {
let quantityInput = document.getElementById(`quantity-${productId}`);
let quantityDisplay = document.getElementById(`quantity-display-${productId}`);
// Get the decrease button that's within the same form as the quantity input
let decreaseBtn = quantityInput.closest('.quantity-controls').querySelector('.decrease-btn');
let currentQuantity = parseInt(quantityInput.value) || 1;
// Calculate new quantity
let newQuantity = currentQuantity + change;
// Enforce min/max limits
if (newQuantity < 1) newQuantity = 1;
if (newQuantity > 10) newQuantity = 10;
// Update quantity in hidden input and display
quantityInput.value = newQuantity;
quantityDisplay.textContent = newQuantity;
// Enable/disable decrease button based on quantity
decreaseBtn.disabled = (newQuantity <= 1);
// Update total price
updateTotal(productId, price);
}
function updateTotal(productId, price) {
let quantityInput = document.getElementById(`quantity-${productId}`);
let totalPriceElement = document.getElementById(`total-price-${productId}`);
let quantity = parseInt(quantityInput.value) || 1; // Default to 1 if invalid
let totalPrice = (quantity * price).toFixed(2); // Ensure two decimal places
totalPriceElement.textContent = totalPrice;
}
function selectColor(productId, colorId, colorName, element) {
// Update hidden input value
document.getElementById(`color-${productId}`).value = colorId;
// Update displayed color name
document.getElementById(`selected-color-name-${productId}`).textContent = colorName;
// Remove selected class from all colors
const colorSamples = element.parentElement.querySelectorAll('.color-sample');
colorSamples.forEach(sample => sample.classList.remove('selected'));
// Add selected class to clicked color
element.classList.add('selected');
}
</script>

@ -0,0 +1,39 @@
{% extends 'tournaments/base.html' %}
{% block head_title %}La Boutique{% endblock %}
{% block first_title %}Padel Club{% endblock %}
{% 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>
<nav class="margin10">
<a class="confirm-nav-button" href="{% url 'shop:view_cart' %}">Voir mon panier ({{ total }} €)</a>
{% if cart_items %}
<a class="cancel-nav-button" href="{% url 'shop:clear_cart' %}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer votre panier ?');">Vider le panier</a>
{% endif %}
</nav>
{% if products %}
<div class="grid-x">
{% for product in products %}
{% include "shop/product_item.html" with product=product %}
{% endfor %}
</div>
{% else %}
<p>No products available.</p>
{% endif %}
</div>
{% endblock %}

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

@ -0,0 +1,26 @@
from django.urls import path
from . import views
app_name = 'shop'
urlpatterns = [
path('', views.product_list, name='product_list'),
# Cart URLs
path('cart/', views.view_cart, name='view_cart'),
path('cart/add/<int:product_id>/', views.add_to_cart_view, name='add_to_cart'),
path('cart/update/<int:product_id>/', views.update_cart_view, name='update_cart'),
path('cart/remove/<int:product_id>/', views.remove_from_cart_view, name='remove_from_cart'),
path('clear-cart/', views.clear_cart, name='clear_cart'),
path('checkout/', views.checkout, name='checkout'),
path('payment/<int:order_id>/', views.payment, name='payment'),
path('payment/success/<int:order_id>/', views.payment_success, name='payment_success'),
path('payment/cancel/<int:order_id>/', views.payment_cancel, name='payment_cancel'),
path('webhook/stripe/', views.stripe_webhook, name='stripe_webhook'),
path('create-checkout-session/', views.create_checkout_session, name='create_checkout_session'),
path('cart/update-item/', views.update_cart_item, name='update_cart_item'),
path('cart/remove-item/', views.remove_from_cart, name='remove_from_cart'),
path('debug/simulate-payment-success/', views.simulate_payment_success, name='simulate_payment_success'),
path('debug/simulate-payment-failure/', views.simulate_payment_failure, name='simulate_payment_failure'),
]

@ -0,0 +1,584 @@
from .stripe_utils import stripe_service
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib import messages
from .models import Product, Order, OrderItem, GuestUser, OrderStatus
from django.db.models import Sum
from .forms import GuestCheckoutForm
import stripe
from django.conf import settings
from django.urls import reverse
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.views.decorators.csrf import ensure_csrf_cookie
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from . import cart
# Shared helper methods
def _check_stripe_config():
"""Check if Stripe API keys are properly configured"""
return hasattr(settings, 'STRIPE_SECRET_KEY') and settings.STRIPE_SECRET_KEY
def _create_stripe_line_items(order_items):
"""Create line items for Stripe checkout from order items"""
line_items = []
for item in order_items:
item_price = int(float(item.price) * 100) # Convert to cents
line_items.append({
'price_data': {
'currency': 'eur',
'product_data': {
'name': item.product.title,
'description': f"Color: {item.color.name if item.color else 'N/A'}, Size: {item.size.name if item.size else 'N/A'}",
},
'unit_amount': item_price,
},
'quantity': item.quantity,
})
return line_items
def _create_stripe_checkout_session(request, order, line_items):
"""Create a Stripe checkout session for the order"""
# Create success and cancel URLs
success_url = request.build_absolute_uri(reverse('shop:payment_success', args=[order.id]))
cancel_url = request.build_absolute_uri(reverse('shop:payment_cancel', args=[order.id]))
# Create metadata to identify this order
metadata = {
'order_id': str(order.id),
}
# Add user info to metadata if available
if request.user.is_authenticated:
metadata['user_id'] = str(request.user.id)
elif 'guest_email' in request.session:
metadata['guest_email'] = request.session.get('guest_email', '')
try:
# Use the service to create the session
checkout_session = stripe_service.create_checkout_session(
line_items=line_items,
success_url=success_url,
cancel_url=cancel_url,
metadata=metadata,
)
# Save the checkout session ID to the order
order.stripe_checkout_session_id = checkout_session.id
order.save()
return checkout_session
except Exception as e:
print(f"Stripe error: {str(e)}")
raise
# View functions
def product_list(request):
products = Product.objects.all()
cart_items = cart.get_cart_items(request)
total = cart.get_cart_total(request)
return render(request, 'shop/product_list.html', {
'products': products,
'cart_items': cart_items,
'total': total
})
def view_cart(request):
"""Display the shopping cart"""
cart_items = cart.get_cart_items(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
}
# Add Stripe publishable key for authenticated users
if request.user.is_authenticated:
context['stripe_publishable_key'] = settings.STRIPE_PUBLISHABLE_KEY
return render(request, 'shop/cart.html', context)
@ensure_csrf_cookie
def add_to_cart_view(request, product_id):
"""Add a product to the cart"""
product = get_object_or_404(Product, id=product_id)
quantity = int(request.POST.get('quantity', 1))
color_id = request.POST.get('color')
size_id = request.POST.get('size')
cart_item = cart.add_to_cart(request, product_id, quantity, color_id, size_id)
messages.success(request, f'{cart_item.quantity} x {product.title} added to your cart')
return redirect('shop:product_list')
def update_cart_view(request, product_id):
"""Update cart item quantity"""
if request.method == 'POST':
quantity = int(request.POST.get('quantity', 0))
cart.update_cart_item(request, product_id, quantity)
return redirect('shop:view_cart')
def remove_from_cart_view(request, product_id):
"""Remove item from cart"""
cart.remove_from_cart(request, product_id)
return redirect('shop:view_cart')
def clear_cart(request):
"""Clear the cart"""
cart.clear_cart(request)
messages.success(request, "Your cart has been cleared.")
return redirect('shop:product_list')
def create_order(request):
"""Create an order from the current cart"""
cart_items = cart.get_cart_items(request)
# Check if cart is empty
if not cart_items.exists():
return None
total_price = sum(item.get_total_price() for item in cart_items)
# Check if total price is valid
if total_price <= 0:
return None
if request.user.is_authenticated:
# Authenticated user order
order = Order.objects.create(
user=request.user,
total_price=total_price,
stripe_mode=stripe_service.mode # Add this line
)
else:
# Guest user order
try:
guest_user = GuestUser.objects.get(email=request.session['guest_email'])
order = Order.objects.create(
guest_user=guest_user,
total_price=total_price,
stripe_mode=stripe_service.mode # Add this line
)
except (KeyError, GuestUser.DoesNotExist):
# No guest user information, create order without user
order = Order.objects.create(
total_price=total_price,
stripe_mode=stripe_service.mode # Add this line
)
# Create order items
for cart_item in cart_items:
OrderItem.objects.create(
order=order,
product=cart_item.product,
quantity=cart_item.quantity,
color=cart_item.color,
size=cart_item.size,
price=cart_item.product.price
)
# Note: Cart is not cleared here, only after successful payment
return order
def checkout(request):
"""Handle checkout process for both authenticated and guest users"""
# Check if cart is empty
cart_items = cart.get_cart_items(request)
if not cart_items.exists():
messages.error(request, "Your cart is empty. Please add items before checkout.")
return redirect('shop:product_list')
if request.user.is_authenticated:
# Create order for authenticated user and go directly to payment
return _handle_authenticated_checkout(request)
# Handle guest checkout
if request.method == 'GET':
form = GuestCheckoutForm()
return render(request, 'shop/checkout.html', {'form': form})
elif request.method == 'POST':
return _handle_guest_checkout_post(request)
return redirect('shop:product_list')
def _handle_authenticated_checkout(request):
"""Helper function to handle checkout for authenticated users"""
order = create_order(request)
if not order:
messages.error(request, "There was an issue creating your order. Please try again.")
return redirect('shop:view_cart')
return redirect('shop:payment', order_id=order.id)
def _handle_guest_checkout_post(request):
"""Helper function to handle POST requests for guest checkout"""
form = GuestCheckoutForm(request.POST)
if form.is_valid():
# Create or get guest user
email = form.cleaned_data['email']
phone = form.cleaned_data['phone']
guest_user, created = GuestUser.objects.get_or_create(
email=email,
defaults={'phone': phone}
)
# Store email in session
request.session['guest_email'] = email
# Create order
order = create_order(request)
if not order:
messages.error(request, "There was an issue creating your order. Please try again.")
return redirect('shop:view_cart')
return redirect('shop:payment', order_id=order.id)
# Form invalid
return render(request, 'shop/checkout.html', {'form': form})
def payment(request, order_id):
"""Handle payment for an existing order"""
order = get_object_or_404(Order, id=order_id)
order.status = OrderStatus.PENDING
order.payment_status = 'PENDING'
order_items = order.items.all()
# Check for valid order
if not order_items.exists() or order.total_price <= 0:
messages.error(request, "Cannot process an empty order.")
return redirect('shop:product_list')
# Log payment processing
print(f"Processing payment for order #{order_id}, total: {order.total_price}")
# Check Stripe configuration
if not _check_stripe_config():
messages.error(request, "Stripe API keys not configured properly.")
return redirect('shop:view_cart')
# Create line items
line_items = _create_stripe_line_items(order_items)
if not line_items:
messages.error(request, "Cannot create payment with no items.")
return redirect('shop:view_cart')
# Create checkout session
try:
checkout_session = _create_stripe_checkout_session(request, order, line_items)
checkout_session_id = checkout_session.id
except Exception as e:
messages.error(request, f"Payment processing error: {str(e)}")
return redirect('shop:view_cart')
display_data = prepare_item_display_data(order_items, is_cart=False)
# Render payment page
return render(request, 'shop/payment.html', {
'order': order,
'display_data': display_data,
'checkout_session_id': checkout_session_id,
'stripe_publishable_key': settings.STRIPE_PUBLISHABLE_KEY,
})
def payment_success(request, order_id):
"""Handle successful payment"""
order = get_object_or_404(Order, id=order_id)
# Clear cart after successful payment
cart.clear_cart(request)
# Only update if not already processed by webhook
if not order.webhook_processed:
print(f"Updating order {order_id} via redirect (not webhook)")
order.status = OrderStatus.PAID
order.payment_status = 'PAID'
order.save()
else:
print(f"Order {order_id} already processed by webhook")
# Get order items for template
order_items = order.items.all()
display_data = prepare_item_display_data(order_items, is_cart=False)
return render(request, 'shop/payment_success.html', {
'order': order,
'display_data': display_data,
})
def payment_cancel(request, order_id):
"""Handle cancelled payment"""
order = get_object_or_404(Order, id=order_id)
# Only update if not already processed by webhook
if not order.webhook_processed:
print(f"Updating order {order_id} to CANCELED via redirect (not webhook)")
order.status = OrderStatus.CANCELED
order.payment_status = 'FAILED'
order.save()
else:
print(f"Order {order_id} already processed by webhook")
messages.warning(request, "Your payment was cancelled.")
return render(request, 'shop/payment_cancel.html', {'order': order})
@require_POST
def create_checkout_session(request):
"""API endpoint to create a Stripe checkout session directly from cart"""
if not request.user.is_authenticated:
return JsonResponse({'error': 'User must be authenticated'}, status=403)
# Create the order
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()
# Create line items
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})
except Exception as e:
return JsonResponse({'error': str(e)}, status=400)
@require_POST
def update_cart_item(request):
"""Update a cart item quantity (increase/decrease)"""
item_id = request.POST.get('item_id')
action = request.POST.get('action')
try:
cart_item = cart.get_cart_item(request, item_id)
if action == 'increase':
# Increase quantity by 1
cart_item.quantity += 1
cart_item.save()
messages.success(request, "Quantity increased.")
elif action == 'decrease' and cart_item.quantity > 1:
# Decrease quantity by 1
cart_item.quantity -= 1
cart_item.save()
messages.success(request, "Quantity decreased.")
except Exception as e:
messages.error(request, f"Error updating cart: {str(e)}")
return redirect('shop:view_cart')
@require_POST
def remove_from_cart(request):
"""Remove an item from cart by item_id"""
item_id = request.POST.get('item_id')
try:
cart_item = cart.get_cart_item(request, item_id)
cart_item.delete()
messages.success(request, "Item removed from cart.")
except Exception as e:
messages.error(request, f"Error removing item: {str(e)}")
return redirect('shop:view_cart')
def prepare_item_display_data(items, is_cart=True):
"""
Transform cart items or order items into a standardized format for display
Args:
items: QuerySet of CartItem or OrderItem
is_cart: True if items are CartItems, False if OrderItems
Returns:
Dictionary with standardized item data
"""
prepared_items = []
total_quantity = 0
total_price = 0
for item in items:
if is_cart:
# For CartItem
item_data = {
'id': item.id,
'product_title': item.product.title,
'product_description': item.product.description if item.product.description else None,
'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()
}
total_price += item.get_total_price()
else:
# For OrderItem
item_data = {
'id': item.id,
'product_title': item.product.title,
'product_description': item.product.description if item.product.description else None,
'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()
}
total_price += item.get_total_price()
total_quantity += item.quantity
prepared_items.append(item_data)
return {
'items': prepared_items,
'total_quantity': total_quantity,
'total_price': total_price
}
def simulate_payment_success(request):
"""Debug function to simulate successful payment without Stripe"""
# Create an order from the cart
order = create_order(request)
if not order:
messages.error(request, "Could not create order from cart")
return redirect('shop:view_cart')
# Clear the cart
cart.clear_cart(request)
return redirect('shop:payment_success', order_id=order.id)
def simulate_payment_failure(request):
"""Debug function to simulate failed payment without Stripe"""
# Create an order from the cart
order = create_order(request)
if not order:
messages.error(request, "Could not create order from cart")
return redirect('shop:view_cart')
return redirect('shop:payment_cancel', order_id=order.id)
@require_POST
@csrf_exempt
def stripe_webhook(request):
"""Handle Stripe webhook events"""
payload = request.body
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')
# Log the received webhook for debugging
print(f"Webhook received: {sig_header}")
if not sig_header:
print("No signature header")
return HttpResponse(status=400)
try:
# Use the service to verify the webhook
event = stripe_service.verify_webhook_signature(payload, sig_header)
# Log the event type and mode
mode = "TEST" if stripe_service.is_test_mode else "LIVE"
print(f"{mode} webhook event type: {event['type']}")
except ValueError as e:
print(f"Invalid payload: {str(e)}")
return HttpResponse(status=400)
except stripe.error.SignatureVerificationError as e:
print(f"Invalid signature: {str(e)}")
return HttpResponse(status=400)
# Handle the event based on type
event_type = event['type']
if event_type == 'checkout.session.completed':
handle_checkout_session_completed(event['data']['object'])
elif event_type == 'payment_intent.succeeded':
handle_payment_intent_succeeded(event['data']['object'])
elif event_type == 'payment_intent.payment_failed':
handle_payment_intent_failed(event['data']['object'])
# Return success response
return HttpResponse(status=200)
def handle_checkout_session_completed(session):
"""Handle completed checkout session webhook"""
print(f"Processing checkout.session.completed for session {session.id}")
# Get order from metadata
order_id = session.get('metadata', {}).get('order_id')
if not order_id:
print(f"Warning: No order_id in metadata for session {session.id}")
return
try:
order = Order.objects.get(id=order_id)
# Update order status if not already processed
if not order.webhook_processed:
print(f"Updating order {order_id} to PAID via webhook")
order.status = OrderStatus.PAID
order.payment_status = 'PAID'
order.webhook_processed = True
order.save()
# You could add additional processing here
# - Send order confirmation email
# - Update inventory
# - Etc.
else:
print(f"Order {order_id} already processed by webhook")
except Order.DoesNotExist:
print(f"Error: Order {order_id} not found for session {session.id}")
def handle_payment_intent_succeeded(payment_intent):
"""Handle successful payment intent webhook"""
print(f"Processing payment_intent.succeeded for intent {payment_intent.id}")
# If you're using payment intents directly, handle them here
# For Checkout Sessions, you'll likely rely on checkout.session.completed instead
def handle_payment_intent_failed(payment_intent):
"""Handle failed payment intent webhook"""
print(f"Processing payment_intent.payment_failed for intent {payment_intent.id}")
# Get order from metadata
order_id = payment_intent.get('metadata', {}).get('order_id')
if not order_id:
print(f"No order_id in metadata for payment intent {payment_intent.id}")
return
try:
order = Order.objects.get(id=order_id)
# Update order status
if not order.webhook_processed or order.payment_status != 'FAILED':
print(f"Updating order {order_id} to FAILED via webhook")
order.status = OrderStatus.CANCELED
order.payment_status = 'FAILED'
order.webhook_processed = True
order.save()
except Order.DoesNotExist:
print(f"Error: Order {order_id} not found for payment intent {payment_intent.id}")

@ -13,7 +13,7 @@ class CustomUserAdmin(UserAdmin):
form = CustomUserChangeForm
add_form = CustomUserCreationForm
model = CustomUser
list_display = ['first_name', 'last_name', 'email', 'latest_event_club_name', 'username', 'is_active', 'date_joined', 'event_count', 'origin']
list_display = ['email', 'first_name', 'last_name', 'latest_event_club_name', 'username', 'is_active', 'date_joined', 'event_count', 'origin']
list_filter = ['is_active', 'origin']
ordering = ['-date_joined']
fieldsets = [
@ -42,6 +42,7 @@ class CustomUserAdmin(UserAdmin):
class EventAdmin(SyncedObjectAdmin):
list_display = ['creation_date', 'name', 'club', 'creator', 'tenup_id']
list_filter = ['creator', 'tenup_id']
raw_id_fields = ['creator']
ordering = ['-creation_date']
class TournamentAdmin(SyncedObjectAdmin):
@ -110,6 +111,7 @@ class ClubAdmin(SyncedObjectAdmin):
list_display = ['name', 'acronym', 'city', 'creator', 'events_count', 'broadcast_code']
search_fields = ('name', 'acronym', 'city')
ordering = ['creator']
raw_id_fields = ['creator']
class PurchaseAdmin(SyncedObjectAdmin):
list_display = ['id', 'user', 'product_id', 'quantity', 'purchase_date', 'revocation_date', 'expiration_date']

@ -0,0 +1,28 @@
from django.contrib import messages
from django.contrib.auth import views as auth_views
from django.urls import reverse
from .forms import EmailOrUsernameAuthenticationForm
class CustomLoginView(auth_views.LoginView):
template_name = 'registration/login.html'
authentication_form = EmailOrUsernameAuthenticationForm
def get_success_url(self):
# First check the 'next' parameter which has higher priority
next_url = self.request.POST.get('next') or self.request.GET.get('next')
if next_url and next_url.strip():
return next_url
# Then check if we have a stored referrer URL
referrer = self.request.session.get('login_referrer')
if referrer:
# Clear the stored referrer to prevent reuse
del self.request.session['login_referrer']
return referrer
# Fall back to default
return reverse('index')
def get(self, request, *args, **kwargs):
messages.get_messages(request).used = True
return super().get(request, *args, **kwargs)

@ -0,0 +1,19 @@
from django.conf import settings
from django.urls import resolve, reverse
class ReferrerMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Check if the user is anonymous and going to the login page
if not request.user.is_authenticated and request.path == reverse('login'):
# Get the referring URL from the HTTP_REFERER header
referrer = request.META.get('HTTP_REFERER')
# Only store referrer if it exists and is not the login page itself
if referrer and 'login' not in referrer:
request.session['login_referrer'] = referrer
response = self.get_response(request)
return response

@ -218,7 +218,11 @@ class GroupStageTeam:
self.set_diff = 0
self.game_diff = 0
self.display_set_difference = False
self.weight = team_registration.weight
if team_registration.playerregistration_set.count() == 0:
weight = None
else:
weight = team_registration.weight
self.weight = weight
self.team_registration = team_registration
self.qualified = team_registration.qualified

@ -76,7 +76,12 @@ class TeamRegistration(SideStoreModel):
else:
players = list(self.player_registrations.all())
if len(players) == 0:
return []
if self.wild_card_bracket:
return ['Place réservée wildcard']
elif self.wild_card_group_stage:
return ['Place réservée wildcard']
else:
return ['Place réservée']
elif len(players) == 1:
return [players[0].shortened_name()]
else:
@ -288,3 +293,10 @@ class TeamRegistration(SideStoreModel):
if p.registered_online:
return True
return False
def formatted_special_status(self):
if self.wild_card_bracket:
return "(wildcard tableau)"
if self.wild_card_group_stage:
return "(wildcard poule)"
return ""

@ -112,8 +112,11 @@ class TeamScore(SideStoreModel):
if self.team_registration:
id = self.team_registration.id
image = self.team_registration.logo
weight = self.team_registration.weight
is_winner = self.team_registration.id == match.winning_team_id
if self.team_registration.playerregistration_set.count() == 0:
weight = None
else:
weight = self.team_registration.weight
else:
id = None
image = None

@ -1453,7 +1453,11 @@ class TeamItem:
self.names = team_registration.team_names()
self.date = team_registration.local_call_date()
self.registration_date = team_registration.registration_date
self.weight = team_registration.weight
if team_registration.playerregistration_set.count() == 0:
weight = None
else:
weight = team_registration.weight
self.weight = weight
self.initial_weight = team_registration.initial_weight()
self.image = team_registration.logo
self.stage = ""

@ -109,9 +109,21 @@
{% endif %}
<div class="margin10">
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
{% endfor %}
{% if form.errors %}
<div class="alert alert-error">
{% if form.non_field_errors %}
{% for error in form.non_field_errors %}
<p>{{ error }}</p>
{% endfor %}
{% endif %}
{% for field in form %}
{% for error in field.errors %}
<p>{{ error }}</p>
{% endfor %}
{% endfor %}
</div>
{% endif %}
</div>
<button type="submit" name="add_player" class="rounded-button">

@ -43,9 +43,6 @@
</form>
<p>Pas encore de compte ? <a href="{% url 'signup' %}" class="styled-link">Créer le tout de suite !</a></p>
</div>
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
{% endfor %}
</div>
</div>

@ -13,7 +13,9 @@
/>
<link rel="stylesheet" href="{% static 'tournaments/css/style.css' %}" />
<link rel="stylesheet" href="{% static 'tournaments/css/basics.css' %}" />
{% block extra_css %}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'shop/css/shop.css' %}">
{% endblock %}
<link
rel="icon"

@ -34,7 +34,7 @@
<div class="flex-right">
<div x-show="group_stage.started === false">
<div class="score ws numbers" x-show="hide_weight === false">
<div class="score ws numbers" x-show="hide_weight === false && group_stage.teams[i-1].weight">
<span x-text="group_stage.teams[i-1].weight"></span>
</div>
</div>

@ -14,7 +14,7 @@
<div class="flex {% if team.qualified %}qualified{% endif %}">
<div class="flex-left player {% if team.names|length == 1 %}single-player{% else %}two-players{% endif %}">
{% if team.team_registration.id %}
{% if team.team_registration.id and team.weight %}
<a href="{% url 'team-details' tournament.id team.team_registration.id %}" class="group-stage-link"> <!-- Add this anchor tag -->
{% endif %}
@ -24,7 +24,7 @@
</div>
{% endfor %}
{% if team.team_registration.id %}
{% if team.team_registration.id and team.weight %}
</a>
{% endif %}
@ -36,7 +36,7 @@
{% else %}
{% if tournament.hide_weight %}
<div class="score ws"></div>
{% else %}
{% elif team.weight %}
<div class="score ws numbers">{{ team.weight }}</div>
{% endif %}
{% endif %}

@ -15,7 +15,7 @@
{% for team in match.teams %}
<div class="match-result {% cycle 'bottom-border' '' %}">
<div class="player {% if team.names|length == 1 %}single-player{% else %}two-players{% endif %}">
{% if team.id %}
{% if team.id and team.weight %}
<a href="{% url 'team-details' tournament.id team.id %}" class="player-link">
{% endif %}
@ -30,7 +30,7 @@
{{ name }}
</div>
{% endfor %}
{% if team.id %}
{% if team.id and team.weight %}
</a>
{% endif %}
</div>

@ -5,6 +5,7 @@
{% block second_title %}{{ tournament.display_name }}{% endblock %}
{% block content %}
{% include 'tournaments/navigation_tournament.html' %}
<div class="grid-x grid-margin-x">
<style>
.bubble {
@ -12,7 +13,7 @@
}
</style>
<div class="cell medium-12">
<h1 class="club my-block topmargin20">{{ team.formatted_team_names }}</h1>
<h1 class="club my-block topmargin20">{{ team.formatted_team_names }} {{team.formatted_special_status}}</h1>
<div class="grid-x">
{% for player in team.player_registrations.all %}
{% include 'tournaments/player_row.html' with player=player %}

@ -30,9 +30,9 @@
<div class="table-cell right horizontal-padding"></div>
{% elif tournament.hide_weight %}
<div class="table-cell right horizontal-padding"></div>
{% elif not team.wildcard_bracket and not team.wildcard_groupstage %}
{% elif not team.wildcard_bracket and not team.wildcard_groupstage and team.weight %}
<div class="table-cell right horizontal-padding large numbers">{{ team.weight }}</div>
{% elif team.names %}
{% elif team.names and team.weight %}
<div class="table-cell right horizontal-padding large numbers">{{ team.weight }}</div>
{% else %}
<div class="table-cell right horizontal-padding large numbers"></div>

@ -1,6 +1,11 @@
from django.contrib.auth import views as auth_views
from django.urls import include, path
from django.contrib import admin
from django.conf import settings
from django.conf.urls.static import static
from .forms import EmailOrUsernameAuthenticationForm, CustomPasswordChangeForm
from .custom_views import CustomLoginView
from . import views
@ -49,10 +54,7 @@ urlpatterns = [
path('terms-of-use/', views.terms_of_use, name='terms-of-use'),
path('utils/xls-to-csv/', views.xls_to_csv, name='xls-to-csv'),
path('mail-test/', views.simple_form_view, name='mail-test'),
path('login/', auth_views.LoginView.as_view(
template_name='registration/login.html',
authentication_form=EmailOrUsernameAuthenticationForm
), name='login'),
path('login/', CustomLoginView.as_view(), name='login'),
path('password_change/',
auth_views.PasswordChangeView.as_view(
success_url='/profile/', # Redirect back to profile after success

Loading…
Cancel
Save