diff --git a/.gitignore b/.gitignore index 2f99ee3..9daa608 100644 --- a/.gitignore +++ b/.gitignore @@ -173,3 +173,6 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +# Media files +media/ +*/media/ diff --git a/api/serializers.py b/api/serializers.py index 6eedf50..e530a5a 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers from tournaments.models.court import Court -from tournaments.models import Club, LiveMatch, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, FailedApiCall, DateInterval, Log, DeviceToken, UnregisteredTeam, UnregisteredPlayer +from tournaments.models import Club, LiveMatch, TeamScore, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamRegistration, PlayerRegistration, Purchase, FailedApiCall, DateInterval, Log, DeviceToken, UnregisteredTeam, UnregisteredPlayer, Image from django.db.utils import IntegrityError from django.conf import settings @@ -238,3 +238,17 @@ class UnregisteredPlayerSerializer(serializers.ModelSerializer): model = UnregisteredPlayer fields = '__all__' # ['id', 'team_registration_id', 'first_name', 'last_name', 'licence_id', 'rank', 'has_paid'] + +class ImageSerializer(serializers.ModelSerializer): + image_url = serializers.SerializerMethodField() + + def get_image_url(self, obj): + if obj.image: + return self.context['request'].build_absolute_uri(obj.image.url) + return None + + class Meta: + model = Image + fields = ['id', 'title', 'description', 'image', 'image_url', 'uploaded_at', + 'event', 'image_type'] + read_only_fields = ['id', 'uploaded_at', 'image_url'] diff --git a/api/urls.py b/api/urls.py index 390e456..13faa2e 100644 --- a/api/urls.py +++ b/api/urls.py @@ -11,6 +11,7 @@ router.register(r'users', views.UserViewSet) router.register(r'user-agents', views.ShortUserViewSet) router.register(r'clubs', views.ClubViewSet) router.register(r'tournaments', views.TournamentViewSet) +router.register(r'images', views.ImageViewSet) router.register(r'events', views.EventViewSet) router.register(r'rounds', views.RoundViewSet) router.register(r'group-stages', views.GroupStageViewSet) @@ -50,5 +51,7 @@ urlpatterns = [ # forgotten password path('dj-rest-auth/', include('dj_rest_auth.urls')), + path('stripe/create-account/', views.create_stripe_connect_account, name='create_stripe_account'), + path('stripe/create-account-link/', views.create_stripe_account_link, name='create_account_link'), ] diff --git a/api/views.py b/api/views.py index 0ded140..820f642 100644 --- a/api/views.py +++ b/api/views.py @@ -1,5 +1,5 @@ -from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, DrawLogSerializer, TournamentSerializer, UserSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, PurchaseSerializer, ShortUserSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer, CustomUserSerializer, UnregisteredTeamSerializer, UnregisteredPlayerSerializer -from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer +from .serializers import ClubSerializer, CourtSerializer, DateIntervalSerializer, DrawLogSerializer, TournamentSerializer, UserSerializer, EventSerializer, RoundSerializer, GroupStageSerializer, MatchSerializer, TeamScoreSerializer, TeamRegistrationSerializer, PlayerRegistrationSerializer, PurchaseSerializer, ShortUserSerializer, FailedApiCallSerializer, LogSerializer, DeviceTokenSerializer, CustomUserSerializer, UnregisteredTeamSerializer, UnregisteredPlayerSerializer, ImageSerializer +from tournaments.models import Club, Tournament, CustomUser, Event, Round, GroupStage, Match, TeamScore, TeamRegistration, PlayerRegistration, Court, DateInterval, Purchase, FailedApiCall, Log, DeviceToken, DrawLog, UnregisteredTeam, UnregisteredPlayer, Image from rest_framework import viewsets from rest_framework.response import Response @@ -29,6 +29,9 @@ from django.core.files.storage import default_storage from django.core.files.base import ContentFile import os from django.http import HttpResponse +import logging + +logger = logging.getLogger(__name__) @api_view(['GET']) def user_by_token(request): @@ -303,6 +306,34 @@ class ShortUserViewSet(viewsets.ModelViewSet): def get_queryset(self): return self.request.user.agents +class ImageViewSet(viewsets.ModelViewSet): + """ + Viewset for handling event image uploads and retrieval. + + This allows umpires/organizers to upload images for events from the iOS app, + which can then be displayed on the event pages. + """ + serializer_class = ImageSerializer + queryset = Image.objects.all() + + def get_queryset(self): + queryset = Image.objects.all() + + # Filter by event + event_id = self.request.query_params.get('event_id') + image_type = self.request.query_params.get('image_type') + + if event_id: + queryset = queryset.filter(event_id=event_id) + if image_type: + queryset = queryset.filter(image_type=image_type) + + return queryset + + def perform_create(self, serializer): + serializer.save() + + @api_view(['POST']) @permission_classes([IsAuthenticated]) def process_refund(request, team_registration_id): @@ -317,7 +348,7 @@ def process_refund(request, team_registration_id): payment_service = PaymentService(request) players_serializer = PlayerRegistrationSerializer(team_registration.players_sorted_by_rank, many=True) - success, message, refund = payment_service.process_refund(team_registration_id) + success, message, refund = payment_service.process_refund(team_registration_id, force_refund=True) return Response({ 'success': success, 'message': message, @@ -329,46 +360,6 @@ def process_refund(request, team_registration_id): 'message': str(e) }, status=400) -@api_view(['POST']) -@permission_classes([IsAuthenticated]) -def validate_stripe_account(request): - stripe.api_key = settings.STRIPE_SECRET_KEY - # Parse the request body - data = json.loads(request.body) - account_id = data.get('account_id') - - if not account_id: - return Response({ - 'valid': False, - 'error': 'Account ID is required' - }, status=400) - - # Try to retrieve the account from Stripe - try: - # Basic account verification - account = stripe.Account.retrieve(account_id) - - # Only check if the account can receive payments - is_valid = account.id is not None - return Response({ - 'valid': is_valid, - 'account': { - 'id': account.id - } - }) - - except stripe.error.PermissionError: - return Response({ - 'valid': False, - 'error': 'No permission to access this account' - }, status=403) - - except stripe.error.InvalidRequestError: - return Response({ - 'valid': False, - 'error': 'Invalid account ID' - }, status=400) - @api_view(['POST']) @permission_classes([IsAuthenticated]) def xls_to_csv(request): @@ -381,8 +372,12 @@ def xls_to_csv(request): file_path = os.path.join(directory, uploaded_file.name) file_name = default_storage.save(file_path, ContentFile(uploaded_file.read())) + logger.info(f'file saved at {file_name}') + full_path = default_storage.path(file_name) + logger.info(f'full_path = {full_path}') + # Check available sheets and look for 'inscriptions' - xls = pd.ExcelFile(file_name) + xls = pd.ExcelFile(full_path) sheet_names = xls.sheet_names # Determine which sheet to use @@ -394,13 +389,14 @@ def xls_to_csv(request): break # Convert to csv and save - data_xls = pd.read_excel(file_name, sheet_name=target_sheet, index_col=None) + data_xls = pd.read_excel(full_path, sheet_name=target_sheet, index_col=None) csv_file_name = create_random_filename('players', 'csv') output_path = os.path.join(directory, csv_file_name) - data_xls.to_csv(output_path, sep=';', index=False, encoding='utf-8') + full_output_path = default_storage.path(output_path) + data_xls.to_csv(full_output_path, sep=';', index=False, encoding='utf-8') # Send the processed file back - with default_storage.open(output_path, 'rb') as file: + with default_storage.open(full_output_path, 'rb') as file: response = HttpResponse(file.read(), content_type='application/octet-stream') response['Content-Disposition'] = f'attachment; filename="players.csv"' @@ -431,3 +427,144 @@ def get_payment_config(request): return Response({ 'stripe_fee': getattr(settings, 'STRIPE_FEE', 0) }) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def create_stripe_connect_account(request): + stripe.api_key = settings.STRIPE_SECRET_KEY + user = request.user + + try: + # Create a new Standard account + account = stripe.Account.create( + type='standard', + metadata={ + 'padelclub_email': user.email, + 'platform': 'padelclub' + } + ) + + return Response({ + 'success': True, + 'account_id': account.id, + }) + + except Exception as e: + return Response({ + 'success': False, + 'error': str(e) + }, status=400) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def create_stripe_account_link(request): + """ + Create an account link for a Stripe account. + Uses HTTPS URLs only - no custom URL schemes. + """ + stripe.api_key = settings.STRIPE_SECRET_KEY + + # Parse request data + data = json.loads(request.body) + account_id = data.get('account_id') + + if not account_id: + return Response({ + 'success': False, + 'error': 'No Stripe account ID found' + }, status=400) + + try: + base_path = f"{request.scheme}://{request.get_host()}" + + refresh_url = f"{base_path}/stripe-refresh-account-link/" + return_url = f"{base_path}/stripe-onboarding-complete/" + + # Generate the account link URL + account_link = stripe.AccountLink.create( + account=account_id, + refresh_url=refresh_url, + return_url=return_url, + type='account_onboarding', + ) + + return Response({ + 'success': True, + 'url': account_link.url, + 'account_id': account_id + }) + except Exception as e: + return Response({ + 'success': False, + 'error': str(e) + }, status=400) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def validate_stripe_account(request): + """ + Validate a Stripe account for a tournament. + Returns validation status and onboarding URL if needed. + """ + stripe.api_key = settings.STRIPE_SECRET_KEY + + # Parse the request body + data = json.loads(request.body) + account_id = data.get('account_id') + + if not account_id: + return Response({ + 'valid': False, + 'error': 'No account ID found to validate', + 'needs_onboarding': True + }, status=200) + + try: + # Validate the account with Stripe + account = stripe.Account.retrieve(account_id) + + # Check account capabilities + charges_enabled = account.get('charges_enabled', False) + payouts_enabled = account.get('payouts_enabled', False) + details_submitted = account.get('details_submitted', False) + + # Determine if the account is valid and ready + is_valid = account.id is not None + can_process_payments = charges_enabled and payouts_enabled + onboarding_complete = details_submitted + needs_onboarding = not (can_process_payments and onboarding_complete) + + return Response({ + 'valid': is_valid, + 'can_process_payments': can_process_payments, + 'onboarding_complete': onboarding_complete, + 'needs_onboarding': needs_onboarding, + 'account': { + 'id': account.id, + 'charges_enabled': charges_enabled, + 'payouts_enabled': payouts_enabled, + 'details_submitted': details_submitted + } + }) + + except stripe.error.PermissionError: + # Account doesn't exist or isn't connected to your platform + return Response({ + 'valid': False, + 'error': 'This Stripe account is not connected to your platform or does not exist.', + 'needs_onboarding': True, + }, status=200) + + except stripe.error.InvalidRequestError: + return Response({ + 'valid': False, + 'error': 'Invalid account ID format', + 'needs_onboarding': True, + }, status=200) + + except Exception as e: + return Response({ + 'valid': False, + 'error': f'Unexpected error: {str(e)}', + 'needs_onboarding': True, + }, status=200) diff --git a/padelclub_backend/settings.py b/padelclub_backend/settings.py index b6c8262..31e51fb 100644 --- a/padelclub_backend/settings.py +++ b/padelclub_backend/settings.py @@ -147,6 +147,10 @@ USE_L10N = True STATIC_URL = '/static/' STATIC_ROOT = os.path.join(BASE_DIR, 'static/') +# Media files (User uploads) +MEDIA_URL = '/media/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'media/') + # Default primary key field type # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field diff --git a/padelclub_backend/settings_app.py b/padelclub_backend/settings_app.py index 4722911..b2d7cc2 100644 --- a/padelclub_backend/settings_app.py +++ b/padelclub_backend/settings_app.py @@ -38,7 +38,7 @@ QR_CODE_CACHE_ALIAS = 'qr-code' SYNC_APPS = { 'sync': {}, - 'tournaments': { 'exclude': ['Log', 'FailedApiCall', 'DeviceToken'] } + 'tournaments': { 'exclude': ['Log', 'FailedApiCall', 'DeviceToken', 'Image'] } } SYNC_MODEL_CHILDREN_SHARING = { diff --git a/padelclub_backend/urls.py b/padelclub_backend/urls.py index 85c8b15..abb883a 100644 --- a/padelclub_backend/urls.py +++ b/padelclub_backend/urls.py @@ -15,6 +15,8 @@ Including another URLconf """ from django.contrib import admin from django.urls import include, path +from django.conf import settings +from django.conf.urls.static import static urlpatterns = [ @@ -27,3 +29,7 @@ urlpatterns = [ path('dj-auth/', include('django.contrib.auth.urls')), ] + +# Serve media files in development +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/requirements.txt b/requirements.txt index cc14798..f0cf5e5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,3 +17,4 @@ django-filter==24.3 cryptography==41.0.7 stripe==11.6.0 django-background-tasks==1.2.8 +Pillow==10.2.0 diff --git a/shop/admin.py b/shop/admin.py index 72d69c6..bce87cf 100644 --- a/shop/admin.py +++ b/shop/admin.py @@ -1,11 +1,19 @@ from django.contrib import admin -from django.shortcuts import render -from .models import Product, Color, Size, Order, OrderItem, GuestUser, Coupon, CouponUsage, OrderStatus +from django.shortcuts import render, redirect from django.utils.html import format_html +from django.urls import path +from django.http import HttpResponseRedirect +from django import forms + +from .models import ( + Product, Color, Size, Order, OrderItem, GuestUser, Coupon, CouponUsage, + OrderStatus, ShippingAddress +) @admin.register(Product) class ProductAdmin(admin.ModelAdmin): list_display = ("title", "ordering_value", "price", "cut") + search_fields = ["title", "description"] # Enable search for autocomplete @admin.register(Color) class ColorAdmin(admin.ModelAdmin): @@ -34,14 +42,94 @@ class SizeAdmin(admin.ModelAdmin): class OrderItemInline(admin.TabularInline): model = OrderItem - extra = 0 - readonly_fields = ('product', 'quantity', 'color', 'size', 'price') + extra = 1 # Show one extra row for adding new items + autocomplete_fields = ['product'] # Enable product search + fields = ('product', 'quantity', 'color', 'size', 'price') + +@admin.register(OrderItem) +class OrderItemAdmin(admin.ModelAdmin): + list_display = ('order', 'product', 'quantity', 'color', 'size', 'price', 'get_total_price') + list_filter = ('product', 'color', 'size', 'order__status') + search_fields = ('order__id', 'product__title', 'order__user__email', 'order__guest_user__email') + autocomplete_fields = ['order', 'product'] + list_editable = ('quantity', 'price') + + def get_total_price(self, obj): + return obj.get_total_price() + get_total_price.short_description = 'Total Price' + get_total_price.admin_order_field = 'price' # Allows column to be sortable + +@admin.register(ShippingAddress) +class ShippingAddressAdmin(admin.ModelAdmin): + list_display = ('street_address', 'city', 'postal_code', 'country') + search_fields = ('street_address', 'city', 'postal_code', 'country') + +class ChangeOrderStatusForm(forms.Form): + _selected_action = forms.CharField(widget=forms.MultipleHiddenInput) + status = forms.ChoiceField(choices=OrderStatus.choices, label="New Status") @admin.register(Order) class OrderAdmin(admin.ModelAdmin): - list_display = ('id', 'date_ordered', 'status', 'total_price') + list_display = ('id', 'get_email', 'date_ordered', 'status', 'total_price', 'get_shipping_address') inlines = [OrderItemInline] list_filter = ('status', 'payment_status') + readonly_fields = ('shipping_address_details',) + actions = ['change_order_status'] + autocomplete_fields = ['user'] # Add this line for user search functionality + search_fields = ['id', 'user__email', 'user__username', 'guest_user__email'] # Add this line + + def get_email(self, obj): + if obj.guest_user: + return obj.guest_user.email + else: + return obj.user.email + get_email.short_description = 'Email' + + 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 +191,73 @@ 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') + + def change_order_status(self, request, queryset): + """Admin action to change the status of selected orders""" + form = None + + if 'apply' in request.POST: + form = ChangeOrderStatusForm(request.POST) + + if form.is_valid(): + status = form.cleaned_data['status'] + count = 0 + + for order in queryset: + order.status = status + order.save() + count += 1 + + self.message_user(request, f"{count} orders have been updated to status '{OrderStatus(status).label}'.") + return HttpResponseRedirect(request.get_full_path()) + + if not form: + form = ChangeOrderStatusForm(initial={'_selected_action': request.POST.getlist('_selected_action')}) + + context = { + 'title': 'Change Order Status', + 'orders': queryset, + 'form': form, + 'action': 'change_order_status' + } + return render(request, 'admin/shop/order/change_status.html', context) + + change_order_status.short_description = "Change status for selected orders" + 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/management/commands/create_initial_shop_data.py b/shop/management/commands/create_initial_shop_data.py index b81c332..0872ba0 100644 --- a/shop/management/commands/create_initial_shop_data.py +++ b/shop/management/commands/create_initial_shop_data.py @@ -18,10 +18,13 @@ class Command(BaseCommand): {'name': 'Fuchsia', 'hex': '#C1366B', 'secondary_hex': None, 'ordering': 30}, {'name': 'Corail / Noir', 'hex': '#FF7F50', 'secondary_hex': '#000000', 'ordering': 40}, {'name': 'Gris Foncé Chiné / Noir', 'hex': '#4D4D4D', 'secondary_hex': '#000000', 'ordering': 50}, + {'name': 'Olive', 'hex': '#635E53', 'secondary_hex': None, 'ordering': 54}, {'name': 'Kaki Foncé', 'hex': '#707163', 'secondary_hex': None, 'ordering': 55}, {'name': 'Noir', 'hex': '#000000', 'secondary_hex': None, 'ordering': 60}, {'name': 'Noir / Corail', 'hex': '#000000', 'secondary_hex': '#FF7F50', 'ordering': 61}, {'name': 'Noir / Gris Foncé Chiné', 'hex': '#000000', 'secondary_hex': '#4D4D4D', 'ordering': 62}, + {'name': 'Rose Clair', 'hex': '#E7C8CF', 'secondary_hex': None, 'ordering': 31}, + {'name': 'Sand', 'hex': '#B4A885', 'secondary_hex': None, 'ordering': 32}, ] color_objects = {} @@ -137,6 +140,28 @@ class Command(BaseCommand): 'sizes': ['S', 'M', 'L', 'XL', 'XXL', '3XL'], 'image_filename': 'PS_PA1030_WHITE-SPORTYNAVY.png.avif' }, + { + 'sku': 'PC008', + 'title': 'T-shirt Simple Femme', + 'description': 'T-shirt simple avec logo coeur.', + 'price': 20.00, + 'ordering_value': 60, + 'cut': 1, # Women + 'colors': ['Blanc', 'Bleu Sport', 'Sand', 'Noir', 'Kaki Foncé', 'Rose Clair'], + 'sizes': ['XS','S', 'M', 'L', 'XL', 'XXL'], + 'image_filename': 'PS_PA439_WHITE.png.avif' + }, + { + 'sku': 'PC009', + 'title': 'T-shirt Simple Homme', + 'description': 'T-shirt simple avec logo coeur.', + 'price': 20.00, + 'ordering_value': 61, + 'cut': 2, # Men + 'colors': ['Blanc', 'Bleu Sport', 'Sand', 'Noir', 'Olive', 'Rose Clair'], + 'sizes': ['XS','S', 'M', 'L', 'XL', 'XXL', '3XL'], + 'image_filename': 'PS_PA438_WHITE.png.avif' + }, ] for product_data in products: 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/migrations/0028_alter_order_status.py b/shop/migrations/0028_alter_order_status.py new file mode 100644 index 0000000..42ea1a7 --- /dev/null +++ b/shop/migrations/0028_alter_order_status.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1 on 2025-05-06 19:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0027_shippingaddress_alter_order_payment_status_and_more'), + ] + + operations = [ + 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'), ('READY', 'Ready')], default='PENDING', max_length=20), + ), + ] diff --git a/shop/models.py b/shop/models.py index 55e8345..8af21a6 100644 --- a/shop/models.py +++ b/shop/models.py @@ -8,6 +8,9 @@ class OrderStatus(models.TextChoices): SHIPPED = 'SHIPPED', 'Shipped' DELIVERED = 'DELIVERED', 'Delivered' CANCELED = 'CANCELED', 'Canceled' + REFUNDED = 'REFUNDED', 'Refunded' + PREPARED = 'PREPARED', 'Prepared' + READY = 'READY', 'Ready' class CutChoices(models.IntegerChoices): UNISEX = 0, 'Unisex' @@ -71,6 +74,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 +123,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 +134,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 +150,33 @@ 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, OrderStatus.READY] + + def get_shipping_address(self): + """ + Returns a formatted string of the shipping address + """ + if not self.shipping_address: + return "Aucune adresse de livraison fournie" + + address_parts = [ + self.shipping_address.street_address, + self.shipping_address.apartment if self.shipping_address.apartment else None, + self.shipping_address.city, + self.shipping_address.state if self.shipping_address.state else None, + self.shipping_address.postal_code, + self.shipping_address.country + ] + + # Filter out None values and join with newlines + formatted_address = '\n'.join(part for part in address_parts if part) + return formatted_address + 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/signals.py b/shop/signals.py index f449550..3466662 100644 --- a/shop/signals.py +++ b/shop/signals.py @@ -70,11 +70,11 @@ def _get_order_details(instance): # Translate statuses status_fr_map = { "PENDING": "EN ATTENTE", "PAID": "PAYÉE", - "SHIPPED": "EXPÉDIÉE", "DELIVERED": "LIVRÉE", "CANCELED": "ANNULÉE" + "SHIPPED": "EXPÉDIÉE", "DELIVERED": "LIVRÉE", "CANCELED": "ANNULÉE", "REFUNDED": "REMBOURSÉE", "PREPARED": "EN COURS DE PRÉPARATION", "READY": "PRÊT" } payment_status_fr_map = { - "UNPAID": "NON PAYÉE", "PAID": "PAYÉE", "FAILED": "ÉCHOUÉE" + "UNPAID": "NON PAYÉE", "PAID": "PAYÉE", "FAILED": "ÉCHOUÉE", "REFUNDED": "REMBOURSÉE", } # Calculate discount information @@ -106,7 +106,8 @@ def _get_order_details(instance): '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])}" + 'admin_url': f"{settings.SHOP_SITE_ROOT_URL}{reverse('admin:shop_order_change', args=[instance.id])}", + 'shipping_address': instance.get_shipping_address(), } def _build_items_list(order_id, action): @@ -153,6 +154,8 @@ Statut de paiement: {order_details['payment_status_fr']} {order_details['customer_info']} +{order_details['shipping_address']} + Articles: {items_list} @@ -195,7 +198,8 @@ def _send_customer_notification(instance, order_details, items_list): order_details['final_price'], items_list, contact_email, - shop_url + shop_url, + order_details['shipping_address'] ) # Skip if no email content returned @@ -213,7 +217,7 @@ def _send_customer_notification(instance, order_details, items_list): def _get_customer_email_content(status, payment_status, order_id, date, status_fr, total_price, has_coupon, coupon_info, discount_amount, - final_price, items_list, contact_email, shop_url): + final_price, items_list, contact_email, shop_url, shipping_address): """Get the appropriate customer email content based on order status.""" # Build price information with coupon details if applicable @@ -230,21 +234,24 @@ Montant payé: {final_price}€""" 'subject': f"Confirmation de votre commande #{order_id} - Padel Club", 'message': _build_payment_confirmation_email(order_id, date, status_fr, price_info, items_list, - contact_email, shop_url) + contact_email, shop_url, shipping_address) } # Order status update email - elif status in [OrderStatus.SHIPPED, OrderStatus.DELIVERED, OrderStatus.CANCELED]: + elif status in [OrderStatus.SHIPPED, OrderStatus.DELIVERED, OrderStatus.CANCELED, OrderStatus.PREPARED, OrderStatus.REFUNDED, OrderStatus.READY]: status_message = { + OrderStatus.PREPARED: "Votre commande est en cours de préparation.", + OrderStatus.READY: "Votre commande est prête.", 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.REFUNDED: "Votre commande a été annulée et son remboursement est en cours de traitement.", 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} - Padel Club", 'message': _build_status_update_email(order_id, date, status_message, status_fr, - price_info, items_list, contact_email) + price_info, items_list, contact_email, shipping_address) } # Payment issue notification @@ -266,7 +273,7 @@ Montant payé: {final_price}€""" # No email needed return None -def _build_payment_confirmation_email(order_id, date, status_fr, price_info, items_list, contact_email, shop_url): +def _build_payment_confirmation_email(order_id, date, status_fr, price_info, items_list, contact_email, shop_url, shipping_address): """Build payment confirmation email message.""" return f""" Bonjour, @@ -282,7 +289,10 @@ Détail de votre commande : {items_list} 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. +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 ! +Si jamais la livraison est possible, nous vous expédierons votre commande à l'adresse indiquée. Vous serez alors notifiés par email lorsque votre commande sera expédiée. +{shipping_address} Pour toute question concernant votre commande, n'hésitez pas à contacter notre service client : {contact_email} @@ -295,7 +305,7 @@ Merci de votre confiance et à bientôt sur Padel Club ! L'équipe Padel Club """ -def _build_status_update_email(order_id, date, status_message, status_fr, price_info, items_list, contact_email): +def _build_status_update_email(order_id, date, status_message, status_fr, price_info, items_list, contact_email, shipping_address): """Build status update email message.""" return f""" Bonjour, @@ -303,6 +313,7 @@ Bonjour, Mise à jour concernant votre commande Padel Club #{order_id} du {date} : {status_message} +{shipping_address} Statut actuel: {status_fr} {price_info} 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/static/shop/images/products/PC008/blanc/PS_PA439_WHITE.png.avif b/shop/static/shop/images/products/PC008/blanc/PS_PA439_WHITE.png.avif new file mode 100644 index 0000000..73e3202 Binary files /dev/null and b/shop/static/shop/images/products/PC008/blanc/PS_PA439_WHITE.png.avif differ diff --git a/shop/static/shop/images/products/PC008/bleu-sport/PS_PA439_SPORTYNAVY.png.avif b/shop/static/shop/images/products/PC008/bleu-sport/PS_PA439_SPORTYNAVY.png.avif new file mode 100644 index 0000000..5551bb9 Binary files /dev/null and b/shop/static/shop/images/products/PC008/bleu-sport/PS_PA439_SPORTYNAVY.png.avif differ diff --git a/shop/static/shop/images/products/PC008/kaki-fonce/PS_PA439_DARKKHAKI.png.avif b/shop/static/shop/images/products/PC008/kaki-fonce/PS_PA439_DARKKHAKI.png.avif new file mode 100644 index 0000000..534bf6a Binary files /dev/null and b/shop/static/shop/images/products/PC008/kaki-fonce/PS_PA439_DARKKHAKI.png.avif differ diff --git a/shop/static/shop/images/products/PC008/noir/PS_PA439_BLACK.png.avif b/shop/static/shop/images/products/PC008/noir/PS_PA439_BLACK.png.avif new file mode 100644 index 0000000..b5f4943 Binary files /dev/null and b/shop/static/shop/images/products/PC008/noir/PS_PA439_BLACK.png.avif differ diff --git a/shop/static/shop/images/products/PC008/rose-clair/PS_PA439_PALEPINK.png.avif b/shop/static/shop/images/products/PC008/rose-clair/PS_PA439_PALEPINK.png.avif new file mode 100644 index 0000000..e5f4ba2 Binary files /dev/null and b/shop/static/shop/images/products/PC008/rose-clair/PS_PA439_PALEPINK.png.avif differ diff --git a/shop/static/shop/images/products/PC008/sand/PS_PA439_SAND.png.avif b/shop/static/shop/images/products/PC008/sand/PS_PA439_SAND.png.avif new file mode 100644 index 0000000..c9d98e6 Binary files /dev/null and b/shop/static/shop/images/products/PC008/sand/PS_PA439_SAND.png.avif differ diff --git a/shop/static/shop/images/products/PC009/blanc/PS_PA438_WHITE.png.avif b/shop/static/shop/images/products/PC009/blanc/PS_PA438_WHITE.png.avif new file mode 100644 index 0000000..496c0f4 Binary files /dev/null and b/shop/static/shop/images/products/PC009/blanc/PS_PA438_WHITE.png.avif differ diff --git a/shop/static/shop/images/products/PC009/bleu-sport/PS_PA438_SPORTYNAVY.png.avif b/shop/static/shop/images/products/PC009/bleu-sport/PS_PA438_SPORTYNAVY.png.avif new file mode 100644 index 0000000..cc9e8a5 Binary files /dev/null and b/shop/static/shop/images/products/PC009/bleu-sport/PS_PA438_SPORTYNAVY.png.avif differ diff --git a/shop/static/shop/images/products/PC009/noir/PS_PA438_BLACK.png.avif b/shop/static/shop/images/products/PC009/noir/PS_PA438_BLACK.png.avif new file mode 100644 index 0000000..973481f Binary files /dev/null and b/shop/static/shop/images/products/PC009/noir/PS_PA438_BLACK.png.avif differ diff --git a/shop/static/shop/images/products/PC009/olive/PS_PA438_OLIVE.png.avif b/shop/static/shop/images/products/PC009/olive/PS_PA438_OLIVE.png.avif new file mode 100644 index 0000000..33585a4 Binary files /dev/null and b/shop/static/shop/images/products/PC009/olive/PS_PA438_OLIVE.png.avif differ diff --git a/shop/static/shop/images/products/PC009/rose-clair/PS_PA438_PALEPINK.png.avif b/shop/static/shop/images/products/PC009/rose-clair/PS_PA438_PALEPINK.png.avif new file mode 100644 index 0000000..b53cbdc Binary files /dev/null and b/shop/static/shop/images/products/PC009/rose-clair/PS_PA438_PALEPINK.png.avif differ diff --git a/shop/static/shop/images/products/PC009/sand/PS_PA438_SAND.png.avif b/shop/static/shop/images/products/PC009/sand/PS_PA438_SAND.png.avif new file mode 100644 index 0000000..2d73cda Binary files /dev/null and b/shop/static/shop/images/products/PC009/sand/PS_PA438_SAND.png.avif differ 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/change_status.html b/shop/templates/admin/shop/order/change_status.html new file mode 100644 index 0000000..8a0cff5 --- /dev/null +++ b/shop/templates/admin/shop/order/change_status.html @@ -0,0 +1,32 @@ +{% extends "admin/base_site.html" %} +{% load i18n l10n admin_urls %} + +{% block content %} +
+

