diff --git a/padelclub_backend/settings.py b/padelclub_backend/settings.py index 56de9cc..87ad1e0 100644 --- a/padelclub_backend/settings.py +++ b/padelclub_backend/settings.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 * diff --git a/padelclub_backend/settings_app.py b/padelclub_backend/settings_app.py index 50ae97d..c34bc82 100644 --- a/padelclub_backend/settings_app.py +++ b/padelclub_backend/settings_app.py @@ -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' diff --git a/padelclub_backend/settings_local.py.dist b/padelclub_backend/settings_local.py.dist index 1ad1a9b..43ea805 100644 --- a/padelclub_backend/settings_local.py.dist +++ b/padelclub_backend/settings_local.py.dist @@ -37,3 +37,7 @@ DATABASES = { # }, # }, # } +STRIPE_MODE = 'test' +STRIPE_PUBLISHABLE_KEY = '' +STRIPE_SECRET_KEY = '' +STRIPE_WEBHOOK_SECRET = '' diff --git a/padelclub_backend/urls.py b/padelclub_backend/urls.py index 62a5654..85c8b15 100644 --- a/padelclub_backend/urls.py +++ b/padelclub_backend/urls.py @@ -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), diff --git a/requirements.txt b/requirements.txt index cd5b9c2..1f1169f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,4 @@ xlrd==2.0.1 openpyxl==3.1.5 django-filter==24.3 cryptography==41.0.7 +stripe==11.6.0 diff --git a/shop/__init__.py b/shop/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shop/admin.py b/shop/admin.py new file mode 100644 index 0000000..f4f6814 --- /dev/null +++ b/shop/admin.py @@ -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] diff --git a/shop/apps.py b/shop/apps.py new file mode 100644 index 0000000..e044cad --- /dev/null +++ b/shop/apps.py @@ -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 diff --git a/shop/cart.py b/shop/cart.py new file mode 100644 index 0000000..76dcec6 --- /dev/null +++ b/shop/cart.py @@ -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") diff --git a/shop/context_processors.py b/shop/context_processors.py new file mode 100644 index 0000000..b55a424 --- /dev/null +++ b/shop/context_processors.py @@ -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', + } diff --git a/shop/forms.py b/shop/forms.py new file mode 100644 index 0000000..a2f264b --- /dev/null +++ b/shop/forms.py @@ -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") diff --git a/shop/management/commands/__init__.py b/shop/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shop/management/commands/create_initial_shop_data.py b/shop/management/commands/create_initial_shop_data.py new file mode 100644 index 0000000..a613712 --- /dev/null +++ b/shop/management/commands/create_initial_shop_data.py @@ -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')) diff --git a/shop/migrations/0001_initial.py b/shop/migrations/0001_initial.py new file mode 100644 index 0000000..e1c35b8 --- /dev/null +++ b/shop/migrations/0001_initial.py @@ -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)), + ], + ), + ] diff --git a/shop/migrations/0002_rename_color_productcolor_rename_size_productsize_and_more.py b/shop/migrations/0002_rename_color_productcolor_rename_size_productsize_and_more.py new file mode 100644 index 0000000..6033982 --- /dev/null +++ b/shop/migrations/0002_rename_color_productcolor_rename_size_productsize_and_more.py @@ -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'), + ), + ] diff --git a/shop/migrations/0003_rename_productcolor_color_rename_productsize_size_and_more.py b/shop/migrations/0003_rename_productcolor_color_rename_productsize_size_and_more.py new file mode 100644 index 0000000..70c25ab --- /dev/null +++ b/shop/migrations/0003_rename_productcolor_color_rename_productsize_size_and_more.py @@ -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'), + ), + ] diff --git a/shop/migrations/0004_cartitem_color_cartitem_size.py b/shop/migrations/0004_cartitem_color_cartitem_size.py new file mode 100644 index 0000000..889c712 --- /dev/null +++ b/shop/migrations/0004_cartitem_color_cartitem_size.py @@ -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'), + ), + ] diff --git a/shop/migrations/0005_alter_color_name_alter_size_name.py b/shop/migrations/0005_alter_color_name_alter_size_name.py new file mode 100644 index 0000000..63c8369 --- /dev/null +++ b/shop/migrations/0005_alter_color_name_alter_size_name.py @@ -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), + ), + ] diff --git a/shop/migrations/0006_alter_product_options_product_order.py b/shop/migrations/0006_alter_product_options_product_order.py new file mode 100644 index 0000000..4c68f84 --- /dev/null +++ b/shop/migrations/0006_alter_product_options_product_order.py @@ -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), + ), + ] diff --git a/shop/migrations/0007_product_cut.py b/shop/migrations/0007_product_cut.py new file mode 100644 index 0000000..905a6bf --- /dev/null +++ b/shop/migrations/0007_product_cut.py @@ -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), + ), + ] diff --git a/shop/migrations/0008_alter_product_options_and_more.py b/shop/migrations/0008_alter_product_options_and_more.py new file mode 100644 index 0000000..513e9c5 --- /dev/null +++ b/shop/migrations/0008_alter_product_options_and_more.py @@ -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', + ), + ] diff --git a/shop/migrations/0009_order_orderitem.py b/shop/migrations/0009_order_orderitem.py new file mode 100644 index 0000000..c80df8f --- /dev/null +++ b/shop/migrations/0009_order_orderitem.py @@ -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')), + ], + ), + ] diff --git a/shop/migrations/0010_guestuser.py b/shop/migrations/0010_guestuser.py new file mode 100644 index 0000000..441e980 --- /dev/null +++ b/shop/migrations/0010_guestuser.py @@ -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)), + ], + ), + ] diff --git a/shop/migrations/0011_order_guest_user.py b/shop/migrations/0011_order_guest_user.py new file mode 100644 index 0000000..41eeb73 --- /dev/null +++ b/shop/migrations/0011_order_guest_user.py @@ -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'), + ), + ] diff --git a/shop/migrations/0012_order_payment_status_and_more.py b/shop/migrations/0012_order_payment_status_and_more.py new file mode 100644 index 0000000..e3153cc --- /dev/null +++ b/shop/migrations/0012_order_payment_status_and_more.py @@ -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), + ), + ] diff --git a/shop/migrations/0013_color_colorhex_alter_color_name.py b/shop/migrations/0013_color_colorhex_alter_color_name.py new file mode 100644 index 0000000..abe2177 --- /dev/null +++ b/shop/migrations/0013_color_colorhex_alter_color_name.py @@ -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), + ), + ] diff --git a/shop/migrations/0014_alter_size_name.py b/shop/migrations/0014_alter_size_name.py new file mode 100644 index 0000000..d72281a --- /dev/null +++ b/shop/migrations/0014_alter_size_name.py @@ -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), + ), + ] diff --git a/shop/migrations/0015_alter_product_image.py b/shop/migrations/0015_alter_product_image.py new file mode 100644 index 0000000..22e30bc --- /dev/null +++ b/shop/migrations/0015_alter_product_image.py @@ -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), + ), + ] diff --git a/shop/migrations/0016_order_webhook_processed.py b/shop/migrations/0016_order_webhook_processed.py new file mode 100644 index 0000000..06fe45d --- /dev/null +++ b/shop/migrations/0016_order_webhook_processed.py @@ -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), + ), + ] diff --git a/shop/migrations/0017_order_stripe_mode.py b/shop/migrations/0017_order_stripe_mode.py new file mode 100644 index 0000000..8a018a9 --- /dev/null +++ b/shop/migrations/0017_order_stripe_mode.py @@ -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), + ), + ] diff --git a/shop/migrations/0018_color_secondary_hex_color.py b/shop/migrations/0018_color_secondary_hex_color.py new file mode 100644 index 0000000..d1df904 --- /dev/null +++ b/shop/migrations/0018_color_secondary_hex_color.py @@ -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), + ), + ] diff --git a/shop/migrations/0019_product_description.py b/shop/migrations/0019_product_description.py new file mode 100644 index 0000000..5bc157e --- /dev/null +++ b/shop/migrations/0019_product_description.py @@ -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), + ), + ] diff --git a/shop/migrations/0020_product_sku.py b/shop/migrations/0020_product_sku.py new file mode 100644 index 0000000..a216205 --- /dev/null +++ b/shop/migrations/0020_product_sku.py @@ -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), + ), + ] diff --git a/shop/migrations/0021_alter_color_name_alter_product_sku.py b/shop/migrations/0021_alter_color_name_alter_product_sku.py new file mode 100644 index 0000000..9093cd4 --- /dev/null +++ b/shop/migrations/0021_alter_color_name_alter_product_sku.py @@ -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), + ), + ] diff --git a/shop/migrations/__init__.py b/shop/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shop/models.py b/shop/models.py new file mode 100644 index 0000000..508483e --- /dev/null +++ b/shop/models.py @@ -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 diff --git a/shop/signals.py b/shop/signals.py new file mode 100644 index 0000000..83be688 --- /dev/null +++ b/shop/signals.py @@ -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 + """ diff --git a/shop/static/shop/css/shop.css b/shop/static/shop/css/shop.css new file mode 100644 index 0000000..9d7e92c --- /dev/null +++ b/shop/static/shop/css/shop.css @@ -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; +} diff --git a/shop/static/shop/images/products/PS_K473_WHITE.png.avif b/shop/static/shop/images/products/PS_K473_WHITE.png.avif new file mode 100644 index 0000000..d9357bb Binary files /dev/null and b/shop/static/shop/images/products/PS_K473_WHITE.png.avif differ diff --git a/shop/static/shop/images/products/PS_K476_WHITE.png.avif b/shop/static/shop/images/products/PS_K476_WHITE.png.avif new file mode 100644 index 0000000..7083a76 Binary files /dev/null and b/shop/static/shop/images/products/PS_K476_WHITE.png.avif differ diff --git a/shop/static/shop/images/products/PS_PA1030_WHITE-SPORTYNAVY.png.avif b/shop/static/shop/images/products/PS_PA1030_WHITE-SPORTYNAVY.png.avif new file mode 100644 index 0000000..fc90cf1 Binary files /dev/null and b/shop/static/shop/images/products/PS_PA1030_WHITE-SPORTYNAVY.png.avif differ diff --git a/shop/static/shop/images/products/PS_PA1031_WHITE-SPORTYNAVY.png.avif b/shop/static/shop/images/products/PS_PA1031_WHITE-SPORTYNAVY.png.avif new file mode 100644 index 0000000..5cb8645 Binary files /dev/null and b/shop/static/shop/images/products/PS_PA1031_WHITE-SPORTYNAVY.png.avif differ diff --git a/shop/static/shop/images/products/PS_PA4030_SPORTYNAVY-WHITE.png.avif b/shop/static/shop/images/products/PS_PA4030_SPORTYNAVY-WHITE.png.avif new file mode 100644 index 0000000..9c646a3 Binary files /dev/null and b/shop/static/shop/images/products/PS_PA4030_SPORTYNAVY-WHITE.png.avif differ diff --git a/shop/static/shop/images/products/PS_PA4031_WHITE-SPORTYNAVY.png.avif b/shop/static/shop/images/products/PS_PA4031_WHITE-SPORTYNAVY.png.avif new file mode 100644 index 0000000..808f317 Binary files /dev/null and b/shop/static/shop/images/products/PS_PA4031_WHITE-SPORTYNAVY.png.avif differ diff --git a/shop/static/shop/images/products/hat.jpg b/shop/static/shop/images/products/hat.jpg new file mode 100644 index 0000000..3a67a20 Binary files /dev/null and b/shop/static/shop/images/products/hat.jpg differ diff --git a/shop/static/shop/images/products/tshirt_h.png b/shop/static/shop/images/products/tshirt_h.png new file mode 100644 index 0000000..b82c8c3 Binary files /dev/null and b/shop/static/shop/images/products/tshirt_h.png differ diff --git a/shop/stripe_utils.py b/shop/stripe_utils.py new file mode 100644 index 0000000..99fb354 --- /dev/null +++ b/shop/stripe_utils.py @@ -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 {}, + ) diff --git a/shop/templates/shop/cart.html b/shop/templates/shop/cart.html new file mode 100644 index 0000000..af2d6ec --- /dev/null +++ b/shop/templates/shop/cart.html @@ -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 %} + +{% if STRIPE_IS_TEST_MODE %} +
+ ⚠️ Test Mode: Stripe is currently in test mode. No real payments will be processed. +
+ Use test card: 4242 4242 4242 4242 with any future date and any CVC. +
+{% endif %} + +
+
+

