commit
ebcbcffc05
@ -0,0 +1,37 @@ |
||||
from django.contrib import admin |
||||
from .models import Product, Color, Size, Order, OrderItem, GuestUser |
||||
|
||||
@admin.register(Product) |
||||
class ProductAdmin(admin.ModelAdmin): |
||||
list_display = ("title", "ordering_value", "price", "cut") |
||||
|
||||
@admin.register(Color) |
||||
class ColorAdmin(admin.ModelAdmin): |
||||
list_display = ("name",) |
||||
|
||||
@admin.register(Size) |
||||
class SizeAdmin(admin.ModelAdmin): |
||||
list_display = ("name",) |
||||
|
||||
class OrderItemInline(admin.TabularInline): |
||||
model = OrderItem |
||||
extra = 0 |
||||
readonly_fields = ('product', 'quantity', 'color', 'size', 'price') |
||||
|
||||
@admin.register(Order) |
||||
class OrderAdmin(admin.ModelAdmin): |
||||
list_display = ('id', 'date_ordered', 'status', 'total_price') |
||||
inlines = [OrderItemInline] |
||||
|
||||
class GuestUserOrderInline(admin.TabularInline): |
||||
model = Order |
||||
extra = 0 |
||||
readonly_fields = ('date_ordered', 'total_price') |
||||
can_delete = False |
||||
show_change_link = True |
||||
exclude = ('user',) # Exclude the user field from the inline display |
||||
|
||||
@admin.register(GuestUser) |
||||
class GuestUserAdmin(admin.ModelAdmin): |
||||
list_display = ('email', 'phone') |
||||
inlines = [GuestUserOrderInline] |
||||
@ -0,0 +1,8 @@ |
||||
from django.apps import AppConfig |
||||
|
||||
|
||||
class ShopConfig(AppConfig): |
||||
default_auto_field = 'django.db.models.BigAutoField' |
||||
name = 'shop' |
||||
def ready(self): |
||||
import shop.signals # Import signals to ensure they're connected |
||||
@ -0,0 +1,76 @@ |
||||
from .models import CartItem, Product, Color, Size |
||||
|
||||
def get_or_create_cart_id(request): |
||||
"""Get the cart ID from the session or create a new one""" |
||||
if 'cart_id' not in request.session: |
||||
request.session['cart_id'] = request.session.session_key or request.session.create() |
||||
return request.session['cart_id'] |
||||
|
||||
def get_cart_items(request): |
||||
"""Get all cart items for the current session""" |
||||
cart_id = get_or_create_cart_id(request) |
||||
return CartItem.objects.filter(session_id=cart_id) |
||||
|
||||
def add_to_cart(request, product_id, quantity=1, color_id=None, size_id=None): |
||||
"""Add a product to the cart or update its quantity""" |
||||
product = Product.objects.get(id=product_id) |
||||
cart_id = get_or_create_cart_id(request) |
||||
|
||||
color = Color.objects.get(id=color_id) if color_id else None |
||||
size = Size.objects.get(id=size_id) if size_id else None |
||||
|
||||
try: |
||||
# Try to get existing cart item with the same product, color, and size |
||||
cart_item = CartItem.objects.get( |
||||
product=product, |
||||
session_id=cart_id, |
||||
color=color, |
||||
size=size |
||||
) |
||||
cart_item.quantity += quantity |
||||
cart_item.save() |
||||
except CartItem.DoesNotExist: |
||||
# Create new cart item |
||||
cart_item = CartItem.objects.create( |
||||
product=product, |
||||
quantity=quantity, |
||||
session_id=cart_id, |
||||
color=color, |
||||
size=size |
||||
) |
||||
|
||||
return cart_item |
||||
|
||||
def remove_from_cart(request, product_id): |
||||
"""Remove a product from the cart""" |
||||
cart_id = get_or_create_cart_id(request) |
||||
CartItem.objects.filter(product_id=product_id, session_id=cart_id).delete() |
||||
|
||||
def update_cart_item(request, product_id, quantity): |
||||
"""Update the quantity of a cart item""" |
||||
cart_id = get_or_create_cart_id(request) |
||||
cart_item = CartItem.objects.get(product_id=product_id, session_id=cart_id) |
||||
|
||||
if quantity > 0: |
||||
cart_item.quantity = quantity |
||||
cart_item.save() |
||||
else: |
||||
cart_item.delete() |
||||
|
||||
def get_cart_total(request): |
||||
"""Calculate the total price of all items in the cart""" |
||||
return sum(item.product.price * item.quantity for item in get_cart_items(request)) |
||||
|
||||
def clear_cart(request): |
||||
"""Clear the cart""" |
||||
cart_id = get_or_create_cart_id(request) |
||||
CartItem.objects.filter(session_id=cart_id).delete() |
||||
|
||||
# Add this function to your cart.py file |
||||
def get_cart_item(request, item_id): |
||||
"""Get a specific cart item by its ID""" |
||||
cart_id = get_or_create_cart_id(request) |
||||
try: |
||||
return CartItem.objects.get(id=item_id, session_id=cart_id) |
||||
except CartItem.DoesNotExist: |
||||
raise Exception("Cart item not found") |
||||
@ -0,0 +1,10 @@ |
||||
from django.conf import settings |
||||
|
||||
def stripe_context(request): |
||||
"""Add Stripe-related context variables to templates""" |
||||
stripe_mode = getattr(settings, 'STRIPE_MODE', 'test') |
||||
return { |
||||
'STRIPE_PUBLISHABLE_KEY': settings.STRIPE_PUBLISHABLE_KEY, |
||||
'STRIPE_MODE': stripe_mode, |
||||
'STRIPE_IS_TEST_MODE': stripe_mode == 'test', |
||||
} |
||||
@ -0,0 +1,5 @@ |
||||
from django import forms |
||||
|
||||
class GuestCheckoutForm(forms.Form): |
||||
email = forms.EmailField(required=True) |
||||
phone = forms.CharField(max_length=20, required=True, label="Téléphone portable") |
||||
@ -0,0 +1,180 @@ |
||||
from django.core.management.base import BaseCommand |
||||
from shop.models import Color, Size, Product |
||||
from django.conf import settings |
||||
|
||||
class Command(BaseCommand): |
||||
help = 'Creates initial data for the shop' |
||||
|
||||
def handle(self, *args, **kwargs): |
||||
# Create colors |
||||
self.stdout.write('Creating colors...') |
||||
colors = [ |
||||
{'name': 'Noir', 'hex': '#333333', 'secondary_hex': None}, |
||||
{'name': 'Noir / Gris Foncé Chiné', 'hex': '#000000', 'secondary_hex': '#4D4D4D'}, |
||||
{'name': 'Bleu Sport', 'hex': '#112B44', 'secondary_hex': None}, |
||||
{'name': 'Bleu Sport / Bleu Sport Chiné', 'hex': '#112B44', 'secondary_hex': '#16395A'}, |
||||
{'name': 'Bleu Sport / Blanc', 'hex': '#112B44', 'secondary_hex': '#FFFFFF'}, |
||||
{'name': 'Blanc / Gris Clair', 'hex': '#FFFFFF', 'secondary_hex': '#D3D3D3'}, |
||||
{'name': 'Fuchsia', 'hex': '#C1366B', 'secondary_hex': None}, |
||||
{'name': 'Corail / Noir', 'hex': '#FF7F50', 'secondary_hex': '#000000'}, |
||||
{'name': 'Blanc / Bleu Sport', 'hex': '#FFFFFF', 'secondary_hex': '#112B44'}, |
||||
{'name': 'Blanc', 'hex': '#FFFFFF', 'secondary_hex': None}, |
||||
] |
||||
|
||||
color_objects = {} |
||||
for color_data in colors: |
||||
color, created = Color.objects.get_or_create( |
||||
name=color_data['name'], |
||||
defaults={ |
||||
'colorHex': color_data['hex'], |
||||
'secondary_hex_color': color_data['secondary_hex'] |
||||
} |
||||
) |
||||
color_objects[color_data['name']] = color |
||||
if created: |
||||
self.stdout.write(f'Created color: {color_data["name"]}') |
||||
else: |
||||
# Update existing colors with secondary color if needed |
||||
if color.colorHex != color_data['hex'] or color.secondary_hex_color != color_data['secondary_hex']: |
||||
color.colorHex = color_data['hex'] |
||||
color.secondary_hex_color = color_data['secondary_hex'] |
||||
color.save() |
||||
self.stdout.write(f'Updated color: {color_data["name"]}') |
||||
else: |
||||
self.stdout.write(f'Color already exists: {color_data["name"]}') |
||||
|
||||
# Create sizes |
||||
self.stdout.write('Creating sizes...') |
||||
sizes = ['Taille Unique', 'XS', 'S', 'M', 'L', 'XL', 'XXL', '3XL', '4XL'] |
||||
|
||||
size_objects = {} |
||||
for name in sizes: |
||||
size, created = Size.objects.get_or_create(name=name) |
||||
size_objects[name] = size |
||||
if created: |
||||
self.stdout.write(f'Created size: {name}') |
||||
else: |
||||
self.stdout.write(f'Size already exists: {name}') |
||||
|
||||
# Create products |
||||
self.stdout.write('Creating products...') |
||||
products = [ |
||||
{ |
||||
'sku': 'PC001', |
||||
'title': 'Padel Club Cap', |
||||
'description': 'Casquette logo centre', |
||||
'price': 25.00, |
||||
'ordering_value': 1, |
||||
'cut': 0, # Unisex |
||||
'colors': ['Blanc', 'Bleu Sport', 'Noir'], |
||||
'sizes': ['Taille Unique'], |
||||
'image_filename': 'hat.jpg' |
||||
}, |
||||
{ |
||||
'sku': 'PC002', |
||||
'title': 'Padel Club Hoodie Femme', |
||||
'description': 'Hoodie femme logo cœur et dos', |
||||
'price': 50.00, |
||||
'ordering_value': 10, |
||||
'cut': 1, |
||||
'colors': ['Blanc', 'Bleu Sport', 'Noir', 'Fuchsia'], |
||||
'sizes': ['XS', 'S', 'M', 'L', 'XL', 'XXL'], |
||||
'image_filename': 'PS_K473_WHITE.png.avif' |
||||
}, |
||||
{ |
||||
'sku': 'PC003', |
||||
'title': 'Padel Club Hoodie Homme', |
||||
'description': 'Hoodie homme logo cœur et dos', |
||||
'price': 50.00, |
||||
'ordering_value': 11, |
||||
'cut': 2, |
||||
'colors': ['Blanc', 'Bleu Sport', 'Noir', 'Fuchsia'], |
||||
'sizes': ['XS', 'S', 'M', 'L', 'XL', 'XXL', '3XL', '4XL'], |
||||
'image_filename': 'PS_K476_WHITE.png.avif' |
||||
}, |
||||
{ |
||||
'sku': 'PC004', |
||||
'title': 'Débardeur Femme', |
||||
'description': 'Débardeur femme avec logo coeur.', |
||||
'price': 25.00, |
||||
'ordering_value': 20, |
||||
'cut': 1, # Women |
||||
'colors': ['Blanc / Bleu Sport', 'Noir', 'Noir / Gris Foncé Chiné'], |
||||
'sizes': ['XS', 'S', 'M', 'L', 'XL'], |
||||
'image_filename': 'PS_PA4031_WHITE-SPORTYNAVY.png.avif' |
||||
}, |
||||
{ |
||||
'sku': 'PC005', |
||||
'title': 'Jupe bicolore Femme', |
||||
'description': 'Avec short intégré logo jambe (sauf corail)', |
||||
'price': 30.00, |
||||
'ordering_value': 30, |
||||
'cut': 1, # Women |
||||
'colors': ['Blanc / Bleu Sport', 'Bleu Sport / Blanc', 'Corail / Noir', 'Noir / Gris Foncé Chiné'], |
||||
'sizes': ['XS', 'S', 'M', 'L', 'XL'], |
||||
'image_filename': 'PS_PA1031_WHITE-SPORTYNAVY.png.avif' |
||||
}, |
||||
{ |
||||
'sku': 'PC006', |
||||
'title': 'T-shirt Bicolore Homme', |
||||
'description': 'T-shirt bicolore avec logo coeur.', |
||||
'price': 25.00, |
||||
'ordering_value': 40, |
||||
'cut': 2, # Men |
||||
'colors': ['Blanc / Bleu Sport', 'Noir', 'Noir / Gris Foncé Chiné'], |
||||
'sizes': ['S', 'M', 'L', 'XL', 'XXL', '3XL'], |
||||
'image_filename': 'tshirt_h.png' |
||||
}, |
||||
{ |
||||
'sku': 'PC007', |
||||
'title': 'Short Bicolore Homme', |
||||
'description': 'Short bicolore avec logo jambe.', |
||||
'price': 30.00, |
||||
'ordering_value': 50, |
||||
'cut': 2, # Men |
||||
'colors': ['Blanc / Bleu Sport', 'Noir', 'Noir / Gris Foncé Chiné'], |
||||
'sizes': ['S', 'M', 'L', 'XL', 'XXL', '3XL'], |
||||
'image_filename': 'PS_PA1030_WHITE-SPORTYNAVY.png.avif' |
||||
}, |
||||
] |
||||
|
||||
for product_data in products: |
||||
product, created = Product.objects.update_or_create( |
||||
sku=product_data['sku'], |
||||
defaults={ |
||||
'title': product_data['title'], |
||||
'description': product_data.get('description', ''), |
||||
'price': product_data['price'], |
||||
'ordering_value': product_data['ordering_value'], |
||||
'cut': product_data['cut'] |
||||
} |
||||
) |
||||
|
||||
if created: |
||||
self.stdout.write(f'Created product: {product_data["sku"]} - {product_data["title"]}') |
||||
else: |
||||
self.stdout.write(f'Updated product: {product_data["sku"]} - {product_data["title"]}') |
||||
|
||||
# Handle the image path |
||||
if 'image_filename' in product_data and product_data['image_filename']: |
||||
image_path = f"{settings.STATIC_URL}shop/images/products/{product_data['image_filename']}" |
||||
if product.image != image_path: |
||||
product.image = image_path |
||||
product.save() |
||||
self.stdout.write(f'Updated image path to "{image_path}" for: {product_data["sku"]}') |
||||
|
||||
# Update colors - first clear existing then add new ones |
||||
product.colors.clear() |
||||
for color_name in product_data['colors']: |
||||
if color_name in color_objects: |
||||
product.colors.add(color_objects[color_name]) |
||||
self.stdout.write(f'Updated colors for: {product_data["sku"]}') |
||||
|
||||
# Update sizes - first clear existing then add new ones |
||||
product.sizes.clear() |
||||
for size_name in product_data['sizes']: |
||||
if size_name in size_objects: |
||||
product.sizes.add(size_objects[size_name]) |
||||
self.stdout.write(f'Updated sizes for: {product_data["sku"]}') |
||||
|
||||
self.stdout.write(self.style.SUCCESS('Successfully created/updated shop data')) |
||||
@ -0,0 +1,53 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-17 17:27 |
||||
|
||||
from django.conf import settings |
||||
from django.db import migrations, models |
||||
import django.db.models.deletion |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
initial = True |
||||
|
||||
dependencies = [ |
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.CreateModel( |
||||
name='Color', |
||||
fields=[ |
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
('name', models.CharField(choices=[('Red', 'Red'), ('Blue', 'Blue'), ('Green', 'Green'), ('Black', 'Black'), ('White', 'White')], max_length=10, unique=True)), |
||||
], |
||||
), |
||||
migrations.CreateModel( |
||||
name='Size', |
||||
fields=[ |
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
('name', models.CharField(choices=[('S', 'Small'), ('M', 'Medium'), ('L', 'Large'), ('XL', 'X-Large')], max_length=5, unique=True)), |
||||
], |
||||
), |
||||
migrations.CreateModel( |
||||
name='Product', |
||||
fields=[ |
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
('title', models.CharField(max_length=200)), |
||||
('image', models.ImageField(blank=True, null=True, upload_to='products/')), |
||||
('price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10)), |
||||
('colors', models.ManyToManyField(blank=True, related_name='products', to='shop.color')), |
||||
('sizes', models.ManyToManyField(blank=True, related_name='products', to='shop.size')), |
||||
], |
||||
), |
||||
migrations.CreateModel( |
||||
name='CartItem', |
||||
fields=[ |
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
('quantity', models.PositiveIntegerField(default=1)), |
||||
('session_id', models.CharField(blank=True, max_length=255, null=True)), |
||||
('date_added', models.DateTimeField(auto_now_add=True)), |
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shop.product')), |
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), |
||||
], |
||||
), |
||||
] |
||||
@ -0,0 +1,39 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-17 17:31 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0001_initial'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.RenameModel( |
||||
old_name='Color', |
||||
new_name='ProductColor', |
||||
), |
||||
migrations.RenameModel( |
||||
old_name='Size', |
||||
new_name='ProductSize', |
||||
), |
||||
migrations.RemoveField( |
||||
model_name='product', |
||||
name='colors', |
||||
), |
||||
migrations.RemoveField( |
||||
model_name='product', |
||||
name='sizes', |
||||
), |
||||
migrations.AddField( |
||||
model_name='product', |
||||
name='product_colors', |
||||
field=models.ManyToManyField(blank=True, to='shop.productcolor'), |
||||
), |
||||
migrations.AddField( |
||||
model_name='product', |
||||
name='product_sizes', |
||||
field=models.ManyToManyField(blank=True, to='shop.productsize'), |
||||
), |
||||
] |
||||
@ -0,0 +1,39 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-17 17:33 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0002_rename_color_productcolor_rename_size_productsize_and_more'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.RenameModel( |
||||
old_name='ProductColor', |
||||
new_name='Color', |
||||
), |
||||
migrations.RenameModel( |
||||
old_name='ProductSize', |
||||
new_name='Size', |
||||
), |
||||
migrations.RemoveField( |
||||
model_name='product', |
||||
name='product_colors', |
||||
), |
||||
migrations.RemoveField( |
||||
model_name='product', |
||||
name='product_sizes', |
||||
), |
||||
migrations.AddField( |
||||
model_name='product', |
||||
name='colors', |
||||
field=models.ManyToManyField(blank=True, related_name='products', to='shop.color'), |
||||
), |
||||
migrations.AddField( |
||||
model_name='product', |
||||
name='sizes', |
||||
field=models.ManyToManyField(blank=True, related_name='products', to='shop.size'), |
||||
), |
||||
] |
||||
@ -0,0 +1,24 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-18 08:00 |
||||
|
||||
from django.db import migrations, models |
||||
import django.db.models.deletion |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0003_rename_productcolor_color_rename_productsize_size_and_more'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='cartitem', |
||||
name='color', |
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.color'), |
||||
), |
||||
migrations.AddField( |
||||
model_name='cartitem', |
||||
name='size', |
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.size'), |
||||
), |
||||
] |
||||
@ -0,0 +1,23 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-18 08:59 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0004_cartitem_color_cartitem_size'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterField( |
||||
model_name='color', |
||||
name='name', |
||||
field=models.CharField(choices=[('Red', 'Red'), ('Blue', 'Blue'), ('Green', 'Green'), ('Black', 'Black'), ('White', 'White')], max_length=20, unique=True), |
||||
), |
||||
migrations.AlterField( |
||||
model_name='size', |
||||
name='name', |
||||
field=models.CharField(choices=[('S', 'Small'), ('M', 'Medium'), ('L', 'Large'), ('XL', 'X-Large'), ('SINGLE', 'Unique')], max_length=20, unique=True), |
||||
), |
||||
] |
||||
@ -0,0 +1,22 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-18 13:46 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0005_alter_color_name_alter_size_name'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterModelOptions( |
||||
name='product', |
||||
options={'ordering': ['order']}, |
||||
), |
||||
migrations.AddField( |
||||
model_name='product', |
||||
name='order', |
||||
field=models.IntegerField(default=0), |
||||
), |
||||
] |
||||
@ -0,0 +1,18 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-18 13:49 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0006_alter_product_options_product_order'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='product', |
||||
name='cut', |
||||
field=models.IntegerField(choices=[(1, 'Women'), (2, 'Men'), (3, 'Kids')], default=2), |
||||
), |
||||
] |
||||
@ -0,0 +1,22 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-18 14:24 |
||||
|
||||
from django.db import migrations |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0007_product_cut'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterModelOptions( |
||||
name='product', |
||||
options={'ordering': ['ordering_value', 'cut']}, |
||||
), |
||||
migrations.RenameField( |
||||
model_name='product', |
||||
old_name='order', |
||||
new_name='ordering_value', |
||||
), |
||||
] |
||||
@ -0,0 +1,38 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-18 14:36 |
||||
|
||||
from django.conf import settings |
||||
from django.db import migrations, models |
||||
import django.db.models.deletion |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
||||
('shop', '0008_alter_product_options_and_more'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.CreateModel( |
||||
name='Order', |
||||
fields=[ |
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
('date_ordered', models.DateTimeField(auto_now_add=True)), |
||||
('status', models.CharField(choices=[('PENDING', 'Pending'), ('PAID', 'Paid'), ('SHIPPED', 'Shipped'), ('DELIVERED', 'Delivered'), ('CANCELED', 'Canceled')], default='PENDING', max_length=20)), |
||||
('total_price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10)), |
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), |
||||
], |
||||
), |
||||
migrations.CreateModel( |
||||
name='OrderItem', |
||||
fields=[ |
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
('quantity', models.PositiveIntegerField(default=1)), |
||||
('price', models.DecimalField(decimal_places=2, max_digits=10)), |
||||
('color', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.color')), |
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='shop.order')), |
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shop.product')), |
||||
('size', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.size')), |
||||
], |
||||
), |
||||
] |
||||
@ -0,0 +1,21 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-18 14:50 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0009_order_orderitem'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.CreateModel( |
||||
name='GuestUser', |
||||
fields=[ |
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), |
||||
('email', models.EmailField(max_length=254)), |
||||
('phone', models.CharField(max_length=20)), |
||||
], |
||||
), |
||||
] |
||||
@ -0,0 +1,19 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-18 17:59 |
||||
|
||||
from django.db import migrations, models |
||||
import django.db.models.deletion |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0010_guestuser'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='order', |
||||
name='guest_user', |
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='shop.guestuser'), |
||||
), |
||||
] |
||||
@ -0,0 +1,28 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-19 12:45 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0011_order_guest_user'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='order', |
||||
name='payment_status', |
||||
field=models.CharField(choices=[('UNPAID', 'Unpaid'), ('PAID', 'Paid'), ('FAILED', 'Failed')], default='UNPAID', max_length=20), |
||||
), |
||||
migrations.AddField( |
||||
model_name='order', |
||||
name='stripe_checkout_session_id', |
||||
field=models.CharField(blank=True, max_length=255, null=True), |
||||
), |
||||
migrations.AddField( |
||||
model_name='order', |
||||
name='stripe_payment_intent_id', |
||||
field=models.CharField(blank=True, max_length=255, null=True), |
||||
), |
||||
] |
||||
@ -0,0 +1,23 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-19 19:00 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0012_order_payment_status_and_more'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='color', |
||||
name='colorHex', |
||||
field=models.CharField(default='#FFFFFF', help_text='Color in hex format (e.g. #FF0000)', max_length=7), |
||||
), |
||||
migrations.AlterField( |
||||
model_name='color', |
||||
name='name', |
||||
field=models.CharField(max_length=20, unique=True), |
||||
), |
||||
] |
||||
@ -0,0 +1,18 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-20 09:57 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0013_color_colorhex_alter_color_name'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterField( |
||||
model_name='size', |
||||
name='name', |
||||
field=models.CharField(max_length=20, unique=True), |
||||
), |
||||
] |
||||
@ -0,0 +1,18 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-20 17:30 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0014_alter_size_name'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterField( |
||||
model_name='product', |
||||
name='image', |
||||
field=models.CharField(blank=True, max_length=200, null=True), |
||||
), |
||||
] |
||||
@ -0,0 +1,18 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-21 05:58 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0015_alter_product_image'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='order', |
||||
name='webhook_processed', |
||||
field=models.BooleanField(default=False), |
||||
), |
||||
] |
||||
@ -0,0 +1,18 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-21 08:01 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0016_order_webhook_processed'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='order', |
||||
name='stripe_mode', |
||||
field=models.CharField(choices=[('test', 'Test Mode'), ('live', 'Live Mode')], default='test', max_length=10), |
||||
), |
||||
] |
||||
@ -0,0 +1,18 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-21 12:14 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0017_order_stripe_mode'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='color', |
||||
name='secondary_hex_color', |
||||
field=models.CharField(blank=True, help_text='Secondary color in hex format for split color display', max_length=7, null=True), |
||||
), |
||||
] |
||||
@ -0,0 +1,18 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-21 12:26 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0018_color_secondary_hex_color'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='product', |
||||
name='description', |
||||
field=models.TextField(blank=True, help_text='Product description text', null=True), |
||||
), |
||||
] |
||||
@ -0,0 +1,18 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-21 12:50 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0019_product_description'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AddField( |
||||
model_name='product', |
||||
name='sku', |
||||
field=models.CharField(default='PC000', help_text='Product SKU (unique identifier)', max_length=50, unique=True), |
||||
), |
||||
] |
||||
@ -0,0 +1,23 @@ |
||||
# Generated by Django 4.2.11 on 2025-03-21 14:02 |
||||
|
||||
from django.db import migrations, models |
||||
|
||||
|
||||
class Migration(migrations.Migration): |
||||
|
||||
dependencies = [ |
||||
('shop', '0020_product_sku'), |
||||
] |
||||
|
||||
operations = [ |
||||
migrations.AlterField( |
||||
model_name='color', |
||||
name='name', |
||||
field=models.CharField(max_length=40, unique=True), |
||||
), |
||||
migrations.AlterField( |
||||
model_name='product', |
||||
name='sku', |
||||
field=models.CharField(help_text='Product SKU (unique identifier)', max_length=50, unique=True), |
||||
), |
||||
] |
||||
@ -0,0 +1,113 @@ |
||||
from django.db import models |
||||
from django.conf import settings |
||||
|
||||
class OrderStatus(models.TextChoices): |
||||
PENDING = 'PENDING', 'Pending' |
||||
PAID = 'PAID', 'Paid' |
||||
SHIPPED = 'SHIPPED', 'Shipped' |
||||
DELIVERED = 'DELIVERED', 'Delivered' |
||||
CANCELED = 'CANCELED', 'Canceled' |
||||
|
||||
class CutChoices(models.IntegerChoices): |
||||
WOMEN = 1, 'Women' |
||||
MEN = 2, 'Men' |
||||
KIDS = 3, 'Kids' |
||||
|
||||
class Color(models.Model): |
||||
name = models.CharField(max_length=40, unique=True) |
||||
colorHex = models.CharField(max_length=7, default="#FFFFFF", help_text="Color in hex format (e.g. #FF0000)") |
||||
secondary_hex_color = models.CharField(max_length=7, null=True, blank=True, |
||||
help_text="Secondary color in hex format for split color display") |
||||
|
||||
def __str__(self): |
||||
return self.name |
||||
|
||||
class Size(models.Model): |
||||
name = models.CharField(max_length=20, unique=True) |
||||
|
||||
def __str__(self): |
||||
return self.name |
||||
|
||||
class Product(models.Model): |
||||
sku = models.CharField(max_length=50, unique=True, help_text="Product SKU (unique identifier)") |
||||
title = models.CharField(max_length=200) |
||||
description = models.TextField(blank=True, null=True, help_text="Product description text") |
||||
image = models.CharField(max_length=200, null=True, blank=True) |
||||
price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00) |
||||
|
||||
# Use string references to prevent circular imports |
||||
colors = models.ManyToManyField("shop.Color", blank=True, related_name="products") |
||||
sizes = models.ManyToManyField("shop.Size", blank=True, related_name="products") |
||||
ordering_value = models.IntegerField(default=0, blank=False) |
||||
cut = models.IntegerField(choices=CutChoices.choices, default=CutChoices.MEN) |
||||
|
||||
class Meta: |
||||
ordering = ['ordering_value', 'cut'] # Add this line to sort by title |
||||
|
||||
def __str__(self): |
||||
return f"{self.sku} - {self.title}" |
||||
|
||||
class CartItem(models.Model): |
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True) |
||||
product = models.ForeignKey(Product, on_delete=models.CASCADE) |
||||
quantity = models.PositiveIntegerField(default=1) |
||||
color = models.ForeignKey(Color, on_delete=models.SET_NULL, null=True, blank=True) |
||||
size = models.ForeignKey(Size, on_delete=models.SET_NULL, null=True, blank=True) |
||||
session_id = models.CharField(max_length=255, null=True, blank=True) |
||||
date_added = models.DateTimeField(auto_now_add=True) |
||||
|
||||
class Meta: |
||||
ordering = ['product__ordering_value', 'product__cut'] # Sort by product's ordering_value |
||||
|
||||
def __str__(self): |
||||
return f"{self.quantity} x {self.product.title}" |
||||
|
||||
def get_total_price(self): |
||||
return self.product.price * self.quantity |
||||
|
||||
class GuestUser(models.Model): |
||||
email = models.EmailField() |
||||
phone = models.CharField(max_length=20) |
||||
|
||||
def __str__(self): |
||||
return f"{self.email}" |
||||
|
||||
|
||||
class Order(models.Model): |
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True) |
||||
date_ordered = models.DateTimeField(auto_now_add=True) |
||||
status = models.CharField(max_length=20, choices=OrderStatus.choices, default=OrderStatus.PENDING) |
||||
total_price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00) |
||||
guest_user = models.ForeignKey(GuestUser, on_delete=models.CASCADE, null=True, blank=True) |
||||
stripe_payment_intent_id = models.CharField(max_length=255, blank=True, null=True) |
||||
stripe_checkout_session_id = models.CharField(max_length=255, blank=True, null=True) |
||||
payment_status = models.CharField(max_length=20, default='UNPAID', choices=[ |
||||
('UNPAID', 'Unpaid'), |
||||
('PAID', 'Paid'), |
||||
('FAILED', 'Failed'), |
||||
]) |
||||
webhook_processed = models.BooleanField(default=False) |
||||
stripe_mode = models.CharField(max_length=10, default='test', choices=[ |
||||
('test', 'Test Mode'), |
||||
('live', 'Live Mode'), |
||||
]) |
||||
|
||||
def __str__(self): |
||||
return f"Order #{self.id} - {self.status}" |
||||
|
||||
class OrderItem(models.Model): |
||||
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items') |
||||
product = models.ForeignKey(Product, on_delete=models.CASCADE) |
||||
quantity = models.PositiveIntegerField(default=1) |
||||
color = models.ForeignKey(Color, on_delete=models.SET_NULL, null=True, blank=True) |
||||
size = models.ForeignKey(Size, on_delete=models.SET_NULL, null=True, blank=True) |
||||
price = models.DecimalField(max_digits=10, decimal_places=2) |
||||
|
||||
class Meta: |
||||
ordering = ['product__ordering_value', 'product__cut'] # Sort by product's ordering_value |
||||
|
||||
def __str__(self): |
||||
return f"{self.quantity} x {self.product.title}" |
||||
|
||||
def get_total_price(self): |
||||
return self.price * self.quantity |
||||
@ -0,0 +1,309 @@ |
||||
from django.db.models.signals import post_save, post_delete |
||||
from django.dispatch import receiver |
||||
from django.core.mail import send_mail |
||||
from django.conf import settings |
||||
from django.urls import reverse |
||||
from .models import Order, OrderItem, OrderStatus |
||||
from django.db import transaction |
||||
|
||||
@receiver([post_save, post_delete], sender=Order) |
||||
def send_order_notification(sender, instance, **kwargs): |
||||
"""Send an email notification when an order is created, updated, or deleted.""" |
||||
transaction.on_commit(lambda: _send_order_email(instance, **kwargs)) |
||||
|
||||
def _send_order_email(instance, **kwargs): |
||||
# Skip processing for PENDING orders |
||||
if instance.status == OrderStatus.PENDING: |
||||
return |
||||
|
||||
# Determine action type |
||||
action = _determine_action_type(kwargs) |
||||
if action in ["DELETED", "CREATED"]: |
||||
return # No emails for these actions |
||||
|
||||
# Build common email components |
||||
order_details = _get_order_details(instance) |
||||
items_list = _build_items_list(instance.id, action) |
||||
|
||||
# Send internal notification |
||||
_send_internal_notification(instance, action, order_details, items_list) |
||||
|
||||
# Send customer notification if applicable |
||||
if order_details['customer_email']: |
||||
_send_customer_notification(instance, order_details, items_list) |
||||
|
||||
def _determine_action_type(kwargs): |
||||
"""Determine the action type from signal kwargs.""" |
||||
if 'signal' in kwargs and kwargs['signal'] == post_delete: |
||||
return "DELETED" |
||||
elif kwargs.get('created', False): |
||||
return "CREATED" |
||||
else: |
||||
return "UPDATED" |
||||
|
||||
def _get_order_details(instance): |
||||
"""Extract and build order details dictionary.""" |
||||
# Get customer info |
||||
customer_email = None |
||||
if instance.user: |
||||
customer_info = f"Utilisateur: {instance.user.email}" |
||||
customer_email = instance.user.email |
||||
elif instance.guest_user: |
||||
customer_info = f"Invité: {instance.guest_user.email} ({instance.guest_user.phone})" |
||||
customer_email = instance.guest_user.email |
||||
else: |
||||
customer_info = "Client inconnu" |
||||
|
||||
# Translate statuses |
||||
status_fr_map = { |
||||
"PENDING": "EN ATTENTE", "PAID": "PAYÉE", |
||||
"SHIPPED": "EXPÉDIÉE", "DELIVERED": "LIVRÉE", "CANCELED": "ANNULÉE" |
||||
} |
||||
|
||||
payment_status_fr_map = { |
||||
"UNPAID": "NON PAYÉE", "PAID": "PAYÉE", "FAILED": "ÉCHOUÉE" |
||||
} |
||||
|
||||
return { |
||||
'order_id': instance.id, |
||||
'status': instance.status, |
||||
'status_fr': status_fr_map.get(instance.status, instance.status), |
||||
'payment_status': instance.payment_status, |
||||
'payment_status_fr': payment_status_fr_map.get(instance.payment_status, instance.payment_status), |
||||
'total_price': instance.total_price, |
||||
'customer_info': customer_info, |
||||
'customer_email': customer_email, |
||||
'date_ordered': instance.date_ordered, |
||||
'admin_url': f"{settings.SHOP_SITE_ROOT_URL}{reverse('admin:shop_order_change', args=[instance.id])}" |
||||
} |
||||
|
||||
def _build_items_list(order_id, action): |
||||
"""Build the list of order items.""" |
||||
items_list = "" |
||||
if action != "DELETED": |
||||
order_items = OrderItem.objects.filter(order_id=order_id).select_related('product', 'color', 'size') |
||||
for item in order_items: |
||||
color = item.color.name if item.color else "N/A" |
||||
size = item.size.name if item.size else "N/A" |
||||
items_list += f"- {item.quantity}x {item.product.title} (Couleur: {color}, Taille: {size}, Prix: {item.price}€)\n" |
||||
return items_list |
||||
|
||||
def _translate_action(action): |
||||
"""Translate action to French.""" |
||||
translations = { |
||||
"CREATED": "CRÉÉE", "UPDATED": "MISE À JOUR", "DELETED": "SUPPRIMÉE" |
||||
} |
||||
return translations.get(action, action) |
||||
|
||||
def _send_internal_notification(instance, action, order_details, items_list): |
||||
"""Send notification email to shop managers.""" |
||||
action_fr = _translate_action(action) |
||||
|
||||
subject = f"Commande #{order_details['order_id']} {action_fr}: {order_details['status_fr']}" |
||||
message = f""" |
||||
La commande #{order_details['order_id']} a été {action_fr.lower()} |
||||
|
||||
Statut: {order_details['status_fr']} |
||||
Statut de paiement: {order_details['payment_status_fr']} |
||||
Prix total: {order_details['total_price']}€ |
||||
|
||||
{order_details['customer_info']} |
||||
|
||||
Articles: |
||||
{items_list} |
||||
|
||||
Voir la commande dans le panneau d'administration: {order_details['admin_url']} |
||||
|
||||
Ceci est un message automatique. Merci de ne pas répondre. |
||||
""" |
||||
|
||||
# Send internal email |
||||
recipient_list = [email for name, email in settings.SHOP_MANAGERS] |
||||
if not recipient_list: |
||||
recipient_list = [settings.DEFAULT_FROM_EMAIL] |
||||
|
||||
send_mail( |
||||
subject=subject, |
||||
message=message, |
||||
from_email=settings.DEFAULT_FROM_EMAIL, |
||||
recipient_list=recipient_list, |
||||
fail_silently=False, |
||||
) |
||||
|
||||
def _send_customer_notification(instance, order_details, items_list): |
||||
"""Send appropriate notification email to customer based on order status.""" |
||||
# Common email variables |
||||
contact_email = settings.SHOP_SUPPORT_EMAIL |
||||
shop_url = f"{settings.SHOP_SITE_ROOT_URL}/shop" |
||||
date_formatted = order_details['date_ordered'].strftime('%d/%m/%Y') |
||||
|
||||
# Determine email content based on status and payment status |
||||
email_content = _get_customer_email_content( |
||||
instance.status, |
||||
order_details['payment_status'], |
||||
order_details['order_id'], |
||||
date_formatted, |
||||
order_details['status_fr'], |
||||
order_details['total_price'], |
||||
items_list, |
||||
contact_email, |
||||
shop_url |
||||
) |
||||
|
||||
# Skip if no email content returned |
||||
if not email_content: |
||||
return |
||||
|
||||
# Send email to customer |
||||
send_mail( |
||||
subject=email_content['subject'], |
||||
message=email_content['message'], |
||||
from_email=settings.DEFAULT_FROM_EMAIL, |
||||
recipient_list=[order_details['customer_email']], |
||||
fail_silently=False, |
||||
) |
||||
|
||||
def _get_customer_email_content(status, payment_status, order_id, date, status_fr, |
||||
total_price, items_list, contact_email, shop_url): |
||||
"""Get the appropriate customer email content based on order status.""" |
||||
|
||||
# Payment confirmation email |
||||
if status == OrderStatus.PAID and payment_status == "PAID": |
||||
return { |
||||
'subject': f"Confirmation de votre commande #{order_id} - PadelClub", |
||||
'message': _build_payment_confirmation_email(order_id, date, status_fr, |
||||
total_price, items_list, |
||||
contact_email, shop_url) |
||||
} |
||||
|
||||
# Order status update email |
||||
elif status in [OrderStatus.SHIPPED, OrderStatus.DELIVERED, OrderStatus.CANCELED]: |
||||
status_message = { |
||||
OrderStatus.SHIPPED: "Votre commande a été expédiée et est en cours de livraison.", |
||||
OrderStatus.DELIVERED: "Votre commande a été livrée. Nous espérons que vous apprécierez vos produits !", |
||||
OrderStatus.CANCELED: "Votre commande a été annulée. Si vous n'êtes pas à l'origine de cette annulation, veuillez nous contacter immédiatement." |
||||
}.get(status, "") |
||||
|
||||
return { |
||||
'subject': f"Mise à jour de votre commande #{order_id} - PadelClub", |
||||
'message': _build_status_update_email(order_id, date, status_message, status_fr, |
||||
total_price, items_list, contact_email) |
||||
} |
||||
|
||||
# Payment issue notification |
||||
elif payment_status == "FAILED": |
||||
return { |
||||
'subject': f"Problème de paiement pour votre commande #{order_id} - PadelClub", |
||||
'message': _build_payment_issue_email(order_id, date, total_price, |
||||
items_list, contact_email, shop_url) |
||||
} |
||||
|
||||
# Payment reminder for unpaid orders |
||||
elif payment_status == "UNPAID" and status != OrderStatus.PENDING: |
||||
return { |
||||
'subject': f"Rappel de paiement pour votre commande #{order_id} - PadelClub", |
||||
'message': _build_payment_reminder_email(order_id, date, total_price, |
||||
items_list, contact_email) |
||||
} |
||||
|
||||
# No email needed |
||||
return None |
||||
|
||||
def _build_payment_confirmation_email(order_id, date, status_fr, total_price, items_list, contact_email, shop_url): |
||||
"""Build payment confirmation email message.""" |
||||
return f""" |
||||
Bonjour, |
||||
|
||||
Nous vous remercions pour votre commande sur PadelClub ! |
||||
|
||||
Récapitulatif de votre commande #{order_id} du {date} : |
||||
|
||||
Statut: {status_fr} |
||||
Prix total: {total_price}€ |
||||
|
||||
Détail de votre commande : |
||||
{items_list} |
||||
|
||||
Nous nous occupons de préparer votre commande dans les plus brefs délais. |
||||
|
||||
Pour toute question concernant votre commande, n'hésitez pas à contacter notre service client : |
||||
{contact_email} |
||||
|
||||
Visitez notre boutique pour découvrir d'autres produits : |
||||
{shop_url} |
||||
|
||||
Merci de votre confiance et à bientôt sur PadelClub ! |
||||
|
||||
L'équipe PadelClub |
||||
""" |
||||
|
||||
def _build_status_update_email(order_id, date, status_message, status_fr, total_price, items_list, contact_email): |
||||
"""Build status update email message.""" |
||||
return f""" |
||||
Bonjour, |
||||
|
||||
Mise à jour concernant votre commande PadelClub #{order_id} du {date} : |
||||
|
||||
{status_message} |
||||
|
||||
Statut actuel: {status_fr} |
||||
Prix total: {total_price}€ |
||||
|
||||
Détail de votre commande : |
||||
{items_list} |
||||
|
||||
Pour toute question concernant votre commande, n'hésitez pas à contacter notre service client : |
||||
{contact_email} |
||||
|
||||
Merci de votre confiance et à bientôt sur PadelClub ! |
||||
|
||||
L'équipe PadelClub |
||||
""" |
||||
|
||||
def _build_payment_issue_email(order_id, date, total_price, items_list, contact_email, shop_url): |
||||
"""Build payment issue email message.""" |
||||
return f""" |
||||
Bonjour, |
||||
|
||||
Nous avons rencontré un problème lors du traitement du paiement de votre commande PadelClub #{order_id}. |
||||
|
||||
Détails de la commande : |
||||
Date: {date} |
||||
Prix total: {total_price}€ |
||||
|
||||
Articles: |
||||
{items_list} |
||||
|
||||
Veuillez vérifier vos informations de paiement et réessayer. Si le problème persiste, n'hésitez pas à contacter notre service client : |
||||
{contact_email} |
||||
|
||||
Vous pouvez également visiter notre boutique pour finaliser votre achat : |
||||
{shop_url} |
||||
|
||||
Merci de votre compréhension. |
||||
|
||||
L'équipe PadelClub |
||||
""" |
||||
|
||||
def _build_payment_reminder_email(order_id, date, total_price, items_list, contact_email): |
||||
"""Build payment reminder email message.""" |
||||
return f""" |
||||
Bonjour, |
||||
|
||||
Nous vous rappelons que votre commande PadelClub #{order_id} du {date} n'a pas encore été payée. |
||||
|
||||
Détails de la commande : |
||||
Prix total: {total_price}€ |
||||
|
||||
Articles: |
||||
{items_list} |
||||
|
||||
Pour finaliser votre commande, veuillez procéder au paiement dès que possible. |
||||
|
||||
Si vous rencontrez des difficultés ou si vous avez des questions, n'hésitez pas à contacter notre service client : |
||||
{contact_email} |
||||
|
||||
Merci de votre confiance. |
||||
|
||||
L'équipe PadelClub |
||||
""" |
||||
@ -0,0 +1,482 @@ |
||||
/* Product Display */ |
||||
.options-container { |
||||
display: grid; |
||||
grid-template-columns: 1fr 30%; |
||||
align-content: center; |
||||
} |
||||
|
||||
.no-image, |
||||
.product-image { |
||||
height: 240px; |
||||
width: 100%; |
||||
object-fit: contain; /* This will maintain the aspect ratio of the image */ |
||||
background-color: white; |
||||
border-radius: 12px; |
||||
} |
||||
|
||||
.no-image { |
||||
background-color: white; |
||||
} |
||||
|
||||
.product-title { |
||||
font-size: 16px; |
||||
font-weight: bold; |
||||
} |
||||
|
||||
.product-price { |
||||
font-size: 16px; |
||||
font-weight: bold; |
||||
} |
||||
|
||||
/* Grid Layout */ |
||||
.option-element { |
||||
align-content: center; |
||||
height: 39px; |
||||
background-color: white !important; |
||||
} |
||||
.option-element.product-title { |
||||
grid-column: 1 / span 2; |
||||
grid-row: 1; |
||||
background-color: blue; |
||||
} |
||||
|
||||
.option-element.product-description { |
||||
grid-column: 1 / span 2; |
||||
grid-row: 2; |
||||
font-size: 14px; |
||||
overflow: hidden; |
||||
text-overflow: ellipsis; |
||||
display: -webkit-box; |
||||
-webkit-line-clamp: 2; /* limit to 2 lines */ |
||||
-webkit-box-orient: vertical; |
||||
} |
||||
|
||||
.option-element.product-price { |
||||
grid-column: 2; |
||||
grid-row: 3; |
||||
text-align: center; |
||||
background-color: green; |
||||
} |
||||
|
||||
.option-element.color-label { |
||||
grid-column: 1; |
||||
grid-row: 3; |
||||
font-size: 14px; |
||||
background-color: yellow; |
||||
} |
||||
|
||||
.option-element.color-selector { |
||||
grid-column: 1; |
||||
grid-row: 4; |
||||
background-color: red; |
||||
} |
||||
|
||||
.option-element.size-selector { |
||||
grid-column: 2; |
||||
grid-row: 4; |
||||
font-size: 12px; |
||||
text-align: center; |
||||
background-color: purple; |
||||
} |
||||
|
||||
.option-element.quantity-selector { |
||||
grid-column: 1; |
||||
grid-row: 5; |
||||
} |
||||
|
||||
.option-element.total-price { |
||||
grid-column: 2; |
||||
grid-row: 5; |
||||
text-align: center; |
||||
} |
||||
|
||||
/* Buttons */ |
||||
.add-to-cart-button, |
||||
.checkout-button { |
||||
background-color: #90ee90; |
||||
color: #707070; |
||||
border: none; |
||||
border-radius: 12px; |
||||
font-size: 12px; |
||||
font-weight: 600; |
||||
text-decoration: none; |
||||
transition: background-color 0.3s ease; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
.add-to-cart-button { |
||||
margin-top: 10px; |
||||
width: 100%; |
||||
text-align: center; |
||||
display: inline-block; |
||||
height: 36px; |
||||
} |
||||
|
||||
.checkout-button { |
||||
padding: 10px 20px; |
||||
display: inline-block; |
||||
height: 36px; |
||||
} |
||||
|
||||
.confirm-nav-button { |
||||
background-color: #90ee90; |
||||
} |
||||
|
||||
.cancel-nav-button { |
||||
background-color: #e84039; |
||||
color: white; |
||||
} |
||||
|
||||
.remove-btn { |
||||
background-color: #e84039; |
||||
color: white; |
||||
border: none; |
||||
padding: 5px 10px; |
||||
border-radius: 12px; |
||||
cursor: pointer; |
||||
font-size: 12px; |
||||
font-weight: 600; |
||||
transition: background-color 0.3s ease; |
||||
} |
||||
|
||||
.add-to-cart-button:hover, |
||||
.confirm-nav-button:hover, |
||||
.cancel-nav-button:hover, |
||||
.remove-btn:hover { |
||||
background-color: #f39200; |
||||
color: white; |
||||
} |
||||
|
||||
/* Cart Table */ |
||||
.cart-table { |
||||
width: 100%; |
||||
border-collapse: separate; |
||||
border-spacing: 0; |
||||
border-radius: 12px; |
||||
overflow: hidden; |
||||
padding: 0px; |
||||
} |
||||
|
||||
.cart-table tbody tr.odd-row { |
||||
background-color: #f0f0f0; |
||||
} |
||||
|
||||
.cart-table tbody tr.even-row { |
||||
background-color: #e8e8e8; |
||||
} |
||||
|
||||
.cart-table th, |
||||
.cart-table td { |
||||
text-align: center; |
||||
} |
||||
|
||||
.text-left { |
||||
text-align: left !important; |
||||
} |
||||
|
||||
.price-column { |
||||
text-align: right; |
||||
} |
||||
|
||||
.cart-table tfoot { |
||||
background-color: #f5f5f5; |
||||
} |
||||
|
||||
.cart-table tfoot .total-label { |
||||
font-weight: bold; |
||||
} |
||||
|
||||
.cart-table tfoot .total-price { |
||||
font-weight: bold; |
||||
font-size: 1.2em; |
||||
} |
||||
|
||||
/* Quantity Controls */ |
||||
.quantity-controls { |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: left; |
||||
} |
||||
|
||||
.quantity-form { |
||||
display: flex; |
||||
align-items: center; |
||||
} |
||||
|
||||
.quantity-btn { |
||||
width: 28px; |
||||
height: 28px; |
||||
border-radius: 50%; |
||||
border: 1px solid #ddd; |
||||
background-color: #f8f8f8; |
||||
cursor: pointer; |
||||
font-weight: bold; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
padding: 0; |
||||
font-size: 16px; |
||||
transition: background-color 0.3s ease; |
||||
} |
||||
|
||||
.quantity-btn:hover:not([disabled]) { |
||||
background-color: #f39200; |
||||
color: white; |
||||
border-color: #f39200; |
||||
} |
||||
|
||||
.quantity-btn:disabled { |
||||
opacity: 0.5; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.quantity-value { |
||||
width: 44px; |
||||
text-align: center; |
||||
font-weight: bold; |
||||
font-size: 14px; |
||||
} |
||||
|
||||
/* Cart Summary & Checkout */ |
||||
.cart-summary { |
||||
margin-top: 20px; |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
flex-wrap: wrap; |
||||
} |
||||
|
||||
.guest-checkout-notice { |
||||
margin-top: 10px; |
||||
font-size: 0.9em; |
||||
background-color: #f8f9fa; |
||||
padding: 10px; |
||||
border-radius: 10px; |
||||
width: 100%; |
||||
} |
||||
|
||||
/* Checkout Page */ |
||||
.checkout-container { |
||||
padding: 25px; |
||||
} |
||||
|
||||
.checkout-section { |
||||
margin-bottom: 25px; |
||||
} |
||||
|
||||
.checkout-title { |
||||
color: #333; |
||||
font-size: 1.4em; |
||||
margin-bottom: 15px; |
||||
text-align: center; |
||||
} |
||||
|
||||
.checkout-description { |
||||
text-align: center; |
||||
margin-bottom: 20px; |
||||
color: #666; |
||||
} |
||||
|
||||
.guest-checkout-form { |
||||
background-color: #f8f9fa; |
||||
padding: 20px; |
||||
border-radius: 10px; |
||||
margin-bottom: 20px; |
||||
} |
||||
|
||||
.form-fields p { |
||||
margin-bottom: 15px; |
||||
} |
||||
|
||||
.form-fields label { |
||||
display: block; |
||||
margin-bottom: 5px; |
||||
font-weight: bold; |
||||
} |
||||
|
||||
.form-fields input { |
||||
width: 100%; |
||||
padding: 10px; |
||||
border: 1px solid #ddd; |
||||
border-radius: 8px; |
||||
font-size: 1em; |
||||
} |
||||
|
||||
.button-container { |
||||
display: flex; |
||||
justify-content: center; |
||||
margin-top: 20px; |
||||
} |
||||
|
||||
.checkout-options { |
||||
text-align: center; |
||||
} |
||||
|
||||
.checkout-options p { |
||||
margin: 15px 0; |
||||
} |
||||
|
||||
/* Links */ |
||||
.styled-link { |
||||
color: #f39200; |
||||
text-decoration: none; |
||||
font-weight: bold; |
||||
transition: color 0.3s; |
||||
} |
||||
|
||||
.styled-link:hover { |
||||
color: #e84039; |
||||
text-decoration: underline; |
||||
} |
||||
|
||||
.color-samples { |
||||
display: flex; |
||||
gap: 0px 8px; |
||||
vertical-align: middle; |
||||
align-items: center; |
||||
} |
||||
|
||||
.color-sample { |
||||
width: 28px; |
||||
aspect-ratio: 1; |
||||
border-radius: 50%; |
||||
cursor: pointer; |
||||
border: 1px solid #ddd; |
||||
} |
||||
|
||||
.color-sample:hover { |
||||
transform: scale(1.1); |
||||
} |
||||
|
||||
.color-display { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 8px; |
||||
} |
||||
|
||||
.color-sample-cart { |
||||
width: 28px; |
||||
height: 28px; |
||||
border-radius: 50%; |
||||
border: 1px solid #ddd; |
||||
display: inline-block; |
||||
} |
||||
|
||||
/* Fix for white + sign */ |
||||
.quantity-btn { |
||||
color: #333; /* Darker text color to ensure visibility */ |
||||
} |
||||
|
||||
.quantity-btn:hover:not([disabled]) { |
||||
background-color: #f39200; |
||||
color: white; |
||||
border-color: #f39200; |
||||
} |
||||
|
||||
v .cart-table { |
||||
display: block; |
||||
width: 100%; |
||||
} |
||||
|
||||
.cart-table thead { |
||||
display: none; /* Hide header on small screens */ |
||||
} |
||||
|
||||
.cart-table tbody, |
||||
.cart-table tr, |
||||
.cart-table tfoot { |
||||
display: block; |
||||
width: 100%; |
||||
} |
||||
|
||||
.cart-table td { |
||||
display: flex; |
||||
} |
||||
|
||||
.cart-table td.product-name { |
||||
grid-column: 1 / span 2; |
||||
grid-row: 1; |
||||
font-weight: bold; |
||||
font-size: 1.1em; |
||||
} |
||||
|
||||
.cart-table td.product-description { |
||||
grid-column: 1 / span 3; |
||||
grid-row: 2; |
||||
} |
||||
|
||||
.cart-table td.product-color { |
||||
grid-column: 1 / span 2; |
||||
grid-row: 3; |
||||
} |
||||
|
||||
.cart-table td.product-quantity { |
||||
grid-column: 1; |
||||
grid-row: 4; |
||||
} |
||||
|
||||
.cart-table td.product-price { |
||||
grid-column: 3; |
||||
grid-row: 4; |
||||
justify-content: right; |
||||
} |
||||
|
||||
.cart-table td.product-actions { |
||||
grid-column: 3; |
||||
grid-row: 1; |
||||
justify-content: right; |
||||
} |
||||
|
||||
/* Make each product a grid container */ |
||||
.cart-table tbody tr { |
||||
display: grid; |
||||
grid-template-columns: 1fr 1fr 1fr; |
||||
} |
||||
|
||||
/* Cart summary buttons */ |
||||
.cart-summary { |
||||
flex-direction: column; |
||||
gap: 10px; |
||||
} |
||||
|
||||
.checkout-button { |
||||
width: 100%; |
||||
text-align: center; |
||||
height: 44px; /* Fixed height */ |
||||
line-height: 24px; /* Vertically center text */ |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
} |
||||
|
||||
.cart-table tfoot tr { |
||||
display: grid; |
||||
grid-template-columns: 1fr 1fr 1fr; |
||||
} |
||||
|
||||
.cart-table tfoot td { |
||||
display: block; |
||||
width: 100%; |
||||
} |
||||
|
||||
.cart-table tfoot td.total-quantity { |
||||
grid-column: 1; |
||||
grid-row: 1; |
||||
text-align: left; |
||||
} |
||||
|
||||
.cart-table tfoot td.total-price { |
||||
grid-column: 3; |
||||
grid-row: 1; |
||||
text-align: right; |
||||
} |
||||
|
||||
.cart-table tfoot td:empty { |
||||
display: none; |
||||
} |
||||
|
||||
.color-sample, |
||||
.color-sample-cart { |
||||
position: relative; |
||||
overflow: hidden; |
||||
} |
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 909 KiB |
|
After Width: | Height: | Size: 158 KiB |
@ -0,0 +1,90 @@ |
||||
import stripe |
||||
from django.conf import settings |
||||
import logging |
||||
|
||||
logger = logging.getLogger(__name__) |
||||
|
||||
class StripeService: |
||||
"""Service class to manage Stripe operations with mode awareness""" |
||||
|
||||
def __init__(self): |
||||
# Determine if we're in test mode |
||||
self.mode = getattr(settings, 'STRIPE_MODE', 'test') |
||||
self.is_test_mode = self.mode == 'test' |
||||
|
||||
# Get appropriate keys based on mode |
||||
self.api_key = settings.STRIPE_SECRET_KEY |
||||
self.publishable_key = settings.STRIPE_PUBLISHABLE_KEY |
||||
self.webhook_secret = settings.STRIPE_WEBHOOK_SECRET |
||||
self.currency = getattr(settings, 'STRIPE_CURRENCY', 'eur') |
||||
|
||||
# Configure Stripe library |
||||
stripe.api_key = self.api_key |
||||
|
||||
# Log initialization in debug mode |
||||
mode_str = "TEST" if self.is_test_mode else "LIVE" |
||||
logger.debug(f"Initialized StripeService in {mode_str} mode") |
||||
|
||||
def create_checkout_session(self, line_items, success_url, cancel_url, metadata=None): |
||||
"""Create a Stripe Checkout Session for one-time payments""" |
||||
if self.is_test_mode: |
||||
logger.info(f"Creating checkout session in TEST mode with metadata: {metadata}") |
||||
|
||||
session = stripe.checkout.Session.create( |
||||
payment_method_types=['card'], |
||||
line_items=line_items, |
||||
mode='payment', |
||||
success_url=success_url, |
||||
cancel_url=cancel_url, |
||||
metadata=metadata or {}, |
||||
) |
||||
return session |
||||
|
||||
def verify_webhook_signature(self, payload, signature): |
||||
"""Verify webhook signature using mode-appropriate secret""" |
||||
try: |
||||
event = stripe.Webhook.construct_event( |
||||
payload, signature, self.webhook_secret |
||||
) |
||||
return event |
||||
except stripe.error.SignatureVerificationError as e: |
||||
logger.error(f"Webhook signature verification failed: {str(e)}") |
||||
raise |
||||
|
||||
def construct_event_for_testing(self, payload, signature): |
||||
"""Helper method to construct events during testing""" |
||||
return stripe.Webhook.construct_event( |
||||
payload, signature, self.webhook_secret |
||||
) |
||||
|
||||
def get_checkout_session(self, session_id): |
||||
"""Retrieve a checkout session by ID""" |
||||
return stripe.checkout.Session.retrieve(session_id) |
||||
|
||||
def get_payment_intent(self, payment_intent_id): |
||||
"""Retrieve a payment intent by ID""" |
||||
return stripe.PaymentIntent.retrieve(payment_intent_id) |
||||
|
||||
# Create a singleton instance for import and use throughout the app |
||||
stripe_service = StripeService() |
||||
|
||||
# For backward compatibility, expose some functions directly |
||||
def create_payment_intent(amount, currency=None, metadata=None): |
||||
"""Legacy function for backward compatibility""" |
||||
if currency is None: |
||||
currency = stripe_service.currency |
||||
|
||||
return stripe.PaymentIntent.create( |
||||
amount=amount, |
||||
currency=currency, |
||||
metadata=metadata or {}, |
||||
) |
||||
|
||||
def create_checkout_session(line_items, success_url, cancel_url, metadata=None): |
||||
"""Legacy function for backward compatibility""" |
||||
return stripe_service.create_checkout_session( |
||||
line_items=line_items, |
||||
success_url=success_url, |
||||
cancel_url=cancel_url, |
||||
metadata=metadata or {}, |
||||
) |
||||
@ -0,0 +1,55 @@ |
||||
{% extends 'tournaments/base.html' %} |
||||
|
||||
{% block head_title %}La Boutique{% endblock %} |
||||
{% block first_title %}Padel Club{% endblock %} |
||||
{% block second_title %}La Boutique{% endblock %} |
||||
|
||||
{% block content %} |
||||
|
||||
<nav class="margin10"> |
||||
<a href="{% url 'shop:product_list' %}">La Boutique</a> |
||||
<a href="{% url 'index' %}" class="orange">Accueil</a> |
||||
<a href="{% url 'clubs' %}" class="orange">Clubs</a> |
||||
{% if user.is_authenticated %} |
||||
<a href="{% url 'my-tournaments' %}" class="orange">Mes tournois</a> |
||||
<a href="{% url 'profile' %}">Mon compte</a> |
||||
{% else %} |
||||
<a href="{% url 'login' %}">Se connecter</a> |
||||
{% endif %} |
||||
</nav> |
||||
<h1 class="club my-block topmargin20">Validation de la commande</h1> |
||||
<div class="grid-x"> |
||||
<div class="small-12 medium-6 large-6 my-block"> |
||||
<div class="bubble checkout-container"> |
||||
{% if request.user.is_authenticated %} |
||||
<div class="checkout-section"> |
||||
<p>Vous êtes déjà connecté en tant que <strong>{{ request.user.email }}</strong>.</p> |
||||
<a href="{% url 'shop:checkout' %}" class="checkout-button confirm-nav-button">Passer à la commande</a> |
||||
</div> |
||||
{% else %} |
||||
<div class="checkout-section"> |
||||
<h3 class="checkout-title">Finaliser votre commande</h3> |
||||
<p class="checkout-description">Vous n'êtes pas connecté. Veuillez choisir une option :</p> |
||||
</div> |
||||
|
||||
<div class="checkout-section"> |
||||
<form method="post" class="guest-checkout-form"> |
||||
{% csrf_token %} |
||||
<div class="form-fields"> |
||||
{{ form.as_p }} |
||||
</div> |
||||
<div class="button-container"> |
||||
<button class="checkout-button confirm-nav-button" type="submit" name="guest_checkout">Continuer sans créer de compte</button> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
|
||||
<div class="checkout-section checkout-options"> |
||||
<p>Ou <a class="styled-link" href="{% url 'login' %}?next={% url 'shop:checkout' %}">connectez-vous</a> si vous avez déjà un compte.</p> |
||||
<p>Pas encore de compte ? <a class="styled-link" href="{% url 'signup' %}?next={% url 'shop:checkout' %}">Créez-en un maintenant</a></p> |
||||
</div> |
||||
{% endif %} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{% endblock %} |
||||
@ -0,0 +1,60 @@ |
||||
<table class="cart-table"> |
||||
<tbody> |
||||
{% for item in items %} |
||||
<tr class="{% cycle 'odd-row' 'even-row' %}"> |
||||
<td class="text-left product-name" data-label="Produit">{{ item.product_title }}</td> |
||||
{% if item.product_description %} |
||||
<td class="text-left product-description" data-label="Description">{{ item.product_description }}</td> |
||||
{% endif %} |
||||
<td class="product-color" data-label="Couleur"> |
||||
<div class="color-display"> |
||||
<div class="color-sample-cart" |
||||
{% if item.secondary_color_hex %} |
||||
style="background-image: linear-gradient(to right, {{ item.color_hex }} 50%, {{ item.secondary_color_hex }} 50%);" |
||||
{% else %} |
||||
style="background-color: {{ item.color_hex }};" |
||||
{% endif %} |
||||
></div> |
||||
{{ item.color_name }} | {{ item.size_name }} |
||||
</div> |
||||
</td> |
||||
<td class="product-quantity" data-label="Quantité"> |
||||
{% if edit_mode %} |
||||
<div class="quantity-controls"> |
||||
<form method="post" action="{% url 'shop:update_cart_item' %}" class="quantity-form"> |
||||
{% csrf_token %} |
||||
<input type="hidden" name="item_id" value="{{ item.id }}"> |
||||
<button type="submit" name="action" value="decrease" class="quantity-btn" {% if item.quantity <= 1 %}disabled{% endif %}>-</button> |
||||
<span class="quantity-value">{{ item.quantity }}</span> |
||||
<button type="submit" name="action" value="increase" class="quantity-btn">+</button> |
||||
</form> |
||||
</div> |
||||
{% else %} |
||||
<span>x {{ item.quantity }}</span> |
||||
{% endif %} |
||||
</td> |
||||
<td class="price-column product-price" data-label="Prix">{{ item.total_price }} €</td> |
||||
{% if edit_mode %} |
||||
<td class="product-actions"> |
||||
<form method="post" action="{% url 'shop:remove_from_cart' %}" class="remove-form"> |
||||
{% csrf_token %} |
||||
<input type="hidden" name="item_id" value="{{ item.id }}"> |
||||
<button type="submit" class="remove-btn">retirer</button> |
||||
</form> |
||||
</td> |
||||
{% endif %} |
||||
</tr> |
||||
{% endfor %} |
||||
</tbody> |
||||
<tfoot> |
||||
<tr> |
||||
<td class="total-quantity" data-label="total-quantity">{{ total_quantity }} produit(s)</td> |
||||
<td class="total-label text-left"></td> |
||||
<td class="total-label text-left"></td> |
||||
<td class="price-column total-price" data-label="total-price">{{ total_price }} €</td> |
||||
{% if edit_mode %} |
||||
<td class="total-label text-left"></td> |
||||
{% endif %} |
||||
</tr> |
||||
</tfoot> |
||||
</table> |
||||
@ -0,0 +1,35 @@ |
||||
<!-- padelclub_backend/shop/templates/shop/payment_cancel.html --> |
||||
{% extends 'tournaments/base.html' %} |
||||
|
||||
{% block head_title %}La Boutique{% endblock %} |
||||
{% block first_title %}Padel Club{% endblock %} |
||||
{% block second_title %}La Boutique{% endblock %} |
||||
|
||||
{% block content %} |
||||
|
||||
<nav class="margin10"> |
||||
<a href="{% url 'shop:product_list' %}">La Boutique</a> |
||||
<a href="{% url 'index' %}" class="orange">Accueil</a> |
||||
<a href="{% url 'clubs' %}" class="orange">Clubs</a> |
||||
{% if user.is_authenticated %} |
||||
<a href="{% url 'my-tournaments' %}" class="orange">Mes tournois</a> |
||||
<a href="{% url 'profile' %}">Mon compte</a> |
||||
{% else %} |
||||
<a href="{% url 'login' %}">Se connecter</a> |
||||
{% endif %} |
||||
</nav> |
||||
|
||||
<div class="grid-x"> |
||||
<div class="cell medium-6 large-6 my-block"> |
||||
<h1 class="club my-block topmargin20">Paiement</h1> |
||||
<div class="bubble"> |
||||
<h2>Le paiement a été annulé</h2> |
||||
<p>Votre commande n'a pas été finalisée car le paiement a été annulé.</p> |
||||
|
||||
<div class="cart-summary"> |
||||
<a class="confirm-nav-button checkout-button" href="{% url 'shop:view_cart' %}">Retour au panier</a> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{% endblock %} |
||||
@ -0,0 +1,38 @@ |
||||
{% extends 'tournaments/base.html' %} |
||||
|
||||
{% block head_title %}La Boutique{% endblock %} |
||||
{% block first_title %}Padel Club{% endblock %} |
||||
{% block second_title %}La Boutique{% endblock %} |
||||
|
||||
{% block content %} |
||||
<nav class="margin10"> |
||||
<a href="{% url 'shop:product_list' %}">La Boutique</a> |
||||
<a href="{% url 'index' %}" class="orange">Accueil</a> |
||||
<a href="{% url 'clubs' %}" class="orange">Clubs</a> |
||||
{% if user.is_authenticated %} |
||||
<a href="{% url 'my-tournaments' %}" class="orange">Mes tournois</a> |
||||
<a href="{% url 'profile' %}">Mon compte</a> |
||||
{% else %} |
||||
<a href="{% url 'login' %}">Se connecter</a> |
||||
{% endif %} |
||||
</nav> |
||||
|
||||
<div class="grid-x"> |
||||
<div class="cell medium-6 large-6 my-block"> |
||||
<h1 class="club my-block topmargin20">Paiement réussi</h1> |
||||
<div class="bubble"> |
||||
<h2>Merci pour votre commande !</h2> |
||||
<p>Votre paiement a été traité avec succès.</p> |
||||
<p>Numéro de commande: {{ order.id }}</p> |
||||
|
||||
<!-- Order details --> |
||||
<h3>Détails de la commande</h3> |
||||
{% include 'shop/partials/order_items_display.html' with items=display_data.items total_quantity=display_data.total_quantity total_price=display_data.total_price edit_mode=False %} |
||||
|
||||
<div class="cart-summary"> |
||||
<a class="confirm-nav-button checkout-button" href="{% url 'shop:product_list' %}">Retour à la boutique</a> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{% endblock %} |
||||
@ -0,0 +1,128 @@ |
||||
<div class="small-12 medium-6 large-3 my-block"> |
||||
<div class="bubble"> |
||||
{% if product.image %} |
||||
<img src="{{ product.image }}" alt="{{ product.title }}" class="product-image"> |
||||
{% else %} |
||||
<div class="no-image">No Image Available</div> |
||||
{% endif %} |
||||
<form method="post" action="{% url 'shop:add_to_cart' product.id %}" class="add-to-cart-form"> |
||||
{% csrf_token %} |
||||
<div class="options-container"> |
||||
<div class="option-element product-title"> |
||||
{{ product.title }} |
||||
</div> |
||||
{% if product.description %} |
||||
<div class="option-element product-description"> |
||||
{{ product.description }} |
||||
</div> |
||||
{% endif %} |
||||
|
||||
<div class="option-element product-price">{{ product.price }} €</div> |
||||
<div class="option-element color-label"><span id="selected-color-name-{{ product.id }}">{{ product.colors.all.0.name }}</span></div> |
||||
<div class="option-element color-selector form-group"> |
||||
{% if product.colors.exists %} |
||||
<input type="hidden" name="color" id="color-{{ product.id }}" value="{{ product.colors.all.0.id }}" required> |
||||
<div class="color-samples"> |
||||
{% for color in product.colors.all %} |
||||
<div class="color-sample {% if forloop.first %}selected{% endif %}" |
||||
{% if color.secondary_hex_color %} |
||||
style="background-image: linear-gradient(to right, {{ color.colorHex }} 50%, {{ color.secondary_hex_color }} 50%);" |
||||
{% else %} |
||||
style="background-color: {{ color.colorHex }};" |
||||
{% endif %} |
||||
title="{{ color.name }}" |
||||
data-color-id="{{ color.id }}" |
||||
data-color-name="{{ color.name }}" |
||||
onclick="selectColor('{{ product.id }}', '{{ color.id }}', '{{ color.name }}', this)"></div> |
||||
{% endfor %} |
||||
</div> |
||||
{% endif %} |
||||
</div> |
||||
|
||||
|
||||
<div class="option-element size-selector form-group"> |
||||
{% if product.sizes.exists %} |
||||
{% if product.sizes.all|length == 1 %} |
||||
<input type="hidden" name="size" value="{{ product.sizes.all.0.id }}"> |
||||
<div>{{ product.sizes.all.0.name }}</div> |
||||
|
||||
{% else %} |
||||
<select name="size" id="size-{{ product.id }}" class="form-control" required> |
||||
<option value="" disabled selected>Taille</option> |
||||
{% for size in product.sizes.all %} |
||||
<option value="{{ size.id }}" {% if forloop.first %}selected{% endif %}>{{ size.name }}</option> |
||||
{% endfor %} |
||||
</select> |
||||
{% endif %} |
||||
{% endif %} |
||||
</div> |
||||
<div class="option-element quantity-selector form-group"> |
||||
<div class="quantity-controls"> |
||||
<input type="hidden" name="quantity" id="quantity-{{ product.id }}" value="1"> |
||||
<button type="button" class="quantity-btn decrease-btn" onclick="adjustQuantity('{{ product.id }}', -1, {{ product.price }})" disabled>-</button> |
||||
<span class="quantity-value" id="quantity-display-{{ product.id }}">1</span> |
||||
<button type="button" class="quantity-btn increase-btn" onclick="adjustQuantity('{{ product.id }}', 1, {{ product.price }})">+</button> |
||||
</div> |
||||
</div> |
||||
<div class="option-element total-price form-group"><span id="total-price-{{ product.id }}">{{ product.price }}</span> €</div> |
||||
</div> |
||||
<button type="submit" class="add-to-cart-button">Ajouter au panier</button> |
||||
</form> |
||||
</div> |
||||
</div> |
||||
|
||||
<script> |
||||
|
||||
function adjustQuantity(productId, change, price) { |
||||
let quantityInput = document.getElementById(`quantity-${productId}`); |
||||
let quantityDisplay = document.getElementById(`quantity-display-${productId}`); |
||||
|
||||
// Get the decrease button that's within the same form as the quantity input |
||||
let decreaseBtn = quantityInput.closest('.quantity-controls').querySelector('.decrease-btn'); |
||||
|
||||
let currentQuantity = parseInt(quantityInput.value) || 1; |
||||
|
||||
// Calculate new quantity |
||||
let newQuantity = currentQuantity + change; |
||||
|
||||
// Enforce min/max limits |
||||
if (newQuantity < 1) newQuantity = 1; |
||||
if (newQuantity > 10) newQuantity = 10; |
||||
|
||||
// Update quantity in hidden input and display |
||||
quantityInput.value = newQuantity; |
||||
quantityDisplay.textContent = newQuantity; |
||||
|
||||
// Enable/disable decrease button based on quantity |
||||
decreaseBtn.disabled = (newQuantity <= 1); |
||||
|
||||
// Update total price |
||||
updateTotal(productId, price); |
||||
} |
||||
|
||||
function updateTotal(productId, price) { |
||||
let quantityInput = document.getElementById(`quantity-${productId}`); |
||||
let totalPriceElement = document.getElementById(`total-price-${productId}`); |
||||
|
||||
let quantity = parseInt(quantityInput.value) || 1; // Default to 1 if invalid |
||||
let totalPrice = (quantity * price).toFixed(2); // Ensure two decimal places |
||||
|
||||
totalPriceElement.textContent = totalPrice; |
||||
} |
||||
|
||||
function selectColor(productId, colorId, colorName, element) { |
||||
// Update hidden input value |
||||
document.getElementById(`color-${productId}`).value = colorId; |
||||
|
||||
// Update displayed color name |
||||
document.getElementById(`selected-color-name-${productId}`).textContent = colorName; |
||||
|
||||
// Remove selected class from all colors |
||||
const colorSamples = element.parentElement.querySelectorAll('.color-sample'); |
||||
colorSamples.forEach(sample => sample.classList.remove('selected')); |
||||
|
||||
// Add selected class to clicked color |
||||
element.classList.add('selected'); |
||||
} |
||||
|
||||
</script> |
||||
@ -0,0 +1,39 @@ |
||||
{% extends 'tournaments/base.html' %} |
||||
|
||||
{% block head_title %}La Boutique{% endblock %} |
||||
{% block first_title %}Padel Club{% endblock %} |
||||
{% block second_title %}La Boutique{% endblock %} |
||||
|
||||
{% block content %} |
||||
|
||||
<nav class="margin10"> |
||||
<a href="{% url 'shop:product_list' %}">La Boutique</a> |
||||
<a href="{% url 'index' %}" class="orange">Accueil</a> |
||||
<a href="{% url 'clubs' %}" class="orange">Clubs</a> |
||||
{% if user.is_authenticated %} |
||||
<a href="{% url 'my-tournaments' %}" class="orange">Mes tournois</a> |
||||
<a href="{% url 'profile' %}">Mon compte</a> |
||||
{% else %} |
||||
<a href="{% url 'login' %}">Se connecter</a> |
||||
{% endif %} |
||||
</nav> |
||||
|
||||
<nav class="margin10"> |
||||
<a class="confirm-nav-button" href="{% url 'shop:view_cart' %}">Voir mon panier ({{ total }} €)</a> |
||||
{% if cart_items %} |
||||
<a class="cancel-nav-button" href="{% url 'shop:clear_cart' %}" onclick="return confirm('Êtes-vous sûr de vouloir supprimer votre panier ?');">Vider le panier</a> |
||||
{% endif %} |
||||
|
||||
</nav> |
||||
|
||||
{% if products %} |
||||
<div class="grid-x"> |
||||
{% for product in products %} |
||||
{% include "shop/product_item.html" with product=product %} |
||||
{% endfor %} |
||||
</div> |
||||
{% else %} |
||||
<p>No products available.</p> |
||||
{% endif %} |
||||
</div> |
||||
{% endblock %} |
||||
@ -0,0 +1,3 @@ |
||||
from django.test import TestCase |
||||
|
||||
# Create your tests here. |
||||
@ -0,0 +1,26 @@ |
||||
from django.urls import path |
||||
from . import views |
||||
|
||||
app_name = 'shop' |
||||
|
||||
urlpatterns = [ |
||||
path('', views.product_list, name='product_list'), |
||||
|
||||
# Cart URLs |
||||
path('cart/', views.view_cart, name='view_cart'), |
||||
path('cart/add/<int:product_id>/', views.add_to_cart_view, name='add_to_cart'), |
||||
path('cart/update/<int:product_id>/', views.update_cart_view, name='update_cart'), |
||||
path('cart/remove/<int:product_id>/', views.remove_from_cart_view, name='remove_from_cart'), |
||||
path('clear-cart/', views.clear_cart, name='clear_cart'), |
||||
path('checkout/', views.checkout, name='checkout'), |
||||
path('payment/<int:order_id>/', views.payment, name='payment'), |
||||
path('payment/success/<int:order_id>/', views.payment_success, name='payment_success'), |
||||
path('payment/cancel/<int:order_id>/', views.payment_cancel, name='payment_cancel'), |
||||
path('webhook/stripe/', views.stripe_webhook, name='stripe_webhook'), |
||||
path('create-checkout-session/', views.create_checkout_session, name='create_checkout_session'), |
||||
path('cart/update-item/', views.update_cart_item, name='update_cart_item'), |
||||
path('cart/remove-item/', views.remove_from_cart, name='remove_from_cart'), |
||||
path('debug/simulate-payment-success/', views.simulate_payment_success, name='simulate_payment_success'), |
||||
path('debug/simulate-payment-failure/', views.simulate_payment_failure, name='simulate_payment_failure'), |
||||
|
||||
] |
||||
@ -0,0 +1,584 @@ |
||||
from .stripe_utils import stripe_service |
||||
from django.shortcuts import render, redirect, get_object_or_404 |
||||
from django.contrib import messages |
||||
from .models import Product, Order, OrderItem, GuestUser, OrderStatus |
||||
from django.db.models import Sum |
||||
from .forms import GuestCheckoutForm |
||||
import stripe |
||||
from django.conf import settings |
||||
from django.urls import reverse |
||||
from django.http import JsonResponse |
||||
from django.views.decorators.http import require_POST |
||||
from django.views.decorators.csrf import ensure_csrf_cookie |
||||
from django.http import HttpResponse |
||||
from django.views.decorators.csrf import csrf_exempt |
||||
|
||||
from . import cart |
||||
|
||||
# Shared helper methods |
||||
def _check_stripe_config(): |
||||
"""Check if Stripe API keys are properly configured""" |
||||
return hasattr(settings, 'STRIPE_SECRET_KEY') and settings.STRIPE_SECRET_KEY |
||||
|
||||
def _create_stripe_line_items(order_items): |
||||
"""Create line items for Stripe checkout from order items""" |
||||
line_items = [] |
||||
for item in order_items: |
||||
item_price = int(float(item.price) * 100) # Convert to cents |
||||
|
||||
line_items.append({ |
||||
'price_data': { |
||||
'currency': 'eur', |
||||
'product_data': { |
||||
'name': item.product.title, |
||||
'description': f"Color: {item.color.name if item.color else 'N/A'}, Size: {item.size.name if item.size else 'N/A'}", |
||||
}, |
||||
'unit_amount': item_price, |
||||
}, |
||||
'quantity': item.quantity, |
||||
}) |
||||
return line_items |
||||
|
||||
def _create_stripe_checkout_session(request, order, line_items): |
||||
"""Create a Stripe checkout session for the order""" |
||||
# Create success and cancel URLs |
||||
success_url = request.build_absolute_uri(reverse('shop:payment_success', args=[order.id])) |
||||
cancel_url = request.build_absolute_uri(reverse('shop:payment_cancel', args=[order.id])) |
||||
|
||||
# Create metadata to identify this order |
||||
metadata = { |
||||
'order_id': str(order.id), |
||||
} |
||||
|
||||
# Add user info to metadata if available |
||||
if request.user.is_authenticated: |
||||
metadata['user_id'] = str(request.user.id) |
||||
elif 'guest_email' in request.session: |
||||
metadata['guest_email'] = request.session.get('guest_email', '') |
||||
|
||||
try: |
||||
# Use the service to create the session |
||||
checkout_session = stripe_service.create_checkout_session( |
||||
line_items=line_items, |
||||
success_url=success_url, |
||||
cancel_url=cancel_url, |
||||
metadata=metadata, |
||||
) |
||||
|
||||
# Save the checkout session ID to the order |
||||
order.stripe_checkout_session_id = checkout_session.id |
||||
order.save() |
||||
|
||||
return checkout_session |
||||
|
||||
except Exception as e: |
||||
print(f"Stripe error: {str(e)}") |
||||
raise |
||||
|
||||
# View functions |
||||
def product_list(request): |
||||
products = Product.objects.all() |
||||
cart_items = cart.get_cart_items(request) |
||||
total = cart.get_cart_total(request) |
||||
return render(request, 'shop/product_list.html', { |
||||
'products': products, |
||||
'cart_items': cart_items, |
||||
'total': total |
||||
}) |
||||
|
||||
def view_cart(request): |
||||
"""Display the shopping cart""" |
||||
cart_items = cart.get_cart_items(request) |
||||
total = cart.get_cart_total(request) |
||||
total_quantity = cart_items.aggregate(total_quantity=Sum('quantity'))['total_quantity'] |
||||
display_data = prepare_item_display_data(cart_items, is_cart=True) |
||||
context = { |
||||
'display_data': display_data, |
||||
'total': total, |
||||
'total_quantity': total_quantity, |
||||
'settings': settings, # Add this line to pass settings to template |
||||
} |
||||
|
||||
# Add Stripe publishable key for authenticated users |
||||
if request.user.is_authenticated: |
||||
context['stripe_publishable_key'] = settings.STRIPE_PUBLISHABLE_KEY |
||||
|
||||
return render(request, 'shop/cart.html', context) |
||||
|
||||
@ensure_csrf_cookie |
||||
def add_to_cart_view(request, product_id): |
||||
"""Add a product to the cart""" |
||||
product = get_object_or_404(Product, id=product_id) |
||||
quantity = int(request.POST.get('quantity', 1)) |
||||
color_id = request.POST.get('color') |
||||
size_id = request.POST.get('size') |
||||
|
||||
cart_item = cart.add_to_cart(request, product_id, quantity, color_id, size_id) |
||||
messages.success(request, f'{cart_item.quantity} x {product.title} added to your cart') |
||||
|
||||
return redirect('shop:product_list') |
||||
|
||||
def update_cart_view(request, product_id): |
||||
"""Update cart item quantity""" |
||||
if request.method == 'POST': |
||||
quantity = int(request.POST.get('quantity', 0)) |
||||
cart.update_cart_item(request, product_id, quantity) |
||||
return redirect('shop:view_cart') |
||||
|
||||
def remove_from_cart_view(request, product_id): |
||||
"""Remove item from cart""" |
||||
cart.remove_from_cart(request, product_id) |
||||
return redirect('shop:view_cart') |
||||
|
||||
def clear_cart(request): |
||||
"""Clear the cart""" |
||||
cart.clear_cart(request) |
||||
messages.success(request, "Your cart has been cleared.") |
||||
return redirect('shop:product_list') |
||||
|
||||
def create_order(request): |
||||
"""Create an order from the current cart""" |
||||
cart_items = cart.get_cart_items(request) |
||||
|
||||
# Check if cart is empty |
||||
if not cart_items.exists(): |
||||
return None |
||||
|
||||
total_price = sum(item.get_total_price() for item in cart_items) |
||||
|
||||
# Check if total price is valid |
||||
if total_price <= 0: |
||||
return None |
||||
|
||||
if request.user.is_authenticated: |
||||
# Authenticated user order |
||||
order = Order.objects.create( |
||||
user=request.user, |
||||
total_price=total_price, |
||||
stripe_mode=stripe_service.mode # Add this line |
||||
|
||||
) |
||||
else: |
||||
# Guest user order |
||||
try: |
||||
guest_user = GuestUser.objects.get(email=request.session['guest_email']) |
||||
order = Order.objects.create( |
||||
guest_user=guest_user, |
||||
total_price=total_price, |
||||
stripe_mode=stripe_service.mode # Add this line |
||||
|
||||
) |
||||
except (KeyError, GuestUser.DoesNotExist): |
||||
# No guest user information, create order without user |
||||
order = Order.objects.create( |
||||
total_price=total_price, |
||||
stripe_mode=stripe_service.mode # Add this line |
||||
) |
||||
|
||||
# Create order items |
||||
for cart_item in cart_items: |
||||
OrderItem.objects.create( |
||||
order=order, |
||||
product=cart_item.product, |
||||
quantity=cart_item.quantity, |
||||
color=cart_item.color, |
||||
size=cart_item.size, |
||||
price=cart_item.product.price |
||||
) |
||||
|
||||
# Note: Cart is not cleared here, only after successful payment |
||||
return order |
||||
|
||||
def checkout(request): |
||||
"""Handle checkout process for both authenticated and guest users""" |
||||
# Check if cart is empty |
||||
cart_items = cart.get_cart_items(request) |
||||
if not cart_items.exists(): |
||||
messages.error(request, "Your cart is empty. Please add items before checkout.") |
||||
return redirect('shop:product_list') |
||||
|
||||
if request.user.is_authenticated: |
||||
# Create order for authenticated user and go directly to payment |
||||
return _handle_authenticated_checkout(request) |
||||
|
||||
# Handle guest checkout |
||||
if request.method == 'GET': |
||||
form = GuestCheckoutForm() |
||||
return render(request, 'shop/checkout.html', {'form': form}) |
||||
elif request.method == 'POST': |
||||
return _handle_guest_checkout_post(request) |
||||
|
||||
return redirect('shop:product_list') |
||||
|
||||
def _handle_authenticated_checkout(request): |
||||
"""Helper function to handle checkout for authenticated users""" |
||||
order = create_order(request) |
||||
|
||||
if not order: |
||||
messages.error(request, "There was an issue creating your order. Please try again.") |
||||
return redirect('shop:view_cart') |
||||
|
||||
return redirect('shop:payment', order_id=order.id) |
||||
|
||||
def _handle_guest_checkout_post(request): |
||||
"""Helper function to handle POST requests for guest checkout""" |
||||
form = GuestCheckoutForm(request.POST) |
||||
if form.is_valid(): |
||||
# Create or get guest user |
||||
email = form.cleaned_data['email'] |
||||
phone = form.cleaned_data['phone'] |
||||
guest_user, created = GuestUser.objects.get_or_create( |
||||
email=email, |
||||
defaults={'phone': phone} |
||||
) |
||||
|
||||
# Store email in session |
||||
request.session['guest_email'] = email |
||||
|
||||
# Create order |
||||
order = create_order(request) |
||||
|
||||
if not order: |
||||
messages.error(request, "There was an issue creating your order. Please try again.") |
||||
return redirect('shop:view_cart') |
||||
|
||||
return redirect('shop:payment', order_id=order.id) |
||||
|
||||
# Form invalid |
||||
return render(request, 'shop/checkout.html', {'form': form}) |
||||
|
||||
def payment(request, order_id): |
||||
"""Handle payment for an existing order""" |
||||
order = get_object_or_404(Order, id=order_id) |
||||
order.status = OrderStatus.PENDING |
||||
order.payment_status = 'PENDING' |
||||
order_items = order.items.all() |
||||
|
||||
# Check for valid order |
||||
if not order_items.exists() or order.total_price <= 0: |
||||
messages.error(request, "Cannot process an empty order.") |
||||
return redirect('shop:product_list') |
||||
|
||||
# Log payment processing |
||||
print(f"Processing payment for order #{order_id}, total: {order.total_price}") |
||||
|
||||
# Check Stripe configuration |
||||
if not _check_stripe_config(): |
||||
messages.error(request, "Stripe API keys not configured properly.") |
||||
return redirect('shop:view_cart') |
||||
|
||||
# Create line items |
||||
line_items = _create_stripe_line_items(order_items) |
||||
|
||||
if not line_items: |
||||
messages.error(request, "Cannot create payment with no items.") |
||||
return redirect('shop:view_cart') |
||||
|
||||
# Create checkout session |
||||
try: |
||||
checkout_session = _create_stripe_checkout_session(request, order, line_items) |
||||
checkout_session_id = checkout_session.id |
||||
except Exception as e: |
||||
messages.error(request, f"Payment processing error: {str(e)}") |
||||
return redirect('shop:view_cart') |
||||
|
||||
display_data = prepare_item_display_data(order_items, is_cart=False) |
||||
|
||||
# Render payment page |
||||
return render(request, 'shop/payment.html', { |
||||
'order': order, |
||||
'display_data': display_data, |
||||
'checkout_session_id': checkout_session_id, |
||||
'stripe_publishable_key': settings.STRIPE_PUBLISHABLE_KEY, |
||||
}) |
||||
|
||||
def payment_success(request, order_id): |
||||
"""Handle successful payment""" |
||||
order = get_object_or_404(Order, id=order_id) |
||||
|
||||
# Clear cart after successful payment |
||||
cart.clear_cart(request) |
||||
|
||||
# Only update if not already processed by webhook |
||||
if not order.webhook_processed: |
||||
print(f"Updating order {order_id} via redirect (not webhook)") |
||||
order.status = OrderStatus.PAID |
||||
order.payment_status = 'PAID' |
||||
order.save() |
||||
else: |
||||
print(f"Order {order_id} already processed by webhook") |
||||
|
||||
# Get order items for template |
||||
order_items = order.items.all() |
||||
display_data = prepare_item_display_data(order_items, is_cart=False) |
||||
|
||||
return render(request, 'shop/payment_success.html', { |
||||
'order': order, |
||||
'display_data': display_data, |
||||
}) |
||||
|
||||
def payment_cancel(request, order_id): |
||||
"""Handle cancelled payment""" |
||||
order = get_object_or_404(Order, id=order_id) |
||||
|
||||
# Only update if not already processed by webhook |
||||
if not order.webhook_processed: |
||||
print(f"Updating order {order_id} to CANCELED via redirect (not webhook)") |
||||
order.status = OrderStatus.CANCELED |
||||
order.payment_status = 'FAILED' |
||||
order.save() |
||||
else: |
||||
print(f"Order {order_id} already processed by webhook") |
||||
|
||||
messages.warning(request, "Your payment was cancelled.") |
||||
return render(request, 'shop/payment_cancel.html', {'order': order}) |
||||
|
||||
@require_POST |
||||
def create_checkout_session(request): |
||||
"""API endpoint to create a Stripe checkout session directly from cart""" |
||||
if not request.user.is_authenticated: |
||||
return JsonResponse({'error': 'User must be authenticated'}, status=403) |
||||
|
||||
# Create the order |
||||
order = create_order(request) |
||||
|
||||
if not order: |
||||
return JsonResponse({'error': 'Could not create order from cart'}, status=400) |
||||
|
||||
# Get order items |
||||
order_items = order.items.all() |
||||
|
||||
# Create line items |
||||
line_items = _create_stripe_line_items(order_items) |
||||
|
||||
# Create checkout session |
||||
try: |
||||
checkout_session = _create_stripe_checkout_session(request, order, line_items) |
||||
return JsonResponse({'id': checkout_session.id}) |
||||
except Exception as e: |
||||
return JsonResponse({'error': str(e)}, status=400) |
||||
|
||||
@require_POST |
||||
def update_cart_item(request): |
||||
"""Update a cart item quantity (increase/decrease)""" |
||||
item_id = request.POST.get('item_id') |
||||
action = request.POST.get('action') |
||||
|
||||
try: |
||||
cart_item = cart.get_cart_item(request, item_id) |
||||
|
||||
if action == 'increase': |
||||
# Increase quantity by 1 |
||||
cart_item.quantity += 1 |
||||
cart_item.save() |
||||
messages.success(request, "Quantity increased.") |
||||
|
||||
elif action == 'decrease' and cart_item.quantity > 1: |
||||
# Decrease quantity by 1 |
||||
cart_item.quantity -= 1 |
||||
cart_item.save() |
||||
messages.success(request, "Quantity decreased.") |
||||
|
||||
except Exception as e: |
||||
messages.error(request, f"Error updating cart: {str(e)}") |
||||
|
||||
return redirect('shop:view_cart') |
||||
|
||||
@require_POST |
||||
def remove_from_cart(request): |
||||
"""Remove an item from cart by item_id""" |
||||
item_id = request.POST.get('item_id') |
||||
|
||||
try: |
||||
cart_item = cart.get_cart_item(request, item_id) |
||||
cart_item.delete() |
||||
messages.success(request, "Item removed from cart.") |
||||
except Exception as e: |
||||
messages.error(request, f"Error removing item: {str(e)}") |
||||
|
||||
return redirect('shop:view_cart') |
||||
|
||||
def prepare_item_display_data(items, is_cart=True): |
||||
""" |
||||
Transform cart items or order items into a standardized format for display |
||||
|
||||
Args: |
||||
items: QuerySet of CartItem or OrderItem |
||||
is_cart: True if items are CartItems, False if OrderItems |
||||
|
||||
Returns: |
||||
Dictionary with standardized item data |
||||
""" |
||||
prepared_items = [] |
||||
total_quantity = 0 |
||||
total_price = 0 |
||||
|
||||
for item in items: |
||||
if is_cart: |
||||
# For CartItem |
||||
item_data = { |
||||
'id': item.id, |
||||
'product_title': item.product.title, |
||||
'product_description': item.product.description if item.product.description else None, |
||||
'color_name': item.color.name if item.color else 'N/A', |
||||
'color_hex': item.color.colorHex if item.color else '#FFFFFF', |
||||
'secondary_color_hex': item.color.secondary_hex_color if item.color and item.color.secondary_hex_color else None, |
||||
'size_name': item.size.name if item.size else 'N/A', |
||||
'quantity': item.quantity, |
||||
'total_price': item.get_total_price() |
||||
} |
||||
total_price += item.get_total_price() |
||||
else: |
||||
# For OrderItem |
||||
item_data = { |
||||
'id': item.id, |
||||
'product_title': item.product.title, |
||||
'product_description': item.product.description if item.product.description else None, |
||||
'color_name': item.color.name if item.color else 'N/A', |
||||
'color_hex': item.color.colorHex if item.color else '#FFFFFF', |
||||
'secondary_color_hex': item.color.secondary_hex_color if item.color and item.color.secondary_hex_color else None, |
||||
'size_name': item.size.name if item.size else 'N/A', |
||||
'quantity': item.quantity, |
||||
'total_price': item.get_total_price() |
||||
} |
||||
total_price += item.get_total_price() |
||||
|
||||
total_quantity += item.quantity |
||||
prepared_items.append(item_data) |
||||
|
||||
return { |
||||
'items': prepared_items, |
||||
'total_quantity': total_quantity, |
||||
'total_price': total_price |
||||
} |
||||
|
||||
|
||||
def simulate_payment_success(request): |
||||
"""Debug function to simulate successful payment without Stripe""" |
||||
# Create an order from the cart |
||||
order = create_order(request) |
||||
|
||||
if not order: |
||||
messages.error(request, "Could not create order from cart") |
||||
return redirect('shop:view_cart') |
||||
|
||||
# Clear the cart |
||||
cart.clear_cart(request) |
||||
|
||||
return redirect('shop:payment_success', order_id=order.id) |
||||
|
||||
def simulate_payment_failure(request): |
||||
"""Debug function to simulate failed payment without Stripe""" |
||||
# Create an order from the cart |
||||
order = create_order(request) |
||||
|
||||
if not order: |
||||
messages.error(request, "Could not create order from cart") |
||||
return redirect('shop:view_cart') |
||||
|
||||
return redirect('shop:payment_cancel', order_id=order.id) |
||||
|
||||
|
||||
@require_POST |
||||
@csrf_exempt |
||||
def stripe_webhook(request): |
||||
"""Handle Stripe webhook events""" |
||||
payload = request.body |
||||
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE') |
||||
|
||||
# Log the received webhook for debugging |
||||
print(f"Webhook received: {sig_header}") |
||||
|
||||
if not sig_header: |
||||
print("No signature header") |
||||
return HttpResponse(status=400) |
||||
|
||||
try: |
||||
# Use the service to verify the webhook |
||||
event = stripe_service.verify_webhook_signature(payload, sig_header) |
||||
|
||||
# Log the event type and mode |
||||
mode = "TEST" if stripe_service.is_test_mode else "LIVE" |
||||
print(f"{mode} webhook event type: {event['type']}") |
||||
|
||||
except ValueError as e: |
||||
print(f"Invalid payload: {str(e)}") |
||||
return HttpResponse(status=400) |
||||
except stripe.error.SignatureVerificationError as e: |
||||
print(f"Invalid signature: {str(e)}") |
||||
return HttpResponse(status=400) |
||||
|
||||
# Handle the event based on type |
||||
event_type = event['type'] |
||||
|
||||
if event_type == 'checkout.session.completed': |
||||
handle_checkout_session_completed(event['data']['object']) |
||||
elif event_type == 'payment_intent.succeeded': |
||||
handle_payment_intent_succeeded(event['data']['object']) |
||||
elif event_type == 'payment_intent.payment_failed': |
||||
handle_payment_intent_failed(event['data']['object']) |
||||
|
||||
# Return success response |
||||
return HttpResponse(status=200) |
||||
|
||||
def handle_checkout_session_completed(session): |
||||
"""Handle completed checkout session webhook""" |
||||
print(f"Processing checkout.session.completed for session {session.id}") |
||||
|
||||
# Get order from metadata |
||||
order_id = session.get('metadata', {}).get('order_id') |
||||
if not order_id: |
||||
print(f"Warning: No order_id in metadata for session {session.id}") |
||||
return |
||||
|
||||
try: |
||||
order = Order.objects.get(id=order_id) |
||||
|
||||
# Update order status if not already processed |
||||
if not order.webhook_processed: |
||||
print(f"Updating order {order_id} to PAID via webhook") |
||||
order.status = OrderStatus.PAID |
||||
order.payment_status = 'PAID' |
||||
order.webhook_processed = True |
||||
order.save() |
||||
|
||||
# You could add additional processing here |
||||
# - Send order confirmation email |
||||
# - Update inventory |
||||
# - Etc. |
||||
else: |
||||
print(f"Order {order_id} already processed by webhook") |
||||
|
||||
except Order.DoesNotExist: |
||||
print(f"Error: Order {order_id} not found for session {session.id}") |
||||
|
||||
def handle_payment_intent_succeeded(payment_intent): |
||||
"""Handle successful payment intent webhook""" |
||||
print(f"Processing payment_intent.succeeded for intent {payment_intent.id}") |
||||
|
||||
# If you're using payment intents directly, handle them here |
||||
# For Checkout Sessions, you'll likely rely on checkout.session.completed instead |
||||
|
||||
def handle_payment_intent_failed(payment_intent): |
||||
"""Handle failed payment intent webhook""" |
||||
print(f"Processing payment_intent.payment_failed for intent {payment_intent.id}") |
||||
|
||||
# Get order from metadata |
||||
order_id = payment_intent.get('metadata', {}).get('order_id') |
||||
if not order_id: |
||||
print(f"No order_id in metadata for payment intent {payment_intent.id}") |
||||
return |
||||
|
||||
try: |
||||
order = Order.objects.get(id=order_id) |
||||
|
||||
# Update order status |
||||
if not order.webhook_processed or order.payment_status != 'FAILED': |
||||
print(f"Updating order {order_id} to FAILED via webhook") |
||||
order.status = OrderStatus.CANCELED |
||||
order.payment_status = 'FAILED' |
||||
order.webhook_processed = True |
||||
order.save() |
||||
|
||||
except Order.DoesNotExist: |
||||
print(f"Error: Order {order_id} not found for payment intent {payment_intent.id}") |
||||
@ -0,0 +1,28 @@ |
||||
from django.contrib import messages |
||||
from django.contrib.auth import views as auth_views |
||||
from django.urls import reverse |
||||
from .forms import EmailOrUsernameAuthenticationForm |
||||
|
||||
class CustomLoginView(auth_views.LoginView): |
||||
template_name = 'registration/login.html' |
||||
authentication_form = EmailOrUsernameAuthenticationForm |
||||
|
||||
def get_success_url(self): |
||||
# First check the 'next' parameter which has higher priority |
||||
next_url = self.request.POST.get('next') or self.request.GET.get('next') |
||||
if next_url and next_url.strip(): |
||||
return next_url |
||||
|
||||
# Then check if we have a stored referrer URL |
||||
referrer = self.request.session.get('login_referrer') |
||||
if referrer: |
||||
# Clear the stored referrer to prevent reuse |
||||
del self.request.session['login_referrer'] |
||||
return referrer |
||||
|
||||
# Fall back to default |
||||
return reverse('index') |
||||
|
||||
def get(self, request, *args, **kwargs): |
||||
messages.get_messages(request).used = True |
||||
return super().get(request, *args, **kwargs) |
||||
@ -0,0 +1,19 @@ |
||||
from django.conf import settings |
||||
from django.urls import resolve, reverse |
||||
|
||||
class ReferrerMiddleware: |
||||
def __init__(self, get_response): |
||||
self.get_response = get_response |
||||
|
||||
def __call__(self, request): |
||||
# Check if the user is anonymous and going to the login page |
||||
if not request.user.is_authenticated and request.path == reverse('login'): |
||||
# Get the referring URL from the HTTP_REFERER header |
||||
referrer = request.META.get('HTTP_REFERER') |
||||
|
||||
# Only store referrer if it exists and is not the login page itself |
||||
if referrer and 'login' not in referrer: |
||||
request.session['login_referrer'] = referrer |
||||
|
||||
response = self.get_response(request) |
||||
return response |
||||
Loading…
Reference in new issue