diff --git a/shop/admin.py b/shop/admin.py index 760fbad..62fa1b3 100644 --- a/shop/admin.py +++ b/shop/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import Product, Color, Size, Order, OrderItem, GuestUser +from .models import Product, Color, Size, Order, OrderItem, GuestUser, Coupon, CouponUsage from django.utils.html import format_html @admin.register(Product) @@ -53,3 +53,33 @@ class GuestUserOrderInline(admin.TabularInline): class GuestUserAdmin(admin.ModelAdmin): list_display = ('email', 'phone') inlines = [GuestUserOrderInline] + +@admin.register(Coupon) +class CouponAdmin(admin.ModelAdmin): + list_display = ('code', 'discount_amount', 'discount_percent', 'is_active', + 'valid_from', 'valid_to', 'current_uses', 'max_uses') + list_filter = ('is_active', 'valid_from', 'valid_to') + search_fields = ('code', 'description') + readonly_fields = ('current_uses', 'created_at', 'stripe_coupon_id') + fieldsets = ( + ('Basic Information', { + 'fields': ('code', 'description', 'is_active') + }), + ('Discount', { + 'fields': ('discount_amount', 'discount_percent') + }), + ('Validity', { + 'fields': ('valid_from', 'valid_to', 'max_uses', 'current_uses') + }), + ('Stripe Information', { + 'fields': ('stripe_coupon_id',), + 'classes': ('collapse',) + }), + ) + +@admin.register(CouponUsage) +class CouponUsageAdmin(admin.ModelAdmin): + list_display = ('coupon', 'user', 'guest_email', 'order', 'used_at') + list_filter = ('used_at',) + search_fields = ('coupon__code', 'user__username', 'user__email', 'guest_email') + readonly_fields = ('used_at',) diff --git a/shop/forms.py b/shop/forms.py index a2f264b..9a9befc 100644 --- a/shop/forms.py +++ b/shop/forms.py @@ -1,5 +1,9 @@ from django import forms +from .models import Coupon class GuestCheckoutForm(forms.Form): email = forms.EmailField(required=True) phone = forms.CharField(max_length=20, required=True, label="Téléphone portable") + +class CouponApplyForm(forms.Form): + code = forms.CharField(max_length=50) diff --git a/shop/migrations/0024_coupon_order_discount_amount_order_coupon_and_more.py b/shop/migrations/0024_coupon_order_discount_amount_order_coupon_and_more.py new file mode 100644 index 0000000..ab19af1 --- /dev/null +++ b/shop/migrations/0024_coupon_order_discount_amount_order_coupon_and_more.py @@ -0,0 +1,54 @@ +# Generated by Django 5.1 on 2025-03-27 14:34 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0023_alter_color_options_color_ordering'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Coupon', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=50, unique=True)), + ('description', models.CharField(blank=True, max_length=255)), + ('discount_amount', models.DecimalField(decimal_places=2, default=0, max_digits=10)), + ('discount_percent', models.DecimalField(decimal_places=2, default=0, max_digits=5)), + ('is_active', models.BooleanField(default=True)), + ('valid_from', models.DateTimeField()), + ('valid_to', models.DateTimeField()), + ('max_uses', models.IntegerField(default=0)), + ('current_uses', models.IntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('stripe_coupon_id', models.CharField(blank=True, max_length=100, null=True)), + ], + ), + migrations.AddField( + model_name='order', + name='discount_amount', + field=models.DecimalField(decimal_places=2, default=0.0, max_digits=10), + ), + migrations.AddField( + model_name='order', + name='coupon', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.coupon'), + ), + migrations.CreateModel( + name='CouponUsage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('guest_email', models.EmailField(blank=True, max_length=254, null=True)), + ('used_at', models.DateTimeField(auto_now_add=True)), + ('coupon', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='usages', to='shop.coupon')), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='coupon_usages', to='shop.order')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/shop/migrations/0025_alter_product_cut.py b/shop/migrations/0025_alter_product_cut.py new file mode 100644 index 0000000..3c68b31 --- /dev/null +++ b/shop/migrations/0025_alter_product_cut.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1 on 2025-03-27 17:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0024_coupon_order_discount_amount_order_coupon_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='product', + name='cut', + field=models.IntegerField(choices=[(0, 'Unisex'), (1, 'Women'), (2, 'Men'), (3, 'Kids')], default=0), + ), + ] diff --git a/shop/models.py b/shop/models.py index 94f3f38..14f64ed 100644 --- a/shop/models.py +++ b/shop/models.py @@ -9,6 +9,7 @@ class OrderStatus(models.TextChoices): CANCELED = 'CANCELED', 'Canceled' class CutChoices(models.IntegerChoices): + UNISEX = 0, 'Unisex' WOMEN = 1, 'Women' MEN = 2, 'Men' KIDS = 3, 'Kids' @@ -43,7 +44,7 @@ class Product(models.Model): 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) + cut = models.IntegerField(choices=CutChoices.choices, default=CutChoices.UNISEX) class Meta: ordering = ['ordering_value', 'cut'] # Add this line to sort by title @@ -76,6 +77,38 @@ class GuestUser(models.Model): def __str__(self): return f"{self.email}" +class Coupon(models.Model): + code = models.CharField(max_length=50, unique=True) + description = models.CharField(max_length=255, blank=True) + discount_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0) + discount_percent = models.DecimalField(max_digits=5, decimal_places=2, default=0) + is_active = models.BooleanField(default=True) + valid_from = models.DateTimeField() + valid_to = models.DateTimeField() + max_uses = models.IntegerField(default=0) # 0 for unlimited + current_uses = models.IntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + stripe_coupon_id = models.CharField(max_length=100, blank=True, null=True) + + def __str__(self): + return self.code + + def is_valid(self): + from django.utils import timezone + now = timezone.now() + if not self.is_active: + return False + if now < self.valid_from or now > self.valid_to: + return False + if self.max_uses > 0 and self.current_uses >= self.max_uses: + return False + return True + + def get_discount_amount_for_total(self, total_price): + """Calculate the discount amount for a given total price""" + if self.discount_percent > 0: + return (self.discount_percent / 100) * total_price + return self.discount_amount class Order(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True) @@ -95,10 +128,15 @@ class Order(models.Model): ('test', 'Test Mode'), ('live', 'Live Mode'), ]) + coupon = models.ForeignKey(Coupon, on_delete=models.SET_NULL, null=True, blank=True) + discount_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0.00) def __str__(self): return f"Order #{self.id} - {self.status}" + def get_total_after_discount(self): + return max(self.total_price - self.discount_amount, 0) + class OrderItem(models.Model): order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items') product = models.ForeignKey(Product, on_delete=models.CASCADE) @@ -115,3 +153,14 @@ class OrderItem(models.Model): def get_total_price(self): return self.price * self.quantity + +class CouponUsage(models.Model): + coupon = models.ForeignKey(Coupon, on_delete=models.CASCADE, related_name='usages') + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True) + order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='coupon_usages') + guest_email = models.EmailField(blank=True, null=True) + used_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + user_identifier = self.user.username if self.user else self.guest_email + return f"{self.coupon.code} - {user_identifier} - {self.used_at}" diff --git a/shop/signals.py b/shop/signals.py index 70ed58a..025c7a5 100644 --- a/shop/signals.py +++ b/shop/signals.py @@ -66,6 +66,21 @@ def _get_order_details(instance): "UNPAID": "NON PAYÉE", "PAID": "PAYÉE", "FAILED": "ÉCHOUÉE" } + # Calculate discount information + has_coupon = instance.coupon is not None + coupon_info = "" + final_price = instance.total_price + + if has_coupon: + coupon_code = instance.coupon.code + discount_amount = instance.discount_amount + final_price = instance.get_total_after_discount() + + if instance.coupon.discount_percent > 0: + coupon_info = f"Code promo: {coupon_code} ({instance.coupon.discount_percent}%)" + else: + coupon_info = f"Code promo: {coupon_code} (€{discount_amount})" + return { 'order_id': instance.id, 'status': instance.status, @@ -73,6 +88,10 @@ def _get_order_details(instance): 'payment_status': instance.payment_status, 'payment_status_fr': payment_status_fr_map.get(instance.payment_status, instance.payment_status), 'total_price': instance.total_price, + 'has_coupon': has_coupon, + 'coupon_info': coupon_info, + 'discount_amount': instance.discount_amount if has_coupon else 0, + 'final_price': final_price, 'customer_info': customer_info, 'customer_email': customer_email, 'date_ordered': instance.date_ordered, @@ -101,13 +120,22 @@ def _send_internal_notification(instance, action, order_details, items_list): """Send notification email to shop managers.""" action_fr = _translate_action(action) + # Build price information with coupon details if applicable + price_info = f"Prix total: {order_details['total_price']}€" + if order_details['has_coupon']: + price_info = f""" +Prix total: {order_details['total_price']}€ +{order_details['coupon_info']} +Réduction: -{order_details['discount_amount']}€ +Montant payé: {order_details['final_price']}€""" + 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']}€ +{price_info} {order_details['customer_info']} @@ -147,6 +175,10 @@ def _send_customer_notification(instance, order_details, items_list): date_formatted, order_details['status_fr'], order_details['total_price'], + order_details['has_coupon'], + order_details['coupon_info'], + order_details['discount_amount'], + order_details['final_price'], items_list, contact_email, shop_url @@ -166,15 +198,24 @@ def _send_customer_notification(instance, order_details, items_list): ) def _get_customer_email_content(status, payment_status, order_id, date, status_fr, - total_price, items_list, contact_email, shop_url): + total_price, has_coupon, coupon_info, discount_amount, + final_price, items_list, contact_email, shop_url): """Get the appropriate customer email content based on order status.""" + # Build price information with coupon details if applicable + price_info = f"Prix total: {total_price}€" + if has_coupon: + price_info = f"""Prix total: {total_price}€ +{coupon_info} +Réduction: -{discount_amount}€ +Montant payé: {final_price}€""" + # Payment confirmation email if status == OrderStatus.PAID and payment_status == "PAID": return { - 'subject': f"Confirmation de votre commande #{order_id} - PadelClub", + 'subject': f"Confirmation de votre commande #{order_id} - Padel Club", 'message': _build_payment_confirmation_email(order_id, date, status_fr, - total_price, items_list, + price_info, items_list, contact_email, shop_url) } @@ -187,46 +228,47 @@ def _get_customer_email_content(status, payment_status, order_id, date, status_f }.get(status, "") return { - 'subject': f"Mise à jour de votre commande #{order_id} - PadelClub", + 'subject': f"Mise à jour de votre commande #{order_id} - Padel Club", 'message': _build_status_update_email(order_id, date, status_message, status_fr, - total_price, items_list, contact_email) + price_info, 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, + 'subject': f"Problème de paiement pour votre commande #{order_id} - Padel Club", + 'message': _build_payment_issue_email(order_id, date, price_info, 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, + 'subject': f"Rappel de paiement pour votre commande #{order_id} - Padel Club", + 'message': _build_payment_reminder_email(order_id, date, price_info, 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): +def _build_payment_confirmation_email(order_id, date, status_fr, price_info, items_list, contact_email, shop_url): """Build payment confirmation email message.""" return f""" Bonjour, -Nous vous remercions pour votre commande sur PadelClub ! +Nous vous remercions pour votre commande sur Padel Club ! Récapitulatif de votre commande #{order_id} du {date} : Statut: {status_fr} -Prix total: {total_price}€ +{price_info} Détail de votre commande : {items_list} -Nous nous occupons de préparer votre commande dans les plus brefs délais. +IMPORTANT - COMMENT RÉCUPÉRER VOTRE COMMANDE : +Notre boutique fonctionne entre amis 'Padel Club'. Nous allons préparer votre commande et vous la remettre en main propre lors d'une prochaine session de padel ! Aucune expédition n'est prévue, nous vous remettrons directement vos articles sur place. Pour toute question concernant votre commande, n'hésitez pas à contacter notre service client : {contact_email} @@ -234,22 +276,22 @@ Pour toute question concernant votre commande, n'hésitez pas à contacter notre Visitez notre boutique pour découvrir d'autres produits : {shop_url} -Merci de votre confiance et à bientôt sur PadelClub ! +Merci de votre confiance et à bientôt sur Padel Club ! -L'équipe PadelClub +L'équipe Padel Club """ -def _build_status_update_email(order_id, date, status_message, status_fr, total_price, items_list, contact_email): +def _build_status_update_email(order_id, date, status_message, status_fr, price_info, items_list, contact_email): """Build status update email message.""" return f""" Bonjour, -Mise à jour concernant votre commande PadelClub #{order_id} du {date} : +Mise à jour concernant votre commande Padel Club #{order_id} du {date} : {status_message} Statut actuel: {status_fr} -Prix total: {total_price}€ +{price_info} Détail de votre commande : {items_list} @@ -257,21 +299,21 @@ Détail de votre commande : 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 ! +Merci de votre confiance et à bientôt sur Padel Club ! -L'équipe PadelClub +L'équipe Padel Club """ -def _build_payment_issue_email(order_id, date, total_price, items_list, contact_email, shop_url): +def _build_payment_issue_email(order_id, date, price_info, 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}. +Nous avons rencontré un problème lors du traitement du paiement de votre commande Padel Club #{order_id}. Détails de la commande : Date: {date} -Prix total: {total_price}€ +{price_info} Articles: {items_list} @@ -284,18 +326,18 @@ Vous pouvez également visiter notre boutique pour finaliser votre achat : Merci de votre compréhension. -L'équipe PadelClub +L'équipe Padel Club """ -def _build_payment_reminder_email(order_id, date, total_price, items_list, contact_email): +def _build_payment_reminder_email(order_id, date, price_info, 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. +Nous vous rappelons que votre commande Padel Club #{order_id} du {date} n'a pas encore été payée. Détails de la commande : -Prix total: {total_price}€ +{price_info} Articles: {items_list} @@ -307,7 +349,7 @@ Si vous rencontrez des difficultés ou si vous avez des questions, n'hésitez pa Merci de votre confiance. -L'équipe PadelClub +L'équipe Padel Club """ @receiver(user_logged_in) diff --git a/shop/static/shop/css/shop.css b/shop/static/shop/css/shop.css index e71a1f6..105aaf3 100644 --- a/shop/static/shop/css/shop.css +++ b/shop/static/shop/css/shop.css @@ -119,13 +119,28 @@ height: 36px; } +.coupon-section { + color: #707070; + font-size: 12px; + font-weight: 600; + text-decoration: none; + margin-top: 20px; +} + .confirm-nav-button { background-color: #90ee90; + color: #707070; + font-size: 12px; + font-weight: 600; + text-decoration: none; } .cancel-nav-button { background-color: #e84039; color: white; + font-size: 12px; + font-weight: 600; + text-decoration: none; } .remove-btn { diff --git a/shop/stripe_utils.py b/shop/stripe_utils.py index a6a5934..2e7cba1 100644 --- a/shop/stripe_utils.py +++ b/shop/stripe_utils.py @@ -25,21 +25,31 @@ class StripeService: mode_str = "TEST" if self.is_test_mode else "LIVE" logger.debug(f"Initialized StripeService in {mode_str} mode") - def create_checkout_session(self, customer_email, 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( - customer_email=customer_email, - payment_method_types=['card'], - line_items=line_items, - mode='payment', - success_url=success_url, - cancel_url=cancel_url, - metadata=metadata or {}, - ) - return session + def create_checkout_session(self, customer_email=None, line_items=None, + success_url=None, cancel_url=None, metadata=None, + discounts=None): + """Create a Stripe checkout session with the given parameters""" + + # Initialize session parameters + session_params = { + 'payment_method_types': ['card'], + 'line_items': line_items, + 'mode': 'payment', + 'success_url': success_url, + 'cancel_url': cancel_url, + 'metadata': metadata or {}, + } + + # Add customer email if provided + if customer_email: + session_params['customer_email'] = customer_email + + # Add discounts if provided + if discounts: + session_params['discounts'] = discounts + + # Create and return the session + return stripe.checkout.Session.create(**session_params) def verify_webhook_signature(self, payload, signature): """Verify webhook signature using mode-appropriate secret""" diff --git a/shop/templates/shop/cart.html b/shop/templates/shop/cart.html index af2d6ec..ced18b3 100644 --- a/shop/templates/shop/cart.html +++ b/shop/templates/shop/cart.html @@ -29,8 +29,44 @@
Cette boutique fonctionne entre amis 'Padel Club'. Les commandes sont :
+Pas d'expédition : nous vous remettrons votre commande personnellement au club !
+