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,11 +250,20 @@ class UserView(viewsets.ModelViewSet):
|
|||||||
# Fallback - deny access (prevents AttributeError for AnonymousUser)
|
# Fallback - deny access (prevents AttributeError for AnonymousUser)
|
||||||
return [OnlyRolesAllowed("admin")()]
|
return [OnlyRolesAllowed("admin")()]
|
||||||
|
|
||||||
# Any authenticated user can retrieve (view) any user's profile
|
# Users can only view their own profile, admins can view any profile
|
||||||
#FIXME: popřemýšlet co vše může získat
|
|
||||||
elif self.action == 'retrieve':
|
elif self.action == 'retrieve':
|
||||||
|
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()]
|
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()
|
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
|
created_at__gte=last_week_date
|
||||||
)
|
)
|
||||||
|
|
||||||
|
config = SiteConfiguration.get_solo()
|
||||||
|
|
||||||
send_email_with_context(
|
send_email_with_context(
|
||||||
recipients=SiteConfiguration.get_solo().contact_email,
|
recipients=config.contact_email,
|
||||||
subject="Nový produkt přidán do obchodu",
|
subject="Nový produkt přidán do obchodu",
|
||||||
template_path="email/advertisement/commerce/new_items_added_this_week.html",
|
template_path="email/advertisement/commerce/new_items_added_this_week.html",
|
||||||
context={
|
context={
|
||||||
"products_of_week": products_of_week,
|
"products_of_week": products_of_week,
|
||||||
|
"site_currency": config.currency,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
|
|
||||||
{% if product.price %}
|
{% if product.price %}
|
||||||
<div class="product-price">
|
<div class="product-price">
|
||||||
{{ product.price|floatformat:0 }} {{ product.currency|default:"Kč" }}
|
{{ product.price|floatformat:0 }} {{ site_currency|default:"€" }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 --
|
# -- CENA --
|
||||||
price = models.DecimalField(max_digits=10, decimal_places=2, help_text="Net price (without VAT)")
|
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 - configured by business owner in configuration app!!!
|
||||||
vat_rate = models.ForeignKey(
|
vat_rate = models.ForeignKey(
|
||||||
@@ -127,7 +127,8 @@ class Product(models.Model):
|
|||||||
return self.price * vat_rate.rate_decimal
|
return self.price * vat_rate.rate_decimal
|
||||||
|
|
||||||
def __str__(self):
|
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
|
#obrázek pro produkty
|
||||||
class ProductImage(models.Model):
|
class ProductImage(models.Model):
|
||||||
@@ -164,7 +165,8 @@ class Order(models.Model):
|
|||||||
|
|
||||||
# Stored order grand total; recalculated on save
|
# Stored order grand total; recalculated on save
|
||||||
total_price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
|
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)
|
# fakturační údaje (zkopírované z user profilu při objednávce)
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
@@ -258,12 +260,24 @@ class Order(models.Model):
|
|||||||
if self.pk and not self.items.exists():
|
if self.pk and not self.items.exists():
|
||||||
raise ValidationError("Order must have at least one item.")
|
raise ValidationError("Order must have at least one item.")
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def get_currency(self):
|
||||||
# Keep total_price always in sync with items and discount
|
\"\"\"Get order currency - falls back to site configuration if not set\"\"\"
|
||||||
self.total_price = self.calculate_total_price()
|
if self.currency:
|
||||||
|
return self.currency
|
||||||
|
config = SiteConfiguration.get_solo()
|
||||||
|
return config.currency
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
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:
|
if self.user and is_new:
|
||||||
self.import_data_from_user()
|
self.import_data_from_user()
|
||||||
|
|
||||||
@@ -274,6 +288,15 @@ class Order(models.Model):
|
|||||||
from .tasks import notify_order_successfuly_created
|
from .tasks import notify_order_successfuly_created
|
||||||
notify_order_successfuly_created.delay(order=self, user=self.user)
|
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 ------------------
|
# ------------------ DOPRAVCI A ZPŮSOBY DOPRAVY ------------------
|
||||||
|
|
||||||
@@ -480,7 +503,7 @@ class DiscountCode(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# nebo fixní částka
|
# 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_from = models.DateTimeField(default=timezone.now)
|
||||||
valid_to = models.DateTimeField(null=True, blank=True)
|
valid_to = models.DateTimeField(null=True, blank=True)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from .views import (
|
|||||||
AdminWishlistViewSet,
|
AdminWishlistViewSet,
|
||||||
AnalyticsView,
|
AnalyticsView,
|
||||||
)
|
)
|
||||||
|
from .currency_info_view import CurrencyInfoView
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r'orders', OrderViewSet)
|
router.register(r'orders', OrderViewSet)
|
||||||
@@ -33,4 +34,5 @@ urlpatterns = [
|
|||||||
path('refunds/public/', RefundPublicView.as_view(), name='RefundPublicView'),
|
path('refunds/public/', RefundPublicView.as_view(), name='RefundPublicView'),
|
||||||
path('reviews/create/', ReviewPostPublicView.as_view(), name='ReviewCreate'),
|
path('reviews/create/', ReviewPostPublicView.as_view(), name='ReviewCreate'),
|
||||||
path('analytics/', AnalyticsView.as_view(), name='analytics'),
|
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 import viewsets, mixins, status
|
||||||
from rest_framework.permissions import AllowAny, IsAdminUser, SAFE_METHODS
|
from rest_framework.permissions import AllowAny, IsAdminUser, SAFE_METHODS
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
@@ -63,14 +63,39 @@ from .serializers import (
|
|||||||
|
|
||||||
#FIXME: uravit view na nový order serializer
|
#FIXME: uravit view na nový order serializer
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
list=extend_schema(tags=["commerce", "public"], summary="List Orders (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)"),
|
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(
|
queryset = Order.objects.select_related("carrier", "payment").prefetch_related(
|
||||||
"items__product", "discount"
|
"items__product", "discount"
|
||||||
).order_by("-created_at")
|
).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):
|
def get_serializer_class(self):
|
||||||
if self.action == "mini":
|
if self.action == "mini":
|
||||||
@@ -81,51 +106,17 @@ class OrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge
|
|||||||
return OrderCreateSerializer
|
return OrderCreateSerializer
|
||||||
return OrderReadSerializer
|
return OrderReadSerializer
|
||||||
|
|
||||||
@extend_schema(
|
# Order creation is now handled by CreateModelMixin with proper serializer
|
||||||
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)
|
|
||||||
|
|
||||||
# -- List mini orders -- (public) --
|
# -- List mini orders -- (public) --
|
||||||
@action(detail=False, methods=["get"], url_path="detail")
|
@action(detail=False, methods=["get"], url_path="detail")
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["commerce", "public"],
|
tags=["commerce", "public"],
|
||||||
summary="List mini orders (public)",
|
summary="List mini orders (public - anonymous orders, authenticated - user orders)",
|
||||||
responses={200: OrderMiniSerializer(many=True)},
|
responses={200: OrderMiniSerializer(many=True)},
|
||||||
)
|
)
|
||||||
def mini(self, request, *args, **kwargs):
|
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)
|
page = self.paginate_queryset(qs)
|
||||||
|
|
||||||
if page is not None:
|
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")
|
@action(detail=True, methods=["get"], url_path="items")
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["commerce", "public"],
|
tags=["commerce", "public"],
|
||||||
summary="List order items (public)",
|
summary="List order items (public - anonymous orders, authenticated - user orders)",
|
||||||
responses={200: OrderItemReadSerializer(many=True)},
|
responses={200: OrderItemReadSerializer(many=True)},
|
||||||
)
|
)
|
||||||
def items(self, request, pk=None):
|
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()
|
qs = order.items.select_related("product").all()
|
||||||
ser = OrderItemReadSerializer(qs, many=True)
|
ser = OrderItemReadSerializer(qs, many=True)
|
||||||
return Response(ser.data)
|
return Response(ser.data)
|
||||||
@@ -152,11 +143,11 @@ class OrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge
|
|||||||
@action(detail=True, methods=["get"], url_path="carrier")
|
@action(detail=True, methods=["get"], url_path="carrier")
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["commerce", "public"],
|
tags=["commerce", "public"],
|
||||||
summary="Get order carrier (public)",
|
summary="Get order carrier (public - anonymous orders, authenticated - user orders)",
|
||||||
responses={200: CarrierReadSerializer},
|
responses={200: CarrierReadSerializer},
|
||||||
)
|
)
|
||||||
def carrier_detail(self, request, pk=None):
|
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)
|
ser = CarrierReadSerializer(order.carrier)
|
||||||
return Response(ser.data)
|
return Response(ser.data)
|
||||||
|
|
||||||
@@ -164,11 +155,11 @@ class OrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge
|
|||||||
@action(detail=True, methods=["get"], url_path="payment")
|
@action(detail=True, methods=["get"], url_path="payment")
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["commerce", "public"],
|
tags=["commerce", "public"],
|
||||||
summary="Get order payment (public)",
|
summary="Get order payment (public - anonymous orders, authenticated - user orders)",
|
||||||
responses={200: PaymentReadSerializer},
|
responses={200: PaymentReadSerializer},
|
||||||
)
|
)
|
||||||
def payment_detail(self, request, pk=None):
|
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)
|
ser = PaymentReadSerializer(order.payment)
|
||||||
return Response(ser.data)
|
return Response(ser.data)
|
||||||
|
|
||||||
@@ -216,21 +207,6 @@ class OrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge
|
|||||||
ser = CarrierReadSerializer(order.carrier)
|
ser = CarrierReadSerializer(order.carrier)
|
||||||
return Response(ser.data)
|
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) --
|
# -- Cancel order (authenticated) --
|
||||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
@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"}}}},
|
responses={200: {"type": "object", "properties": {"status": {"type": "string"}}}},
|
||||||
)
|
)
|
||||||
def cancel(self, request, pk=None):
|
def cancel(self, request, pk=None):
|
||||||
order = self.get_object()
|
order = self.get_object() # get_object respects get_queryset filtering
|
||||||
|
|
||||||
# Check if user owns order
|
|
||||||
if order.user != request.user:
|
|
||||||
return Response({'detail': 'Not authorized'}, status=403)
|
|
||||||
|
|
||||||
# Can only cancel if not shipped
|
# Can only cancel if not shipped
|
||||||
if order.carrier and order.carrier.state != Carrier.STATE.PREPARING:
|
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'
|
filename=f'invoice_{order.invoice.invoice_number}.pdf'
|
||||||
)
|
)
|
||||||
|
|
||||||
def retrieve(self, request, *args, **kwargs):
|
# retrieve method removed - get_queryset handles filtering automatically
|
||||||
"""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)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- Public/admin viewsets ----------
|
# ---------- Public/admin viewsets ----------
|
||||||
@@ -577,6 +536,23 @@ class ReviewPublicViewSet(viewsets.ModelViewSet):
|
|||||||
ordering_fields = ["rating", "created_at"]
|
ordering_fields = ["rating", "created_at"]
|
||||||
ordering = ["-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>[^/.]+)')
|
@action(detail=False, methods=['get'], url_path='product/(?P<product_id>[^/.]+)')
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["commerce", "public"],
|
tags=["commerce", "public"],
|
||||||
@@ -608,6 +584,24 @@ class CartViewSet(viewsets.GenericViewSet):
|
|||||||
serializer_class = CartSerializer
|
serializer_class = CartSerializer
|
||||||
permission_classes = [AllowAny]
|
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):
|
def get_or_create_cart(self, request):
|
||||||
"""Get or create cart for current user/session"""
|
"""Get or create cart for current user/session"""
|
||||||
if request.user.is_authenticated:
|
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")
|
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):
|
class CURRENCY(models.TextChoices):
|
||||||
CZK = "CZK", "Czech Koruna"
|
|
||||||
EUR = "EUR", "Euro"
|
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:
|
class Meta:
|
||||||
verbose_name = "Shop Configuration"
|
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:
|
Returns:
|
||||||
stripe.checkout.Session: Vytvořená Stripe Checkout Session.
|
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(
|
session = stripe.checkout.Session.create(
|
||||||
mode="payment",
|
mode="payment",
|
||||||
@@ -31,11 +35,11 @@ class StripeClient:
|
|||||||
client_reference_id=str(order.id),
|
client_reference_id=str(order.id),
|
||||||
line_items=[{
|
line_items=[{
|
||||||
"price_data": {
|
"price_data": {
|
||||||
"currency": "czk",
|
"currency": currency,
|
||||||
"product_data": {
|
"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,
|
"quantity": 1,
|
||||||
}],
|
}],
|
||||||
|
|||||||
Reference in New Issue
Block a user