Votre panier

+
+ {% 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 %} + +
+ + {% if user.is_authenticated %} + + + {% else %} + + Passer la commande +
+

Connectez-vous pour un paiement plus rapide.

+ Se connecter +
+ {% endif %} +
+ {% if display_data.items %} + Vider le panier + {% endif %} + + + {% if settings.DEBUG %} +
+

Debug Payment Simulation

+ +
+ {% endif %} + +
+ {% else %} +

Votre panier est vide.

+ Retourner à la boutique + {% endif %} +
+
+
+ +{% if user.is_authenticated and display_data.items %} + + + +{% endif %} +{% endblock %} diff --git a/shop/templates/shop/checkout.html b/shop/templates/shop/checkout.html new file mode 100644 index 0000000..0956084 --- /dev/null +++ b/shop/templates/shop/checkout.html @@ -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 %} + + +

Validation de la commande

+
+
+
+ {% if request.user.is_authenticated %} +
+

Vous êtes déjà connecté en tant que {{ request.user.email }}.

+ Passer à la commande +
+ {% else %} +
+

Finaliser votre commande

+

Vous n'êtes pas connecté. Veuillez choisir une option :

+
+ +
+
+ {% csrf_token %} +
+ {{ form.as_p }} +
+
+ +
+
+
+ +
+