{{ title }}

+
+

Change status for the following {{ orders|length }} orders:

+
    + {% for order in orders %} +
  • {{ order }}
  • + {% endfor %} +
+
+ +
+ {% csrf_token %} + + + {{ form.as_p }} + + {% for obj in orders %} + + {% endfor %} + +
+ + Cancel +
+
+
+{% endblock %} \ No newline at end of file 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 }}

- - Back to Orders +
+ + Back to Orders + + +
+ {% csrf_token %} + +
+

Items Summary

@@ -35,7 +47,7 @@ @@ -54,13 +66,15 @@ + + {% for order in orders %} - + + + {% empty %} - + {% endfor %} diff --git a/shop/templates/shop/cart.html b/shop/templates/shop/cart.html index d9d2a3f..1efb018 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 %} - +{% 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 :

  1. Passées en ligne via notre système
  2. Préparées par notre équipe
  3. -
  4. Remises en main propre lors d'une prochaine session de padel
  5. +
  6. 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 %} @@ -124,6 +132,24 @@ const discountAmount = document.getElementById('discount-amount'); const finalTotal = document.getElementById('final-total'); + // Get address input elements directly + const streetAddress = document.getElementById('street-address'); + const apartment = document.getElementById('apartment'); + const postalCode = document.getElementById('postal-code'); + const city = document.getElementById('city'); + const country = document.getElementById('country'); + + // Function to collect shipping address data + function getShippingData() { + return { + street_address: streetAddress ? streetAddress.value : '', + apartment: apartment ? apartment.value : '', + postal_code: postalCode ? postalCode.value : '', + city: city ? city.value : '', + country: country ? country.value : '' + }; + } + // Initial values const originalTotal = parseFloat('{{ display_data.total_price }}'); @@ -244,6 +270,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 +280,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 %} - -

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..708af24 --- /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 %} +
{{ 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 %}
Order # Date CustomerShipping Address ItemsActions
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 %}
+
+ {% csrf_token %} + +
+ +
+ {% csrf_token %} + +
+
No orders foundNo orders found
+ + {% for order in orders %} + + + + + + + + + {% endfor %} + +
Commande #{{ order.id }} + Détails + {{ order.date_ordered|date:"d/m/Y H:i" }} + {% if order.status == 'PENDING' %} + En attente + {% elif order.status == 'PAID' %} + + {% 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' %} +
+ {% csrf_token %} + +
+ {% endif %} +
+ {% else %} +
+

