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.
This commit is contained in:
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
@@ -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
|
||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
4
.idea/misc.xml
generated
Normal file
4
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13 (vontor-cz)" project-jdk-type="Python SDK" />
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/vontor-cz.iml" filepath="$PROJECT_DIR$/.idea/vontor-cz.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
35
.idea/vontor-cz.iml
generated
Normal file
35
.idea/vontor-cz.iml
generated
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="FacetManager">
|
||||
<facet type="django" name="Django">
|
||||
<configuration>
|
||||
<option name="rootFolder" value="$MODULE_DIR$/backend" />
|
||||
<option name="settingsModule" value="vontor_cz/settings.py" />
|
||||
<option name="manageScript" value="$MODULE_DIR$/backend/manage.py" />
|
||||
<option name="environment" value="<map/>" />
|
||||
<option name="doNotUseTestRunner" value="false" />
|
||||
<option name="trackFilePattern" value="migrations" />
|
||||
</configuration>
|
||||
</facet>
|
||||
</component>
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/backend" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.13 (vontor-cz)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="PyDocumentationSettings">
|
||||
<option name="format" value="GOOGLE" />
|
||||
<option name="myDocStringFormat" value="Google" />
|
||||
</component>
|
||||
<component name="TemplatesService">
|
||||
<option name="TEMPLATE_CONFIGURATION" value="Jinja2" />
|
||||
<option name="TEMPLATE_FOLDERS">
|
||||
<list>
|
||||
<option value="$MODULE_DIR$/backend/account/templates" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</module>
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
|
||||
{% if product.price %}
|
||||
<div class="product-price">
|
||||
{{ product.price|floatformat:0 }} {{ product.currency|default:"Kč" }}
|
||||
{{ product.price|floatformat:0 }} {{ site_currency|default:"€" }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
45
backend/commerce/currency_info_view.py
Normal file
45
backend/commerce/currency_info_view.py
Normal file
@@ -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
|
||||
]
|
||||
})
|
||||
1
backend/commerce/management/__init__.py
Normal file
1
backend/commerce/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Management commands module
|
||||
1
backend/commerce/management/commands/__init__.py
Normal file
1
backend/commerce/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Commerce management commands
|
||||
@@ -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!")
|
||||
)
|
||||
36
backend/commerce/migrations/0003_remove_product_currency.py
Normal file
36
backend/commerce/migrations/0003_remove_product_currency.py
Normal file
@@ -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
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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(
|
||||
@@ -258,12 +260,24 @@ class Order(models.Model):
|
||||
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)
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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<product_id>[^/.]+)')
|
||||
@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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
10
backend/thirdparty/stripe/client.py
vendored
10
backend/thirdparty/stripe/client.py
vendored
@@ -20,6 +20,10 @@ 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",
|
||||
@@ -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,
|
||||
}],
|
||||
|
||||
Reference in New Issue
Block a user