Ou connectez-vous si vous avez déjà un compte.

+

Pas encore de compte ? Créez-en un maintenant

+
+ {% endif %} +
+
+
+{% endblock %} diff --git a/shop/templates/shop/partials/order_items_display.html b/shop/templates/shop/partials/order_items_display.html new file mode 100644 index 0000000..fabb5f9 --- /dev/null +++ b/shop/templates/shop/partials/order_items_display.html @@ -0,0 +1,60 @@ + + + {% for item in items %} + + + {% if item.product_description %} + + {% endif %} + + + + {% if edit_mode %} + + {% endif %} + + {% endfor %} + + + + + + + + {% if edit_mode %} + + {% endif %} + + +
{{ item.product_title }}{{ item.product_description }} +
+
+ {{ item.color_name }} | {{ item.size_name }} +
+
+ {% if edit_mode %} +
+
+ {% csrf_token %} + + + {{ item.quantity }} + +
+
+ {% else %} + x {{ item.quantity }} + {% endif %} +
{{ item.total_price }} € +
+ {% csrf_token %} + + +
+
{{ total_quantity }} produit(s){{ total_price }} €
diff --git a/shop/templates/shop/payment.html b/shop/templates/shop/payment.html new file mode 100644 index 0000000..b9c9db4 --- /dev/null +++ b/shop/templates/shop/payment.html @@ -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 %} +
+ ⚠️ Test Mode: Stripe is currently in test mode. No real payments will be processed. +
+ Use test card: 4242 4242 4242 4242 with any future date and any CVC. +
+{% endif %} + + +
+
+

