From 775709bd083ea0159464d1d3be6d12b0392a7ae6 Mon Sep 17 00:00:00 2001 From: Brunobrno Date: Sat, 24 Jan 2026 21:51:56 +0100 Subject: [PATCH] Migrate to global currency system in commerce app Removed per-product currency in favor of a global site currency managed via SiteConfiguration. Updated models, views, templates, and Stripe integration to use the global currency. Added migration, management command for migration, and API endpoint for currency info. Improved permissions and filtering for orders, reviews, and carts. Expanded supported currencies in configuration. --- .idea/.gitignore | 10 ++ .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 4 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + .idea/vontor-cz.iml | 35 ++++ backend/account/views.py | 15 +- backend/advertisement/tasks.py | 5 +- .../commerce/new_items_added_this_week.html | 2 +- backend/commerce/currency_info_view.py | 45 +++++ backend/commerce/management/__init__.py | 1 + .../commerce/management/commands/__init__.py | 1 + .../commands/migrate_to_global_currency.py | 74 ++++++++ .../0003_remove_product_currency.py | 36 ++++ backend/commerce/models.py | 41 ++++- backend/commerce/urls.py | 2 + backend/commerce/views.py | 158 +++++++++--------- backend/configuration/models.py | 12 +- backend/thirdparty/stripe/client.py | 12 +- 19 files changed, 371 insertions(+), 102 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 .idea/vontor-cz.iml create mode 100644 backend/commerce/currency_info_view.py create mode 100644 backend/commerce/management/__init__.py create mode 100644 backend/commerce/management/commands/__init__.py create mode 100644 backend/commerce/management/commands/migrate_to_global_currency.py create mode 100644 backend/commerce/migrations/0003_remove_product_currency.py diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..430eb9f --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..452c296 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vontor-cz.iml b/.idea/vontor-cz.iml new file mode 100644 index 0000000..cc7388e --- /dev/null +++ b/.idea/vontor-cz.iml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/account/views.py b/backend/account/views.py index 16a0fef..b28cbfe 100644 --- a/backend/account/views.py +++ b/backend/account/views.py @@ -250,10 +250,19 @@ class UserView(viewsets.ModelViewSet): # Fallback - deny access (prevents AttributeError for AnonymousUser) return [OnlyRolesAllowed("admin")()] - # Any authenticated user can retrieve (view) any user's profile - #FIXME: popřemýšlet co vše může získat + # Users can only view their own profile, admins can view any profile elif self.action == 'retrieve': - return [IsAuthenticated()] + user = getattr(self, 'request', None) and getattr(self.request, 'user', None) + # Admins can view any user profile + if user and getattr(user, 'is_authenticated', False) and getattr(user, 'role', None) == 'admin': + return [IsAuthenticated()] + + # Users can view their own profile + if user and getattr(user, 'is_authenticated', False) and self.kwargs.get('pk') and str(getattr(user, 'id', '')) == self.kwargs['pk']: + return [IsAuthenticated()] + + # Deny access to other users' profiles + return [OnlyRolesAllowed("admin")()] return super().get_permissions() diff --git a/backend/advertisement/tasks.py b/backend/advertisement/tasks.py index a62019d..d6b7845 100644 --- a/backend/advertisement/tasks.py +++ b/backend/advertisement/tasks.py @@ -38,12 +38,15 @@ def send_newly_added_items_to_store_email_task_last_week(): created_at__gte=last_week_date ) + config = SiteConfiguration.get_solo() + send_email_with_context( - recipients=SiteConfiguration.get_solo().contact_email, + recipients=config.contact_email, subject="Nový produkt přidán do obchodu", template_path="email/advertisement/commerce/new_items_added_this_week.html", context={ "products_of_week": products_of_week, + "site_currency": config.currency, } ) diff --git a/backend/advertisement/templates/email/advertisement/commerce/new_items_added_this_week.html b/backend/advertisement/templates/email/advertisement/commerce/new_items_added_this_week.html index 98eea1a..a959536 100644 --- a/backend/advertisement/templates/email/advertisement/commerce/new_items_added_this_week.html +++ b/backend/advertisement/templates/email/advertisement/commerce/new_items_added_this_week.html @@ -60,7 +60,7 @@ {% if product.price %}
- {{ product.price|floatformat:0 }} {{ product.currency|default:"Kč" }} + {{ product.price|floatformat:0 }} {{ site_currency|default:"€" }}
{% endif %} diff --git a/backend/commerce/currency_info_view.py b/backend/commerce/currency_info_view.py new file mode 100644 index 0000000..576312e --- /dev/null +++ b/backend/commerce/currency_info_view.py @@ -0,0 +1,45 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from drf_spectacular.utils import extend_schema +from configuration.models import SiteConfiguration + +class CurrencyInfoView(APIView): + """ + Get current site currency and display information. + """ + + @extend_schema( + summary="Get site currency information", + description="Returns the current site currency and available options", + tags=["configuration"] + ) + def get(self, request): + config = SiteConfiguration.get_solo() + + currency_symbols = { + 'EUR': '€', + 'CZK': 'Kč', + 'USD': '$', + 'GBP': '£', + 'PLN': 'zł', + 'HUF': 'Ft', + 'SEK': 'kr', + 'DKK': 'kr', + 'NOK': 'kr', + 'CHF': 'Fr' + } + + return Response({ + 'current_currency': config.currency, + 'currency_symbol': currency_symbols.get(config.currency, config.currency), + 'currency_name': dict(SiteConfiguration.CURRENCY.choices)[config.currency], + 'available_currencies': [ + { + 'code': choice[0], + 'name': choice[1], + 'symbol': currency_symbols.get(choice[0], choice[0]) + } + for choice in SiteConfiguration.CURRENCY.choices + ] + }) \ No newline at end of file diff --git a/backend/commerce/management/__init__.py b/backend/commerce/management/__init__.py new file mode 100644 index 0000000..0cf6859 --- /dev/null +++ b/backend/commerce/management/__init__.py @@ -0,0 +1 @@ +# Management commands module \ No newline at end of file diff --git a/backend/commerce/management/commands/__init__.py b/backend/commerce/management/commands/__init__.py new file mode 100644 index 0000000..fc58c38 --- /dev/null +++ b/backend/commerce/management/commands/__init__.py @@ -0,0 +1 @@ +# Commerce management commands \ No newline at end of file diff --git a/backend/commerce/management/commands/migrate_to_global_currency.py b/backend/commerce/management/commands/migrate_to_global_currency.py new file mode 100644 index 0000000..8f2c5fe --- /dev/null +++ b/backend/commerce/management/commands/migrate_to_global_currency.py @@ -0,0 +1,74 @@ +""" +Management command to migrate from per-product currency to global currency system. +Usage: python manage.py migrate_to_global_currency +""" +from django.core.management.base import BaseCommand +from commerce.models import Product, Order +from configuration.models import SiteConfiguration + + +class Command(BaseCommand): + help = 'Migrate from per-product currency to global currency system' + + def add_arguments(self, parser): + parser.add_argument( + '--target-currency', + type=str, + default='EUR', + help='Target currency to migrate to (default: EUR)' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Show what would be changed without making changes' + ) + + def handle(self, *args, **options): + target_currency = options['target_currency'] + dry_run = options['dry_run'] + + self.stdout.write( + self.style.SUCCESS(f"Migrating to global currency: {target_currency}") + ) + + # Check current state + config = SiteConfiguration.get_solo() + self.stdout.write(f"Current site currency: {config.currency}") + + if hasattr(Product.objects.first(), 'currency'): + # Products still have currency field + product_currencies = Product.objects.values_list('currency', flat=True).distinct() + self.stdout.write(f"Product currencies found: {list(product_currencies)}") + + if len(product_currencies) > 1: + self.stdout.write( + self.style.WARNING( + "Multiple currencies detected in products. " + "Consider currency conversion before migration." + ) + ) + + order_currencies = Order.objects.values_list('currency', flat=True).distinct() + order_currencies = [c for c in order_currencies if c] # Remove empty strings + self.stdout.write(f"Order currencies found: {list(order_currencies)}") + + if not dry_run: + # Update site configuration + config.currency = target_currency + config.save() + self.stdout.write( + self.style.SUCCESS(f"Updated site currency to {target_currency}") + ) + + # Update orders with empty currency + orders_updated = Order.objects.filter(currency='').update(currency=target_currency) + self.stdout.write( + self.style.SUCCESS(f"Updated {orders_updated} orders to use {target_currency}") + ) + + else: + self.stdout.write(self.style.WARNING("DRY RUN - No changes made")) + + self.stdout.write( + self.style.SUCCESS("Migration completed successfully!") + ) \ No newline at end of file diff --git a/backend/commerce/migrations/0003_remove_product_currency.py b/backend/commerce/migrations/0003_remove_product_currency.py new file mode 100644 index 0000000..3e47ff7 --- /dev/null +++ b/backend/commerce/migrations/0003_remove_product_currency.py @@ -0,0 +1,36 @@ +# Generated migration to remove Product.currency field and use global currency system +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('commerce', '0002_alter_productimage_options_carrier_deutschepost_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='product', + name='currency', + ), + migrations.AlterField( + model_name='order', + name='currency', + field=models.CharField( + default='', + help_text='Order currency - auto-filled from site configuration', + max_length=10 + ), + ), + migrations.AlterField( + model_name='discountcode', + name='amount', + field=models.DecimalField( + blank=True, + decimal_places=2, + help_text='Fixed discount amount in site currency', + max_digits=10, + null=True + ), + ), + ] \ No newline at end of file diff --git a/backend/commerce/models.py b/backend/commerce/models.py index 4fa9302..07e7e51 100644 --- a/backend/commerce/models.py +++ b/backend/commerce/models.py @@ -71,7 +71,7 @@ class Product(models.Model): # -- CENA -- price = models.DecimalField(max_digits=10, decimal_places=2, help_text="Net price (without VAT)") - currency = models.CharField(max_length=3, default="CZK") + # Currency is now global from SiteConfiguration, not per-product # VAT rate - configured by business owner in configuration app!!! vat_rate = models.ForeignKey( @@ -127,7 +127,8 @@ class Product(models.Model): return self.price * vat_rate.rate_decimal def __str__(self): - return f"{self.name} ({self.get_price_with_vat()} {self.currency.upper()} inkl. MwSt)" + config = SiteConfiguration.get_solo() + return f"{self.name} ({self.get_price_with_vat()} {config.currency} inkl. MwSt)" #obrázek pro produkty class ProductImage(models.Model): @@ -164,7 +165,8 @@ class Order(models.Model): # Stored order grand total; recalculated on save total_price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00')) - currency = models.CharField(max_length=10, default="CZK") +# Currency - captured from site configuration at creation time, never changes + currency = models.CharField(max_length=10, default="", help_text="Order currency - captured from site configuration at order creation and never changes") # fakturační údaje (zkopírované z user profilu při objednávce) user = models.ForeignKey( @@ -257,13 +259,25 @@ class Order(models.Model): # Validate order has items if self.pk and not self.items.exists(): raise ValidationError("Order must have at least one item.") + + def get_currency(self): + \"\"\"Get order currency - falls back to site configuration if not set\"\"\" + if self.currency: + return self.currency + config = SiteConfiguration.get_solo() + return config.currency def save(self, *args, **kwargs): - # Keep total_price always in sync with items and discount - self.total_price = self.calculate_total_price() - - is_new = self.pk is None - + is_new = self.pk is None + + # CRITICAL: Set currency from site configuration ONLY at creation time + # Once set, currency should NEVER change to maintain order integrity + if is_new and not self.currency: + config = SiteConfiguration.get_solo() + self.currency = config.currency + + # Keep total_price always in sync with items and discount + self.total_price = self.calculate_total_price() if self.user and is_new: self.import_data_from_user() @@ -274,6 +288,15 @@ class Order(models.Model): from .tasks import notify_order_successfuly_created notify_order_successfuly_created.delay(order=self, user=self.user) + def cancel_order(self): + """Cancel the order if possible""" + if self.status == self.OrderStatus.CREATED: + self.status = self.OrderStatus.CANCELLED + self.save() + #TODO: udělat ještě kontrolu jestli už nebyla odeslána zásilka a pokud bude už zaplacena tak se uděla refundace a pokud nebude zaplacena tak se zruší brána. + else: + raise ValidationError("Only orders in 'created' status can be cancelled.") + # ------------------ DOPRAVCI A ZPŮSOBY DOPRAVY ------------------ @@ -480,7 +503,7 @@ class DiscountCode(models.Model): ) # nebo fixní částka - amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, help_text="Fixní sleva v CZK") + amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, help_text=\"Fixed discount amount in site currency\") valid_from = models.DateTimeField(default=timezone.now) valid_to = models.DateTimeField(null=True, blank=True) diff --git a/backend/commerce/urls.py b/backend/commerce/urls.py index 4c8d90f..a306808 100644 --- a/backend/commerce/urls.py +++ b/backend/commerce/urls.py @@ -15,6 +15,7 @@ from .views import ( AdminWishlistViewSet, AnalyticsView, ) +from .currency_info_view import CurrencyInfoView router = DefaultRouter() router.register(r'orders', OrderViewSet) @@ -33,4 +34,5 @@ urlpatterns = [ path('refunds/public/', RefundPublicView.as_view(), name='RefundPublicView'), path('reviews/create/', ReviewPostPublicView.as_view(), name='ReviewCreate'), path('analytics/', AnalyticsView.as_view(), name='analytics'), + path('currency/info/', CurrencyInfoView.as_view(), name='currency-info'), ] diff --git a/backend/commerce/views.py b/backend/commerce/views.py index cf9ab0c..462dca4 100644 --- a/backend/commerce/views.py +++ b/backend/commerce/views.py @@ -15,7 +15,7 @@ from .analytics import ( ) -from django.db import transaction +from django.db import transaction, models from rest_framework import viewsets, mixins, status from rest_framework.permissions import AllowAny, IsAdminUser, SAFE_METHODS from rest_framework.decorators import action @@ -63,14 +63,39 @@ from .serializers import ( #FIXME: uravit view na nový order serializer @extend_schema_view( - list=extend_schema(tags=["commerce", "public"], summary="List Orders (public)"), - retrieve=extend_schema(tags=["commerce", "public"], summary="Retrieve Order (public)"), + list=extend_schema(tags=["commerce", "public"], summary="List Orders (public - anonymous orders, authenticated - user orders, admin - all orders)"), + retrieve=extend_schema(tags=["commerce", "public"], summary="Retrieve Order (public - anonymous orders, authenticated - user orders, admin - all orders)"), + create=extend_schema(tags=["commerce", "public"], summary="Create Order (public)"), ) -class OrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): +class OrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet): queryset = Order.objects.select_related("carrier", "payment").prefetch_related( "items__product", "discount" ).order_by("-created_at") - permission_classes = [AllowAny] + permission_classes = [permissions.AllowAny] + + def get_permissions(self): + """Allow public order access (for anonymous orders) and creation, require auth for user orders""" + if self.action in ['create', 'list', 'retrieve', 'mini', 'items', 'carrier_detail', 'payment_detail', 'verify_payment', 'download_invoice']: + return [permissions.AllowAny()] + return super().get_permissions() + + def get_queryset(self): + """Filter orders by user - admins see all, authenticated users see own orders, anonymous users see anonymous orders only""" + queryset = super().get_queryset() + + # Admin users can see all orders + if self.request.user.is_authenticated and getattr(self.request.user, 'role', None) == 'admin': + return queryset + + # Authenticated users see only their own orders (both user-linked and email-matched anonymous orders) + if self.request.user.is_authenticated: + return queryset.filter( + models.Q(user=self.request.user) | + models.Q(user__isnull=True, email=self.request.user.email) + ) + + # Anonymous users can only see anonymous orders (no user linked) + return queryset.filter(user__isnull=True) def get_serializer_class(self): if self.action == "mini": @@ -81,51 +106,17 @@ class OrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge return OrderCreateSerializer return OrderReadSerializer - @extend_schema( - tags=["commerce", "public"], - summary="Create Order (public)", - request=OrderCreateSerializer, - responses={201: OrderReadSerializer}, - examples=[ - OpenApiExample( - "Create order", - value={ - "first_name": "Jan", - "last_name": "Novak", - "email": "jan@example.com", - "phone": "+420123456789", - "address": "Ulice 1", - "city": "Praha", - "postal_code": "11000", - "country": "Czech Republic", - "note": "Prosím doručit odpoledne", - "items": [ - {"product_id": 1, "quantity": 2}, - {"product_id": 7, "quantity": 1}, - ], - "carrier": {"shipping_method": "store"}, - "payment": {"payment_method": "stripe"}, - "discount_codes": ["WELCOME10"], - }, - ) - ], - ) - def create(self, request, *args, **kwargs): - serializer = OrderCreateSerializer(data=request.data, context={"request": request}) - serializer.is_valid(raise_exception=True) - order = serializer.save() - out = OrderReadSerializer(order) - return Response(out.data, status=status.HTTP_201_CREATED) + # Order creation is now handled by CreateModelMixin with proper serializer # -- List mini orders -- (public) -- @action(detail=False, methods=["get"], url_path="detail") @extend_schema( tags=["commerce", "public"], - summary="List mini orders (public)", + summary="List mini orders (public - anonymous orders, authenticated - user orders)", responses={200: OrderMiniSerializer(many=True)}, ) def mini(self, request, *args, **kwargs): - qs = self.get_queryset() + qs = self.get_queryset() # Already filtered by user/anonymous status page = self.paginate_queryset(qs) if page is not None: @@ -139,11 +130,11 @@ class OrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge @action(detail=True, methods=["get"], url_path="items") @extend_schema( tags=["commerce", "public"], - summary="List order items (public)", + summary="List order items (public - anonymous orders, authenticated - user orders)", responses={200: OrderItemReadSerializer(many=True)}, ) def items(self, request, pk=None): - order = self.get_object() + order = self.get_object() # get_object respects get_queryset filtering qs = order.items.select_related("product").all() ser = OrderItemReadSerializer(qs, many=True) return Response(ser.data) @@ -152,11 +143,11 @@ class OrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge @action(detail=True, methods=["get"], url_path="carrier") @extend_schema( tags=["commerce", "public"], - summary="Get order carrier (public)", + summary="Get order carrier (public - anonymous orders, authenticated - user orders)", responses={200: CarrierReadSerializer}, ) def carrier_detail(self, request, pk=None): - order = self.get_object() + order = self.get_object() # get_object respects get_queryset filtering ser = CarrierReadSerializer(order.carrier) return Response(ser.data) @@ -164,11 +155,11 @@ class OrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge @action(detail=True, methods=["get"], url_path="payment") @extend_schema( tags=["commerce", "public"], - summary="Get order payment (public)", + summary="Get order payment (public - anonymous orders, authenticated - user orders)", responses={200: PaymentReadSerializer}, ) def payment_detail(self, request, pk=None): - order = self.get_object() + order = self.get_object() # get_object respects get_queryset filtering ser = PaymentReadSerializer(order.payment) return Response(ser.data) @@ -216,21 +207,6 @@ class OrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge ser = CarrierReadSerializer(order.carrier) return Response(ser.data) - # -- Get user's own orders (authenticated) -- - @action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated]) - @extend_schema( - tags=["commerce"], - summary="Get authenticated user's orders", - responses={200: OrderMiniSerializer(many=True)}, - ) - def my_orders(self, request): - orders = Order.objects.filter(user=request.user).order_by('-created_at') - page = self.paginate_queryset(orders) - if page: - serializer = OrderMiniSerializer(page, many=True) - return self.get_paginated_response(serializer.data) - serializer = OrderMiniSerializer(orders, many=True) - return Response(serializer.data) # -- Cancel order (authenticated) -- @action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated]) @@ -240,11 +216,7 @@ class OrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge responses={200: {"type": "object", "properties": {"status": {"type": "string"}}}}, ) def cancel(self, request, pk=None): - order = self.get_object() - - # Check if user owns order - if order.user != request.user: - return Response({'detail': 'Not authorized'}, status=403) + order = self.get_object() # get_object respects get_queryset filtering # Can only cancel if not shipped if order.carrier and order.carrier.state != Carrier.STATE.PREPARING: @@ -303,20 +275,7 @@ class OrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge filename=f'invoice_{order.invoice.invoice_number}.pdf' ) - def retrieve(self, request, *args, **kwargs): - """Override retrieve to filter by user if authenticated and not admin""" - order = self.get_object() - - # If user is authenticated and not admin, check if they own the order - if request.user.is_authenticated and not request.user.is_staff: - if order.user and order.user != request.user: - return Response({'detail': 'Not found.'}, status=404) - # Also check by email for anonymous orders - elif not order.user and order.email != request.user.email: - return Response({'detail': 'Not found.'}, status=404) - - serializer = self.get_serializer(order) - return Response(serializer.data) + # retrieve method removed - get_queryset handles filtering automatically # ---------- Public/admin viewsets ---------- @@ -577,6 +536,23 @@ class ReviewPublicViewSet(viewsets.ModelViewSet): ordering_fields = ["rating", "created_at"] ordering = ["-created_at"] + def get_queryset(self): + """Filter reviews - admins see all, users see all reviews but can only modify their own""" + queryset = super().get_queryset() + + # Admin users can see and modify all reviews + if self.request.user.is_authenticated and getattr(self.request.user, 'role', None) == 'admin': + return queryset + + # For modification actions, users can only modify their own reviews + if self.action in ['update', 'partial_update', 'destroy']: + if self.request.user.is_authenticated: + return queryset.filter(user=self.request.user) + return queryset.none() + + # For viewing, everyone can see all reviews (they're public) + return queryset + @action(detail=False, methods=['get'], url_path='product/(?P[^/.]+)') @extend_schema( tags=["commerce", "public"], @@ -608,6 +584,24 @@ class CartViewSet(viewsets.GenericViewSet): serializer_class = CartSerializer permission_classes = [AllowAny] + def get_queryset(self): + """Filter carts by user/session - users only see their own cart""" + queryset = super().get_queryset() + + # Admin users can see all carts + if self.request.user.is_authenticated and getattr(self.request.user, 'role', None) == 'admin': + return queryset + + # Authenticated users see only their cart + if self.request.user.is_authenticated: + return queryset.filter(user=self.request.user) + + # Anonymous users see only their session cart + session_key = self.request.session.session_key + if session_key: + return queryset.filter(session_key=session_key, user__isnull=True) + return queryset.none() + def get_or_create_cart(self, request): """Get or create cart for current user/session""" if request.user.is_authenticated: diff --git a/backend/configuration/models.py b/backend/configuration/models.py index 6abf4bf..a21d89b 100644 --- a/backend/configuration/models.py +++ b/backend/configuration/models.py @@ -44,9 +44,17 @@ class SiteConfiguration(models.Model): addition_of_coupons_amount = models.BooleanField(default=False, help_text="Sčítání slevových kupónů v objednávce (ano/ne), pokud ne tak se použije pouze nejvyšší slevový kupón") class CURRENCY(models.TextChoices): - CZK = "CZK", "Czech Koruna" EUR = "EUR", "Euro" - currency = models.CharField(max_length=10, default=CURRENCY.CZK, choices=CURRENCY.choices) + CZK = "CZK", "Czech Koruna" + USD = "USD", "US Dollar" + GBP = "GBP", "British Pound" + PLN = "PLN", "Polish Zloty" + HUF = "HUF", "Hungarian Forint" + SEK = "SEK", "Swedish Krona" + DKK = "DKK", "Danish Krone" + NOK = "NOK", "Norwegian Krone" + CHF = "CHF", "Swiss Franc" + currency = models.CharField(max_length=10, default=CURRENCY.EUR, choices=CURRENCY.choices) class Meta: verbose_name = "Shop Configuration" diff --git a/backend/thirdparty/stripe/client.py b/backend/thirdparty/stripe/client.py index ce5ad4e..fe59348 100644 --- a/backend/thirdparty/stripe/client.py +++ b/backend/thirdparty/stripe/client.py @@ -20,7 +20,11 @@ class StripeClient: Returns: stripe.checkout.Session: Vytvořená Stripe Checkout Session. """ - + from configuration.models import SiteConfiguration + + # Use order currency or fall back to site configuration + currency = order.get_currency().lower() + session = stripe.checkout.Session.create( mode="payment", payment_method_types=["card"], @@ -31,11 +35,11 @@ class StripeClient: client_reference_id=str(order.id), line_items=[{ "price_data": { - "currency": "czk", + "currency": currency, "product_data": { - "name": f"Objednávka {order.id}", + "name": f"Order #{order.id}", }, - "unit_amount": int(order.total_price * 100), # cena v haléřích + "unit_amount": int(order.total_price * 100), # amount in smallest currency unit }, "quantity": 1, }],