Vous n'avez pas encore de commandes.

+ Découvrir la boutique +
+ {% 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 }}

+
+
+
+ Date: {{ order.date_ordered|date:"d/m/Y H:i" }} +
+
+ Statut: + {% if order.status == 'PENDING' %} + En attente + {% elif order.status == 'PREPARED' %} + En cours de préparation + {% elif order.status == 'PAID' %} + + {% 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 %} +
+
+ +
+

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 %} + + + + {% endif %} + {% else %} +

Aucune adresse de livraison renseignée

+ {% if order.shipping_address_can_be_edited %} + + + {% 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' %} +
+ {% csrf_token %} + +
+ {% 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..f5dc311 --- /dev/null +++ b/shop/templates/shop/partials/navigation_base.html @@ -0,0 +1,12 @@ + 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 %} - - - {% if item.product_description %} - - {% endif %} - - - - {% if edit_mode %} - - {% endif %} - - {% endfor %} - - - - - - - - {% if edit_mode %} - - {% endif %} - - + + {% for item in items %} + + + {% if item.product_description %} + + {% endif %} + + + + {% if edit_mode %} + + {% elif cancel_mode and items.count > 1 %} + + {% endif %} + + {% endfor %} + + + + + + + + {% if edit_mode or cancel_mode %} + + {% endif %} + +
{{ item.product_title }}{{ item.product_description }} -
-
- {{ item.color_name }} | {{ item.size_name }} -
-
- {% if edit_mode %} -
-
- {% csrf_token %} - - - {{ item.quantity }} - -
-
- {% else %} - x {{ item.quantity }} - {% endif %} -
{{ item.total_price }} € -
- {% csrf_token %} - - -
-
{{ total_quantity }} produit(s){{ total_price }} €
{{ item.product_title }}{{ item.product_description }} +
+
+ {{ item.color_name }} | {{ item.size_name }} +
+
+ {% if edit_mode %} +
+
+ {% csrf_token %} + + + {{ item.quantity }} + +
+
+ {% else %} + x {{ item.quantity }} + {% endif %} +
{{ item.total_price }} € +
+ {% csrf_token %} + + +
+
+
+ {% csrf_token %} + +
+
{{ total_quantity }} produit(s){{ total_price }} €
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 %} - +{% 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 %} - +{% 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 %} - +{% 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..50926e9 100644 --- a/shop/templates/shop/product_list.html +++ b/shop/templates/shop/product_list.html @@ -6,22 +6,12 @@ {% block content %} - +{% 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 !

+

Photos : Les photos des vêtements n'ont pas encore tous le logo, la description indique où il se situera. +

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 !

@@ -45,24 +48,26 @@
{% endif %} -
-
- -
- {% csrf_token %} - {{ form.as_p }} - -
+
+
+
+ +
+ {% csrf_token %} + {{ form.as_p }} + +
+
-
-
-
- -
- {% csrf_token %} - {{ password_change_form.as_p }} - -
+
+
+ +
+ {% csrf_token %} + {{ password_change_form.as_p }} + +
+
{% endblock %} diff --git a/tournaments/templates/register_tournament.html b/tournaments/templates/register_tournament.html index a2ff531..91117e8 100644 --- a/tournaments/templates/register_tournament.html +++ b/tournaments/templates/register_tournament.html @@ -102,7 +102,10 @@ {{ player.first_name }} {{ player.last_name }}{% if player.licence_id %} ({{ player.licence_id }}){% endif %}
- {{ player.club_name }} + {{ player.club_name }}{% if player.club_member %} | Membre du club{% endif %}{% if player.club_member and tournament.club_member_fee_deduction %} | Tarif réduit{% endif %} +
+
+ {{ player.email }}
Classement à ce jour : {% if player.rank %}{{ player.rank }}{% if player.computed_rank and player.rank != player.computed_rank %} ({{ player.computed_rank }}){% endif %}{% else %}Non classé ({{ player.computed_rank }}){% endif %} @@ -194,7 +197,7 @@ Confirmer votre inscription en payant immédiatement :
{% endif %} {% if tournament.should_request_payment is False or tournament.online_payment_is_mandatory is False or cart_data.waiting_list_position >= 0 %} diff --git a/tournaments/templates/stripe/onboarding_complete.html b/tournaments/templates/stripe/onboarding_complete.html new file mode 100644 index 0000000..ffa7efe --- /dev/null +++ b/tournaments/templates/stripe/onboarding_complete.html @@ -0,0 +1,17 @@ +{% extends 'tournaments/base.html' %} +{% block head_title %} Intégration Stripe {% endblock %} +{% block first_title %} Padel Club {% endblock %} +{% block second_title %} Intégration Stripe {% endblock %} + +{% block content %} +{% load static %} +{% load tz %} + +
+
+
+ +

Veuillez retourner dans l'application pour terminer et valider la configuration.

+
+
+{% endblock %} diff --git a/tournaments/templates/stripe/refresh_account_link.html b/tournaments/templates/stripe/refresh_account_link.html new file mode 100644 index 0000000..6f940da --- /dev/null +++ b/tournaments/templates/stripe/refresh_account_link.html @@ -0,0 +1,19 @@ +{% extends 'tournaments/base.html' %} +{% block head_title %} Configuration Stripe {% endblock %} +{% block first_title %} Padel Club {% endblock %} +{% block second_title %} Configuration Stripe {% endblock %} + +{% block content %} +{% load static %} +{% load tz %} + +
+
+
+ +

Votre lien d'intégration Stripe a expiré. Un nouveau lien doit être généré.

+

Veuillez retourner dans l'application et ré-essayer.

+
+
+
+{% endblock %} diff --git a/tournaments/templates/tournaments/broadcast/broadcast.html b/tournaments/templates/tournaments/broadcast/broadcast.html index 1fe31fc..a0f6a2b 100644 --- a/tournaments/templates/tournaments/broadcast/broadcast.html +++ b/tournaments/templates/tournaments/broadcast/broadcast.html @@ -6,6 +6,19 @@ {% block first_title %}{{ tournament.event.display_name }}{% endblock %} {% block second_title %}Broadcast{% endblock %} +{% block sponsors %} + {% if tournament.event.images.exists %} + + {% endif %} +{% endblock %} + {% block content %} diff --git a/tournaments/templates/tournaments/broadcast/broadcast_base.html b/tournaments/templates/tournaments/broadcast/broadcast_base.html index b4667b5..efa4206 100644 --- a/tournaments/templates/tournaments/broadcast/broadcast_base.html +++ b/tournaments/templates/tournaments/broadcast/broadcast_base.html @@ -34,7 +34,7 @@