Résumé de votre commande

+
+ {% 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 %} + + +
+ +
+
+
+
+ + + + +{% endblock %} diff --git a/shop/templates/shop/payment_cancel.html b/shop/templates/shop/payment_cancel.html new file mode 100644 index 0000000..b514012 --- /dev/null +++ b/shop/templates/shop/payment_cancel.html @@ -0,0 +1,35 @@ + +{% extends 'tournaments/base.html' %} + +{% block head_title %}La Boutique{% endblock %} +{% block first_title %}Padel Club{% endblock %} +{% block second_title %}La Boutique{% endblock %} + +{% block content %} + + + +
+
+

Paiement

+
+

Le paiement a été annulé

+

Votre commande n'a pas été finalisée car le paiement a été annulé.

+ + +
+
+
+{% endblock %} diff --git a/shop/templates/shop/payment_success.html b/shop/templates/shop/payment_success.html new file mode 100644 index 0000000..75418a1 --- /dev/null +++ b/shop/templates/shop/payment_success.html @@ -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 %} + + +
+
+

Paiement réussi

+
+

Merci pour votre commande !

+

Votre paiement a été traité avec succès.

+

Numéro de commande: {{ order.id }}

+ + +

Détails de la commande

+ {% 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 %} + + +
+
+
+{% endblock %} diff --git a/shop/templates/shop/product_item.html b/shop/templates/shop/product_item.html new file mode 100644 index 0000000..e00c05e --- /dev/null +++ b/shop/templates/shop/product_item.html @@ -0,0 +1,128 @@ +
+
+ {% if product.image %} + {{ product.title }} + {% else %} +
No Image Available
+ {% endif %} +
+ {% csrf_token %} +
+
+ {{ product.title }} +
+ {% if product.description %} +
+ {{ product.description }} +
+ {% endif %} + +
{{ product.price }} €
+
{{ product.colors.all.0.name }}
+
+ {% if product.colors.exists %} + +
+ {% for color in product.colors.all %} +
+ {% endfor %} +
+ {% endif %} +
+ + +
+ {% if product.sizes.exists %} + {% if product.sizes.all|length == 1 %} + +
{{ product.sizes.all.0.name }}
+ + {% else %} + + {% endif %} + {% endif %} +
+
+
+ + + 1 + +
+
+
{{ product.price }}
+
+ +
+
+
+ + diff --git a/shop/templates/shop/product_list.html b/shop/templates/shop/product_list.html new file mode 100644 index 0000000..0f47ce3 --- /dev/null +++ b/shop/templates/shop/product_list.html @@ -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 %} + + + + + +{% if products %} +
+ {% for product in products %} + {% include "shop/product_item.html" with product=product %} + {% endfor %} +
+{% else %} +

