diff --git a/shop/admin.py b/shop/admin.py
index 72d69c6..9c61ae7 100644
--- a/shop/admin.py
+++ b/shop/admin.py
@@ -1,7 +1,10 @@
from django.contrib import admin
from django.shortcuts import render
-from .models import Product, Color, Size, Order, OrderItem, GuestUser, Coupon, CouponUsage, OrderStatus
+from .models import Product, Color, Size, Order, OrderItem, GuestUser, Coupon, CouponUsage, OrderStatus, ShippingAddress
from django.utils.html import format_html
+from django.urls import path
+from django.contrib import admin
+from django.shortcuts import redirect
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
@@ -37,11 +40,63 @@ class OrderItemInline(admin.TabularInline):
extra = 0
readonly_fields = ('product', 'quantity', 'color', 'size', 'price')
+@admin.register(ShippingAddress)
+class ShippingAddressAdmin(admin.ModelAdmin):
+ list_display = ('street_address', 'city', 'postal_code', 'country')
+ search_fields = ('street_address', 'city', 'postal_code', 'country')
+
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
- list_display = ('id', 'date_ordered', 'status', 'total_price')
+ list_display = ('id', 'date_ordered', 'status', 'total_price', 'get_shipping_address')
inlines = [OrderItemInline]
list_filter = ('status', 'payment_status')
+ readonly_fields = ('shipping_address_details',)
+
+ def get_shipping_address(self, obj):
+ if obj.shipping_address:
+ return f"{obj.shipping_address.street_address}, {obj.shipping_address.city}"
+ return "No shipping address"
+ get_shipping_address.short_description = 'Shipping Address'
+
+ def shipping_address_details(self, obj):
+ if obj.shipping_address:
+ return format_html(
+ """
+
+ Street: {}
+ {}
+ City: {}
+ State: {}
+ Postal Code: {}
+ Country: {}
+
+ """,
+ obj.shipping_address.street_address,
+ f"Apartment: {obj.shipping_address.apartment} " if obj.shipping_address.apartment else "",
+ obj.shipping_address.city,
+ obj.shipping_address.state,
+ obj.shipping_address.postal_code,
+ obj.shipping_address.country,
+ )
+ return "No shipping address set"
+ shipping_address_details.short_description = 'Shipping Address Details'
+
+ fieldsets = (
+ (None, {
+ 'fields': ('user', 'guest_user', 'status', 'payment_status', 'total_price')
+ }),
+ ('Shipping Information', {
+ 'fields': ('shipping_address_details',),
+ }),
+ ('Payment Details', {
+ 'fields': ('stripe_payment_intent_id', 'stripe_checkout_session_id', 'stripe_mode'),
+ 'classes': ('collapse',)
+ }),
+ ('Discount Information', {
+ 'fields': ('coupon', 'discount_amount'),
+ 'classes': ('collapse',)
+ }),
+ )
def changelist_view(self, request, extra_context=None):
# If 'show_preparation' parameter is in the request, show the preparation view
@@ -103,6 +158,41 @@ class OrderAdmin(admin.ModelAdmin):
context
)
+ def get_urls(self):
+ urls = super().get_urls()
+ custom_urls = [
+ path('prepare-all-orders/', self.admin_site.admin_view(self.prepare_all_orders), name='prepare_all_orders'),
+ path('prepare-order//', self.admin_site.admin_view(self.prepare_order), name='prepare_order'),
+ path('cancel-and-refund-order//', self.admin_site.admin_view(self.cancel_and_refund_order), name='cancel_and_refund_order'),
+ ]
+ return custom_urls + urls
+
+ def prepare_all_orders(self, request):
+ if request.method == 'POST':
+ Order.objects.filter(status=OrderStatus.PAID).update(status=OrderStatus.PREPARED)
+ self.message_user(request, "All orders have been marked as prepared.")
+ return redirect('admin:shop_order_changelist')
+
+ def prepare_order(self, request, order_id):
+ if request.method == 'POST':
+ order = Order.objects.get(id=order_id)
+ order.status = OrderStatus.PREPARED
+ order.save()
+ self.message_user(request, f"Order #{order_id} has been marked as prepared.")
+ return redirect('admin:shop_order_changelist')
+
+ def cancel_and_refund_order(self, request, order_id):
+ if request.method == 'POST':
+ order = Order.objects.get(id=order_id)
+ try:
+ # Reuse the cancel_order logic from your views
+ from .views import cancel_order
+ cancel_order(request, order_id)
+ self.message_user(request, f"Order #{order_id} has been cancelled and refunded.")
+ except Exception as e:
+ self.message_user(request, f"Error cancelling order: {str(e)}", level='ERROR')
+ return redirect('admin:shop_order_changelist')
+
class GuestUserOrderInline(admin.TabularInline):
model = Order
extra = 0
diff --git a/shop/forms.py b/shop/forms.py
index 9a9befc..76e5b53 100644
--- a/shop/forms.py
+++ b/shop/forms.py
@@ -1,5 +1,6 @@
from django import forms
from .models import Coupon
+from .models import ShippingAddress
class GuestCheckoutForm(forms.Form):
email = forms.EmailField(required=True)
@@ -7,3 +8,15 @@ class GuestCheckoutForm(forms.Form):
class CouponApplyForm(forms.Form):
code = forms.CharField(max_length=50)
+
+class ShippingAddressForm(forms.ModelForm):
+ class Meta:
+ model = ShippingAddress
+ fields = ['street_address', 'apartment', 'city', 'postal_code', 'country']
+ widgets = {
+ 'street_address': forms.TextInput(attrs={'placeholder': 'Adresse'}),
+ 'apartment': forms.TextInput(attrs={'placeholder': 'Appartement (optionnel)'}),
+ 'city': forms.TextInput(attrs={'placeholder': 'Ville'}),
+ 'postal_code': forms.TextInput(attrs={'placeholder': 'Code postal'}),
+ 'country': forms.TextInput(attrs={'placeholder': 'Pays'}),
+ }
diff --git a/shop/migrations/0027_shippingaddress_alter_order_payment_status_and_more.py b/shop/migrations/0027_shippingaddress_alter_order_payment_status_and_more.py
new file mode 100644
index 0000000..fba6820
--- /dev/null
+++ b/shop/migrations/0027_shippingaddress_alter_order_payment_status_and_more.py
@@ -0,0 +1,41 @@
+# Generated by Django 5.1 on 2025-05-06 10:21
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('shop', '0026_alter_order_user'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ShippingAddress',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('street_address', models.CharField(max_length=255)),
+ ('apartment', models.CharField(blank=True, max_length=50, null=True)),
+ ('city', models.CharField(max_length=100)),
+ ('state', models.CharField(blank=True, max_length=100, null=True)),
+ ('postal_code', models.CharField(max_length=20)),
+ ('country', models.CharField(max_length=100)),
+ ],
+ ),
+ migrations.AlterField(
+ model_name='order',
+ name='payment_status',
+ field=models.CharField(choices=[('UNPAID', 'Unpaid'), ('PAID', 'Paid'), ('FAILED', 'Failed'), ('REFUNDED', 'Refunded')], default='UNPAID', max_length=20),
+ ),
+ migrations.AlterField(
+ model_name='order',
+ name='status',
+ field=models.CharField(choices=[('PENDING', 'Pending'), ('PAID', 'Paid'), ('SHIPPED', 'Shipped'), ('DELIVERED', 'Delivered'), ('CANCELED', 'Canceled'), ('REFUNDED', 'Refunded'), ('PREPARED', 'Prepared')], default='PENDING', max_length=20),
+ ),
+ migrations.AddField(
+ model_name='order',
+ name='shipping_address',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.shippingaddress'),
+ ),
+ ]
diff --git a/shop/models.py b/shop/models.py
index 55e8345..ae55bbe 100644
--- a/shop/models.py
+++ b/shop/models.py
@@ -8,6 +8,8 @@ class OrderStatus(models.TextChoices):
SHIPPED = 'SHIPPED', 'Shipped'
DELIVERED = 'DELIVERED', 'Delivered'
CANCELED = 'CANCELED', 'Canceled'
+ REFUNDED = 'REFUNDED', 'Refunded'
+ PREPARED = 'PREPARED', 'Prepared'
class CutChoices(models.IntegerChoices):
UNISEX = 0, 'Unisex'
@@ -71,6 +73,14 @@ class CartItem(models.Model):
def get_total_price(self):
return self.product.price * self.quantity
+class ShippingAddress(models.Model):
+ street_address = models.CharField(max_length=255)
+ apartment = models.CharField(max_length=50, blank=True, null=True)
+ city = models.CharField(max_length=100)
+ state = models.CharField(max_length=100, blank=True, null=True)
+ postal_code = models.CharField(max_length=20)
+ country = models.CharField(max_length=100)
+
class GuestUser(models.Model):
email = models.EmailField()
phone = models.CharField(max_length=20)
@@ -112,6 +122,7 @@ class Coupon(models.Model):
class Order(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True)
+ shipping_address = models.ForeignKey(ShippingAddress, on_delete=models.SET_NULL, null=True, blank=True)
date_ordered = models.DateTimeField(auto_now_add=True)
status = models.CharField(max_length=20, choices=OrderStatus.choices, default=OrderStatus.PENDING)
total_price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00)
@@ -122,6 +133,7 @@ class Order(models.Model):
('UNPAID', 'Unpaid'),
('PAID', 'Paid'),
('FAILED', 'Failed'),
+ ('REFUNDED', 'Refunded')
])
webhook_processed = models.BooleanField(default=False)
stripe_mode = models.CharField(max_length=10, default='test', choices=[
@@ -137,6 +149,13 @@ class Order(models.Model):
def get_total_after_discount(self):
return max(self.total_price - self.discount_amount, 0)
+ def is_cancellable(self):
+ """Check if the order can be cancelled"""
+ return self.status in [OrderStatus.PENDING, OrderStatus.PAID]
+
+ def shipping_address_can_be_edited(self):
+ return self.status in [OrderStatus.PENDING, OrderStatus.PAID, OrderStatus.PREPARED]
+
class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items')
product = models.ForeignKey(Product, on_delete=models.CASCADE)
diff --git a/shop/static/shop/css/shop.css b/shop/static/shop/css/shop.css
index 1384488..11e764f 100644
--- a/shop/static/shop/css/shop.css
+++ b/shop/static/shop/css/shop.css
@@ -546,3 +546,229 @@ v .cart-table {
border: 3px solid #90ee90 !important; /* Use your light-green color */
transform: scale(1.1); /* Makes the selected color slightly larger */
}
+
+.status-badge {
+ padding: 4px 8px;
+ border-radius: 12px;
+ font-size: 12px;
+ color: white;
+ display: inline-block;
+}
+.status-badge.pending {
+ background-color: #f39200;
+}
+.status-badge.paid {
+ background-color: #27ae60;
+}
+.status-badge.shipped {
+ background-color: #3498db;
+}
+.status-badge.delivered {
+ background-color: #2c3e50;
+}
+.status-badge.canceled {
+ background-color: #e74c3c;
+}
+.status-badge.refunded {
+ background-color: #e74c3c;
+}
+.status-badge.prepared {
+ background-color: #27ae60;
+}
+
+.original-price {
+ text-decoration: line-through;
+ color: #777;
+ font-size: 0.9em;
+ display: block;
+ font-weight: bold;
+}
+
+.discounted-price {
+ font-weight: bold;
+}
+
+.view-btn {
+ background-color: #3498db;
+ color: white;
+ border: none;
+ padding: 5px 10px;
+ border-radius: 12px;
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 600;
+ transition: background-color 0.3s ease;
+}
+
+.view-btn:hover {
+ background-color: #2980b9;
+ color: white;
+}
+
+.inline-form {
+ display: inline;
+}
+
+.empty-orders {
+ text-align: center;
+ padding: 20px;
+}
+
+.actions {
+ text-align: center;
+}
+
+.order-meta {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 10px;
+ margin-bottom: 20px;
+ border-bottom: 1px solid #eee;
+ padding-bottom: 15px;
+}
+
+.discount-section {
+ margin-top: 20px;
+ border-top: 1px dashed #eee;
+ padding-top: 15px;
+}
+
+.discount-row {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 5px;
+}
+
+.total-row {
+ font-weight: bold;
+ margin-top: 5px;
+ padding-top: 5px;
+ border-top: 1px solid #eee;
+}
+
+.coupon-info {
+ margin-top: 10px;
+ font-size: 0.9em;
+ color: #666;
+}
+
+.order-actions {
+ margin-top: 20px;
+ text-align: right;
+ padding-top: 15px;
+ border-top: 1px solid #eee;
+}
+
+.order-items-section {
+ margin-bottom: 20px;
+}
+
+.order-items-section h3 {
+ margin-bottom: 10px;
+ padding-bottom: 5px;
+ border-bottom: 1px solid #eee;
+}
+
+.order-table {
+ width: 100%;
+ border-collapse: separate;
+ border-spacing: 0;
+ border-radius: 12px;
+ overflow: hidden;
+ padding: 0px;
+}
+
+.order-table tbody tr.odd-row {
+ background-color: #f0f0f0;
+}
+
+.order-table tbody tr.even-row {
+ background-color: #e8e8e8;
+}
+
+.shipping-address-section {
+ padding: 15px;
+ background: #f9f9f9;
+ border-radius: 5px;
+}
+
+.address-details {
+ margin: 10px 0;
+}
+
+.address-actions {
+ margin-top: 10px;
+}
+
+.edit-address-btn,
+.add-address-btn {
+ background-color: #007bff;
+ color: white;
+ border: none;
+ padding: 5px 10px;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.edit-address-btn:hover,
+.add-address-btn:hover {
+ background-color: #0056b3;
+}
+
+.shipping-address-form {
+ margin-top: 15px;
+ padding: 15px;
+ background: #fff;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+}
+
+.form-actions {
+ margin-top: 10px;
+ display: flex;
+ gap: 10px;
+}
+
+.save-btn {
+ background-color: #27ae60;
+ color: white;
+ border: none;
+ padding: 5px 15px;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.cancel-btn {
+ background-color: #dc3545;
+ color: white;
+ border: none;
+ padding: 5px 15px;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.save-btn:hover {
+ background-color: #218838;
+}
+
+.cancel-btn:hover {
+ background-color: #c82333;
+}
+
+.address-input {
+ padding: 8px;
+ border: 1px solid #ddd;
+ border-radius: 12px;
+ font-size: 14px;
+}
+
+.address-section {
+ margin: 20px 0;
+ padding: 15px;
+ border-radius: 5px;
+}
+
+#address-message {
+ margin-top: 10px;
+ font-size: 14px;
+}
diff --git a/shop/stripe_utils.py b/shop/stripe_utils.py
index 2fe4ede..15e7a87 100644
--- a/shop/stripe_utils.py
+++ b/shop/stripe_utils.py
@@ -76,5 +76,65 @@ class StripeService:
"""Retrieve a payment intent by ID"""
return stripe.PaymentIntent.retrieve(payment_intent_id)
+ def create_refund(self, payment_intent_id, amount=None, reason=None):
+ """
+ Create a refund for a payment intent
+
+ Args:
+ payment_intent_id (str): The payment intent ID to refund
+ amount (int, optional): Amount to refund in cents. If None, refunds the entire amount.
+ reason (str, optional): The reason for the refund, one of 'duplicate', 'fraudulent', or 'requested_by_customer'
+
+ Returns:
+ stripe.Refund: The created refund object
+ """
+ try:
+ refund_params = {
+ 'payment_intent': payment_intent_id,
+ }
+
+ if amount is not None:
+ refund_params['amount'] = amount
+
+ if reason in ['duplicate', 'fraudulent', 'requested_by_customer']:
+ refund_params['reason'] = reason
+
+ # Log the refund attempt
+ mode_str = "TEST" if self.is_test_mode else "LIVE"
+ logger.info(f"[{mode_str}] Creating refund for payment intent {payment_intent_id}")
+
+ # Process the refund
+ refund = stripe.Refund.create(**refund_params)
+
+ # Log success
+ logger.info(f"Refund created successfully: {refund.id}")
+
+ return refund
+
+ except stripe.error.StripeError as e:
+ # Log the error
+ logger.error(f"Stripe error creating refund: {str(e)}")
+ raise
+ except Exception as e:
+ # Log any other errors
+ logger.error(f"Unexpected error creating refund: {str(e)}")
+ raise
+
+ def get_refund(self, refund_id):
+ """
+ Retrieve a refund by ID
+
+ Args:
+ refund_id (str): The ID of the refund to retrieve
+
+ Returns:
+ stripe.Refund: The refund object
+ """
+ try:
+ return stripe.Refund.retrieve(refund_id)
+ except stripe.error.StripeError as e:
+ logger.error(f"Stripe error retrieving refund {refund_id}: {str(e)}")
+ raise
+
# Create a singleton instance for import and use throughout the app
stripe_service = StripeService()
diff --git a/shop/templates/admin/shop/order/preparation_view.html b/shop/templates/admin/shop/order/preparation_view.html
index 63213a5..fd6566a 100644
--- a/shop/templates/admin/shop/order/preparation_view.html
+++ b/shop/templates/admin/shop/order/preparation_view.html
@@ -5,8 +5,20 @@
Total orders with status PAID: {{ total_orders }}
Total items to prepare: {{ total_items }}
- Print This Page
- Back to Orders
+
Items Summary
@@ -35,7 +47,7 @@
{{ item.quantity }}
{% for order_id in item.orders %}
- Order #{{ order_id }} {% if not forloop.last %}, {% endif %}
+ Order #{{ order_id }} {% if not forloop.last %}, {% endif %}
{% endfor %}
@@ -54,13 +66,15 @@
Order #
Date
Customer
+ Shipping Address
Items
+ Actions
{% for order in orders %}
- Order #{{ order.id }}
+ Order #{{ order.id }}
{{ order.date_ordered|date:"Y-m-d H:i" }}
{% if order.user %}
@@ -71,6 +85,16 @@
Unknown
{% endif %}
+
+ {% if order.shipping_address %}
+ {{ order.shipping_address.street_address }}
+ {% if order.shipping_address.apartment %}, {{ order.shipping_address.apartment }}{% endif %}
+ {{ order.shipping_address.postal_code }} {{ order.shipping_address.city }}
+ {{ order.shipping_address.state }}, {{ order.shipping_address.country }}
+ {% else %}
+ No shipping address
+ {% endif %}
+
{% for item in order.items.all %}
{{ item.quantity }}x {{ item.product.title }}
@@ -79,10 +103,28 @@
{% endfor %}
+
+
+
+
+
{% empty %}
- No orders found
+ No orders found
{% endfor %}
diff --git a/shop/templates/shop/cart.html b/shop/templates/shop/cart.html
index d9d2a3f..43f5401 100644
--- a/shop/templates/shop/cart.html
+++ b/shop/templates/shop/cart.html
@@ -5,17 +5,7 @@
{% block second_title %}La Boutique{% endblock %}
{% block content %}
-
- La Boutique
- Accueil
- Clubs
- {% if user.is_authenticated %}
- Mes tournois
- Mon compte
- {% else %}
- Se connecter
- {% endif %}
-
+{% include 'shop/partials/navigation_base.html' %}
{% if STRIPE_IS_TEST_MODE %}
⚠️ Test Mode: Stripe is currently in test mode. No real payments will be processed.
@@ -26,18 +16,18 @@
-
Votre panier
+
Votre panier
{% if display_data.items %}
-
+
Comment fonctionne la livraison ?
Cette boutique fonctionne entre amis 'Padel Club'. Les commandes sont :
Passées en ligne via notre système
Préparées par notre équipe
- Remises en main propre lors d'une prochaine session de padel
+ Remises en main propre lors d'une prochaine session de padel ou livrées à l'adresse indiquée dans la mesure du possible
-
Pas d'expédition : nous vous remettrons votre commande personnellement au club !
+
Livraison : En général, nous vous remettrons votre commande personnellement au club. Les livraisons peuvent être possible en fonction du lieu, n'hésitez donc pas à indiquer une adresse de livraison.
{% include 'shop/partials/order_items_display.html' with items=display_data.items total_quantity=display_data.total_quantity total_price=display_data.total_price edit_mode=True %}
@@ -67,6 +57,24 @@
+
+
Adresse de livraison (dans la mesure du possible)
+
+
+
+
+
+
{% if user.is_authenticated %}
@@ -123,6 +131,17 @@
const subtotalAmount = document.getElementById('subtotal-amount');
const discountAmount = document.getElementById('discount-amount');
const finalTotal = document.getElementById('final-total');
+ const shippingForm = document.getElementById('shipping-form');
+
+ // Function to collect shipping address data
+ function getShippingData() {
+ const formData = new FormData(shippingForm);
+ const shippingData = {};
+ formData.forEach((value, key) => {
+ shippingData[key] = value;
+ });
+ return shippingData;
+ }
// Initial values
const originalTotal = parseFloat('{{ display_data.total_price }}');
@@ -244,6 +263,9 @@
checkoutButton.textContent = 'Chargement...';
checkoutButton.disabled = true;
+ // Get shipping data
+ const shippingData = getShippingData();
+
// Create order and get checkout session
fetch('{% url "shop:create_checkout_session" %}', {
method: 'POST',
@@ -251,6 +273,9 @@
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
+ body: JSON.stringify({
+ shipping_address: shippingData
+ }),
credentials: 'same-origin',
})
.then(function(response) {
diff --git a/shop/templates/shop/checkout.html b/shop/templates/shop/checkout.html
index 15098ed..ee57c96 100644
--- a/shop/templates/shop/checkout.html
+++ b/shop/templates/shop/checkout.html
@@ -6,18 +6,9 @@
{% block content %}
-
- La Boutique
- Accueil
- Clubs
- {% if user.is_authenticated %}
- Mes tournois
- Mon compte
- {% else %}
- Se connecter
- {% endif %}
-
-
Validation de la commande
+{% include 'shop/partials/navigation_base.html' %}
+
+
Validation de la commande
diff --git a/shop/templates/shop/my_orders.html b/shop/templates/shop/my_orders.html
new file mode 100644
index 0000000..304fa71
--- /dev/null
+++ b/shop/templates/shop/my_orders.html
@@ -0,0 +1,81 @@
+{% extends 'tournaments/base.html' %}
+
+{% block head_title %}Mes Commandes{% endblock %}
+{% block first_title %}Padel Club{% endblock %}
+{% block second_title %}Mes Commandes{% endblock %}
+
+{% block content %}
+{% include 'shop/partials/navigation_base.html' %}
+
+
+
+
Mes Commandes
+
+ {% if orders %}
+
+
+ {% for order in orders %}
+
+ Commande #{{ order.id }}
+
+ Détails
+
+ {{ order.date_ordered|date:"d/m/Y H:i" }}
+
+ {% if order.status == 'PENDING' %}
+ En attente
+ {% elif order.status == 'PAID' %}
+ Payée
+ {% elif order.status == 'PREPARED' %}
+ En cours de préparation
+ {% elif order.status == 'SHIPPED' %}
+ Expédiée
+ {% elif order.status == 'DELIVERED' %}
+ Livrée
+ {% elif order.status == 'CANCELED' %}
+ Annulée
+ {% elif order.status == 'REFUNDED' %}
+ Remboursée
+ {% endif %}
+
+
+ {% if order.discount_amount > 0 %}
+ {{ order.total_price }}€
+ {{ order.get_total_after_discount }}€
+ {% else %}
+ {{ order.total_price }}€
+ {% endif %}
+
+
+ {% if order.status == 'PENDING' or order.status == 'PAID' %}
+
+ {% endif %}
+
+
+ {% endfor %}
+
+
+ {% else %}
+
+ {% endif %}
+
+
+ {% if messages %}
+
+ {% for message in messages %}
+
+ {{ message }}
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/shop/templates/shop/order_detail.html b/shop/templates/shop/order_detail.html
new file mode 100644
index 0000000..25f5f4b
--- /dev/null
+++ b/shop/templates/shop/order_detail.html
@@ -0,0 +1,150 @@
+{% extends 'tournaments/base.html' %}
+
+{% block head_title %}Détail de commande{% endblock %}
+{% block first_title %}Padel Club{% endblock %}
+{% block second_title %}Détail de commande{% endblock %}
+
+{% block content %}
+{% include 'shop/partials/navigation_base.html' %}
+
+
+
+
Commande #{{ order.id }}
+
+
+
+
+
Produits
+ {% with items=order_items total_quantity=total_quantity total_price=order.total_price %}
+ {% include 'shop/partials/order_items_display.html' with items=items total_quantity=total_quantity total_price=total_price edit_mode=False cancel_mode=order.is_cancellable %}
+ {% endwith %}
+
+
+
+
Adresse de livraison (dans la mesure du possible)
+ {% if order.shipping_address %}
+
+
{{ order.shipping_address.street_address }}
+ {% if order.shipping_address.apartment %}
+
{{ order.shipping_address.apartment }}
+ {% endif %}
+
{{ order.shipping_address.postal_code }} {{ order.shipping_address.city }}, {{ order.shipping_address.country }}
+
+ {% if order.shipping_address_can_be_edited %}
+
Modifier l'adresse
+
+
+ {% endif %}
+ {% else %}
+
Aucune adresse de livraison renseignée
+ {% if order.shipping_address_can_be_edited %}
+
Ajouter une adresse
+
+ {% endif %}
+ {% endif %}
+
+
+ {% if order.discount_amount > 0 %}
+
+
+ Sous-total:
+ {{ order.total_price }}€
+
+
+ Réduction:
+ -{{ order.discount_amount }}€
+
+
+ Total final:
+ {{ order.get_total_after_discount }}€
+
+
+ {% if order.coupon %}
+
+ Coupon appliqué: {{ order.coupon.code }}
+
+ {% endif %}
+
+ {% endif %}
+
+
+ {% if order.status == 'PENDING' or order.status == 'PAID' %}
+
+ {% endif %}
+
+
+
+
+ {% if messages %}
+
+ {% for message in messages %}
+
+ {{ message }}
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
+
+{% endblock %}
diff --git a/shop/templates/shop/partials/navigation_base.html b/shop/templates/shop/partials/navigation_base.html
new file mode 100644
index 0000000..3c49543
--- /dev/null
+++ b/shop/templates/shop/partials/navigation_base.html
@@ -0,0 +1,9 @@
+
+ La Boutique
+ Mes Commandes
+ {% if user.is_authenticated %}
+ Mon compte
+ {% else %}
+ Se connecter
+ {% endif %}
+
diff --git a/shop/templates/shop/partials/order_items_display.html b/shop/templates/shop/partials/order_items_display.html
index fabb5f9..125a3a3 100644
--- a/shop/templates/shop/partials/order_items_display.html
+++ b/shop/templates/shop/partials/order_items_display.html
@@ -1,60 +1,68 @@
-
- {% for item in items %}
-
- {{ item.product_title }}
- {% if item.product_description %}
- {{ item.product_description }}
- {% endif %}
-
-
-
- {{ item.color_name }} | {{ item.size_name }}
-
-
-
- {% if edit_mode %}
-
-
-
- {% else %}
- x {{ item.quantity }}
- {% endif %}
-
- {{ item.total_price }} €
- {% if edit_mode %}
-
-
-
- {% endif %}
-
- {% endfor %}
-
-
-
- {{ total_quantity }} produit(s)
-
-
- {{ total_price }} €
- {% if edit_mode %}
-
- {% endif %}
-
-
+
+ {% for item in items %}
+
+ {{ item.product_title }}
+ {% if item.product_description %}
+ {{ item.product_description }}
+ {% endif %}
+
+
+
+ {{ item.color_name }} | {{ item.size_name }}
+
+
+
+ {% if edit_mode %}
+
+
+
+ {% else %}
+ x {{ item.quantity }}
+ {% endif %}
+
+ {{ item.total_price }} €
+ {% if edit_mode %}
+
+
+
+ {% elif cancel_mode and items.count > 1 %}
+
+
+
+ {% endif %}
+
+ {% endfor %}
+
+
+
+ {{ total_quantity }} produit(s)
+
+
+ {{ total_price }} €
+ {% if edit_mode or cancel_mode %}
+
+ {% endif %}
+
+
diff --git a/shop/templates/shop/payment.html b/shop/templates/shop/payment.html
index 4517651..c16d356 100644
--- a/shop/templates/shop/payment.html
+++ b/shop/templates/shop/payment.html
@@ -12,21 +12,12 @@
Use test card: 4242 4242 4242 4242 with any future date and any CVC.
{% endif %}
-
- La Boutique
- Accueil
- Clubs
- {% if user.is_authenticated %}
- Mes tournois
- Mon compte
- {% else %}
- Se connecter
- {% endif %}
-
+{% include 'shop/partials/navigation_base.html' %}
+
-
Résumé de votre commande
+
Résumé de votre commande
{% include 'shop/partials/order_items_display.html' with items=display_data.items total_quantity=display_data.total_quantity total_price=display_data.total_price edit_mode=False %}
diff --git a/shop/templates/shop/payment_cancel.html b/shop/templates/shop/payment_cancel.html
index 996d4e6..e4fc22c 100644
--- a/shop/templates/shop/payment_cancel.html
+++ b/shop/templates/shop/payment_cancel.html
@@ -7,21 +7,11 @@
{% block content %}
-
- La Boutique
- Accueil
- Clubs
- {% if user.is_authenticated %}
- Mes tournois
- Mon compte
- {% else %}
- Se connecter
- {% endif %}
-
+{% include 'shop/partials/navigation_base.html' %}
-
Paiement
+
Paiement
Le paiement a été annulé
Votre commande n'a pas été finalisée car le paiement a été annulé.
diff --git a/shop/templates/shop/payment_success.html b/shop/templates/shop/payment_success.html
index e8880a5..eeb681e 100644
--- a/shop/templates/shop/payment_success.html
+++ b/shop/templates/shop/payment_success.html
@@ -5,21 +5,11 @@
{% block second_title %}La Boutique{% endblock %}
{% block content %}
-
- La Boutique
- Accueil
- Clubs
- {% if user.is_authenticated %}
- Mes tournois
- Mon compte
- {% else %}
- Se connecter
- {% endif %}
-
+{% include 'shop/partials/navigation_base.html' %}
-
Paiement réussi
+
Paiement réussi
Merci pour votre commande !
Votre paiement a été traité avec succès.
diff --git a/shop/templates/shop/product_list.html b/shop/templates/shop/product_list.html
index aaacbc4..16cc311 100644
--- a/shop/templates/shop/product_list.html
+++ b/shop/templates/shop/product_list.html
@@ -6,22 +6,12 @@
{% block content %}
-
- La Boutique
- Accueil
- Clubs
- {% if user.is_authenticated %}
- Mes tournois
- Mon compte
- {% else %}
- Se connecter
- {% endif %}
-
+{% include 'shop/partials/navigation_base.html' %}
Bienvenue sur la boutique Padel Club des copains !
Photos : Les photos des vêtements n'ont pas encore tous le logo, la description indique où il situera.
-
Livraison : Commandez en ligne et récupérez votre commande en main propre lors de votre prochaine session de padel au club !
+
Livraison : Commandez en ligne et récupérez votre commande en main propre lors de votre prochaine session de padel au club. La livraison peut être possible !
diff --git a/shop/urls.py b/shop/urls.py
index 7620a74..fa61b2b 100644
--- a/shop/urls.py
+++ b/shop/urls.py
@@ -24,5 +24,9 @@ urlpatterns = [
path('debug/simulate-payment-failure/', views.simulate_payment_failure, name='simulate_payment_failure'),
path('apply-coupon/', views.apply_coupon, name='apply_coupon'),
path('remove-coupon/', views.remove_coupon, name='remove_coupon'),
-
+ path('my-orders/', views.my_orders, name='my_orders'),
+ path('order//', views.order_detail, name='order_detail'),
+ path('cancel-order//', views.cancel_order, name='cancel_order'),
+ path('cancel-order-item///', views.cancel_order_item, name='cancel_order_item'),
+ path('order//update-shipping/', views.update_shipping_address, name='update_shipping_address'),
]
diff --git a/shop/views.py b/shop/views.py
index ea836d8..2a3f303 100644
--- a/shop/views.py
+++ b/shop/views.py
@@ -13,6 +13,8 @@ from django.views.decorators.csrf import ensure_csrf_cookie
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.utils import timezone
+from .forms import ShippingAddressForm
+import json # Add this with your other imports
from . import cart
@@ -143,16 +145,20 @@ def view_cart(request):
total = cart.get_cart_total(request)
total_quantity = cart_items.aggregate(total_quantity=Sum('quantity'))['total_quantity']
display_data = prepare_item_display_data(cart_items, is_cart=True)
+
context = {
'display_data': display_data,
'total': total,
'total_quantity': total_quantity,
- 'settings': settings, # Add this line to pass settings to template
+ 'settings': settings,
}
- # Add Stripe publishable key for authenticated users
+ # Add shipping form and Stripe key for authenticated users
if request.user.is_authenticated:
- context['stripe_publishable_key'] = settings.STRIPE_PUBLISHABLE_KEY
+ context.update({
+ 'stripe_publishable_key': settings.STRIPE_PUBLISHABLE_KEY,
+ 'shipping_form': ShippingAddressForm()
+ })
return render(request, 'shop/cart.html', context)
@@ -457,19 +463,34 @@ def create_checkout_session(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'User must be authenticated'}, status=403)
- # Create the order
- order = create_order(request)
+ # Parse shipping address data from request
+ try:
+ data = json.loads(request.body)
+ shipping_data = data.get('shipping_address', {})
+ except json.JSONDecodeError:
+ shipping_data = {}
+
+ # Validate shipping address
+ shipping_form = ShippingAddressForm(shipping_data)
+ if not shipping_form.is_valid():
+ return JsonResponse({'error': 'Invalid shipping address'}, status=400)
+
+ # Save shipping address
+ shipping_address = shipping_form.save()
+ # Create the order with shipping address
+ order = create_order(request)
if not order:
return JsonResponse({'error': 'Could not create order from cart'}, status=400)
- # Get order items
- order_items = order.items.all()
+ # Attach shipping address to order
+ order.shipping_address = shipping_address
+ order.save()
- # Create line items
+ # Create line items and checkout session as before
+ order_items = order.items.all()
line_items = _create_stripe_line_items(order_items)
- # Create checkout session
try:
checkout_session = _create_stripe_checkout_session(request, order, line_items)
return JsonResponse({'id': checkout_session.id})
@@ -752,3 +773,227 @@ def remove_coupon(request):
if 'coupon_id' in request.session:
del request.session['coupon_id']
return JsonResponse({'status': 'success', 'message': 'Coupon supprimé'})
+
+def my_orders(request):
+ """Display all orders for the logged-in user"""
+ if not request.user.is_authenticated:
+ messages.error(request, "Vous devez être connecté pour voir vos commandes.")
+ return redirect('login')
+
+ # Get all orders for the current user, ordered by date (newest first)
+ orders = Order.objects.filter(user=request.user).order_by('-date_ordered')
+
+ return render(request, 'shop/my_orders.html', {
+ 'orders': orders,
+ })
+
+def order_detail(request, order_id):
+ """Display details for a specific order"""
+ if not request.user.is_authenticated:
+ messages.error(request, "Vous devez être connecté pour voir vos commandes.")
+ return redirect('login')
+
+ # Get the order, ensuring it belongs to the current user
+ order = get_object_or_404(Order, id=order_id, user=request.user)
+
+ # Get order items
+ order_items = order.items.all()
+
+ # Calculate total quantity
+ total_quantity = sum(item.quantity for item in order_items)
+
+ # Transform order items to match the display format expected by the template
+ items_for_display = []
+ for item in order_items:
+ items_for_display.append({
+ 'id': item.id,
+ 'product_title': item.product.title,
+ 'product_description': item.product.description,
+ 'color_name': item.color.name if item.color else 'N/A',
+ 'color_hex': item.color.colorHex if item.color else '#FFFFFF',
+ 'secondary_color_hex': item.color.secondary_hex_color if item.color and item.color.secondary_hex_color else None,
+ 'size_name': item.size.name if item.size else 'N/A',
+ 'quantity': item.quantity,
+ 'total_price': item.get_total_price()
+ })
+
+ shipping_form = ShippingAddressForm(instance=order.shipping_address)
+
+ return render(request, 'shop/order_detail.html', {
+ 'order': order,
+ 'order_items': items_for_display,
+ 'total_quantity': total_quantity,
+ 'shipping_form': shipping_form,
+ })
+
+@require_POST
+def cancel_order(request, order_id):
+ """Cancel an order and process refund if applicable"""
+ if not request.user.is_authenticated:
+ messages.error(request, "Vous devez être connecté pour annuler une commande.")
+ return redirect('login')
+
+ order = get_object_or_404(Order, id=order_id, user=request.user)
+
+ # Check if order can be cancelled
+ if not order.is_cancellable():
+ messages.error(request, "Cette commande ne peut pas être annulée.")
+ return redirect('shop:my_orders')
+
+ print("Order cancellation initiated", order.status, order.stripe_payment_intent_id)
+ # Process refund for paid orders
+ if order.status == OrderStatus.PAID and order.stripe_payment_intent_id:
+ try:
+ # Attempt to refund through Stripe
+ refund = stripe_service.create_refund(
+ order.stripe_payment_intent_id,
+ reason='requested_by_customer'
+ )
+
+ # Update order status
+ order.status = OrderStatus.REFUNDED
+ order.payment_status = 'REFUNDED' # Or 'REFUNDED' if you add this status
+ order.save()
+
+ messages.success(request, "Votre commande a été annulée et remboursée avec succès.")
+ except Exception as e:
+ print(f"Refund error: {str(e)}")
+ messages.error(request, "Un problème est survenu lors du remboursement. Veuillez contacter le support.")
+ else:
+ # For pending orders, just cancel without refund
+ order.status = OrderStatus.CANCELED
+ order.save()
+ messages.success(request, "Votre commande a été annulée avec succès.")
+
+ # Redirect back to the page they came from, or order list
+ referring_page = request.META.get('HTTP_REFERER')
+ if referring_page and 'order_detail' in referring_page:
+ return redirect('shop:order_detail', order_id=order.id)
+ else:
+ return redirect('shop:my_orders')
+
+@require_POST
+def cancel_order_item(request, order_id, item_id):
+ """Cancel a specific item in an order and process partial refund if applicable"""
+ if not request.user.is_authenticated:
+ messages.error(request, "Vous devez être connecté pour annuler un article.")
+ return redirect('login')
+
+ # Get the order and ensure it belongs to the user
+ order = get_object_or_404(Order, id=order_id, user=request.user)
+
+ # Check if order can be modified
+ if not order.is_cancellable():
+ messages.error(request, "Cette commande ne peut plus être modifiée.")
+ return redirect('shop:order_detail', order_id=order.id)
+
+ # Get the specific item
+ try:
+ order_item = order.items.get(id=item_id)
+ except OrderItem.DoesNotExist:
+ messages.error(request, "Article non trouvé.")
+ return redirect('shop:order_detail', order_id=order.id)
+
+ # Calculate refund amount for this item
+ item_total = order_item.get_total_price()
+
+ # If this is the last item, cancel the whole order
+ remaining_items = order.items.exclude(id=item_id)
+ if not remaining_items.exists():
+ return cancel_order(request, order_id)
+
+ # Process refund for paid orders
+ if order.status == OrderStatus.PAID and order.stripe_payment_intent_id:
+ try:
+ # Calculate proportional discount if any
+ if order.discount_amount > 0:
+ discount_ratio = item_total / order.total_price
+ item_discount = order.discount_amount * discount_ratio
+ refund_amount = item_total - item_discount
+ else:
+ refund_amount = item_total
+
+ # Create partial refund through Stripe
+ refund = stripe_service.create_refund(
+ order.stripe_payment_intent_id,
+ amount=int(refund_amount * 100), # Convert to cents
+ reason='requested_by_customer'
+ )
+
+ # Update order totals
+ order.total_price -= item_total
+ if order.discount_amount > 0:
+ order.discount_amount -= item_discount
+ order.save()
+
+ # Delete the item
+ order_item.delete()
+
+ messages.success(request, "L'article a été annulé et remboursé avec succès.")
+
+ except Exception as e:
+ print(f"Refund error: {str(e)}")
+ messages.error(request, "Un problème est survenu lors du remboursement. Veuillez contacter le support.")
+ else:
+ # For pending orders, just remove the item and update totals
+ order.total_price -= item_total
+ order.save()
+ order_item.delete()
+ messages.success(request, "L'article a été annulé avec succès.")
+
+ return redirect('shop:order_detail', order_id=order.id)
+
+def checkout_view(request):
+ if request.method == 'POST':
+ guest_form = GuestCheckoutForm(request.POST)
+ shipping_form = ShippingAddressForm(request.POST)
+
+ if guest_form.is_valid() and shipping_form.is_valid():
+ guest_user = guest_form.save()
+ shipping_address = shipping_form.save()
+
+ # Create order with shipping address
+ order = Order.objects.create(
+ guest_user=guest_user,
+ shipping_address=shipping_address,
+ # ... other order fields
+ )
+
+ return redirect('shop:order_detail', order_id=order.id)
+ else:
+ guest_form = GuestCheckoutForm()
+ shipping_form = ShippingAddressForm()
+
+ context = {
+ 'form': guest_form,
+ 'shipping_form': shipping_form,
+ }
+ return render(request, 'shop/checkout.html', context)
+
+def update_shipping_address(request, order_id):
+ order = get_object_or_404(Order, id=order_id)
+
+ # Check if order can be edited
+ if not order.shipping_address_can_be_edited():
+ messages.error(request, "L'adresse ne peut plus être modifiée pour cette commande.")
+ return redirect('shop:order_detail', order_id=order.id)
+
+ if request.method == 'POST':
+ form = ShippingAddressForm(request.POST, instance=order.shipping_address)
+ print("Shipping address")
+ if form.is_valid():
+ shipping_address = form.save()
+ print("Shipping address updated")
+ if not order.shipping_address:
+ order.shipping_address = shipping_address
+ order.save()
+ messages.success(request, "L'adresse de livraison a été mise à jour.")
+ return redirect('shop:order_detail', order_id=order.id)
+ else:
+ form = ShippingAddressForm(instance=order.shipping_address)
+
+ context = {
+ 'form': form,
+ 'order': order
+ }
+ return redirect('shop:order_detail', order_id=order.id)
diff --git a/tournaments/migrations/0119_alter_tournament_animation_type.py b/tournaments/migrations/0119_alter_tournament_animation_type.py
new file mode 100644
index 0000000..2070e9d
--- /dev/null
+++ b/tournaments/migrations/0119_alter_tournament_animation_type.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.1 on 2025-05-06 07:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tournaments', '0118_tournament_animation_type'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='tournament',
+ name='animation_type',
+ field=models.IntegerField(choices=[(0, 'Tournoi'), (1, 'Mêlée'), (2, 'Classement'), (3, 'Consolation')], default=0),
+ ),
+ ]
diff --git a/tournaments/templates/profile.html b/tournaments/templates/profile.html
index 93e8963..0e79dbd 100644
--- a/tournaments/templates/profile.html
+++ b/tournaments/templates/profile.html
@@ -45,24 +45,26 @@
{% endif %}
-
-
-
Mes informations
-
+
-
-
{% endblock %}
diff --git a/tournaments/templates/tournaments/navigation_base.html b/tournaments/templates/tournaments/navigation_base.html
index 3624765..b9e4be8 100644
--- a/tournaments/templates/tournaments/navigation_base.html
+++ b/tournaments/templates/tournaments/navigation_base.html
@@ -9,4 +9,5 @@
Se connecter
{% endif %}
Ajouter vos tournois
+
La Boutique