No products available.

+{% endif %} + +{% endblock %} diff --git a/shop/tests.py b/shop/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/shop/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/shop/urls.py b/shop/urls.py new file mode 100644 index 0000000..7145f82 --- /dev/null +++ b/shop/urls.py @@ -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//', views.add_to_cart_view, name='add_to_cart'), + path('cart/update//', views.update_cart_view, name='update_cart'), + path('cart/remove//', 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//', views.payment, name='payment'), + path('payment/success//', views.payment_success, name='payment_success'), + path('payment/cancel//', 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'), + +] diff --git a/shop/views.py b/shop/views.py new file mode 100644 index 0000000..dcf6089 --- /dev/null +++ b/shop/views.py @@ -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}") diff --git a/tournaments/admin.py b/tournaments/admin.py index 2335c25..6bb4567 100644 --- a/tournaments/admin.py +++ b/tournaments/admin.py @@ -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'] diff --git a/tournaments/custom_views.py b/tournaments/custom_views.py new file mode 100644 index 0000000..01d54bb --- /dev/null +++ b/tournaments/custom_views.py @@ -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) diff --git a/tournaments/middleware.py b/tournaments/middleware.py new file mode 100644 index 0000000..ad517b2 --- /dev/null +++ b/tournaments/middleware.py @@ -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 diff --git a/tournaments/models/group_stage.py b/tournaments/models/group_stage.py index 5e7b154..aca47e8 100644 --- a/tournaments/models/group_stage.py +++ b/tournaments/models/group_stage.py @@ -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 diff --git a/tournaments/models/team_registration.py b/tournaments/models/team_registration.py index f4abfd0..763add7 100644 --- a/tournaments/models/team_registration.py +++ b/tournaments/models/team_registration.py @@ -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 "" diff --git a/tournaments/models/team_score.py b/tournaments/models/team_score.py index 078f015..8be0b46 100644 --- a/tournaments/models/team_score.py +++ b/tournaments/models/team_score.py @@ -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 diff --git a/tournaments/models/tournament.py b/tournaments/models/tournament.py index 0024b6b..924d53a 100644 --- a/tournaments/models/tournament.py +++ b/tournaments/models/tournament.py @@ -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 = "" diff --git a/tournaments/templates/register_tournament.html b/tournaments/templates/register_tournament.html index 0210214..59f9d6d 100644 --- a/tournaments/templates/register_tournament.html +++ b/tournaments/templates/register_tournament.html @@ -109,9 +109,21 @@ {% endif %}
- {% for message in messages %} -
{{ message }}
- {% endfor %} + {% if form.errors %} +
+ {% if form.non_field_errors %} + {% for error in form.non_field_errors %} +

{{ error }}

+ {% endfor %} + {% endif %} + + {% for field in form %} + {% for error in field.errors %} +

{{ error }}

+ {% endfor %} + {% endfor %} +
+ {% endif %}