Compare commits
6 Commits
8f6d864b4b
...
bruno
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
304194d7ec | ||
| 7c768c9be3 | |||
| ed1b7de7a7 | |||
| ca62e8895a | |||
| 679cff2366 | |||
| 775709bd08 |
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>
|
||||
7
.idea/misc.xml
generated
Normal file
7
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.13 (vontor-cz)" />
|
||||
</component>
|
||||
<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>
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-18 15:11
|
||||
# Generated by Django 5.2.7 on 2026-01-24 22:44
|
||||
|
||||
import account.models
|
||||
import django.contrib.auth.validators
|
||||
@@ -36,6 +36,7 @@ class Migration(migrations.Migration):
|
||||
('email', models.EmailField(db_index=True, max_length=254, unique=True)),
|
||||
('email_verification_token', models.CharField(blank=True, db_index=True, max_length=128, null=True)),
|
||||
('email_verification_sent_at', models.DateTimeField(blank=True, null=True)),
|
||||
('newsletter', models.BooleanField(default=True)),
|
||||
('gdpr', models.BooleanField(default=False)),
|
||||
('is_active', models.BooleanField(default=False)),
|
||||
('create_time', models.DateTimeField(auto_now_add=True)),
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-14 02:23
|
||||
# Generated by Django 5.2.7 on 2026-01-24 22:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -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!")
|
||||
)
|
||||
@@ -1,8 +1,9 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-19 08:55
|
||||
# Generated by Django 5.2.7 on 2026-01-24 22:44
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from decimal import Decimal
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -12,8 +13,10 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('configuration', '0001_initial'),
|
||||
('deutschepost', '0002_deutschepostbulkorder_bulk_label_pdf_and_more'),
|
||||
('stripe', '0001_initial'),
|
||||
('zasilkovna', '0001_initial'),
|
||||
('zasilkovna', '0002_alter_zasilkovnapacket_state'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
@@ -32,14 +35,29 @@ class Migration(migrations.Migration):
|
||||
name='Carrier',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('shipping_method', models.CharField(choices=[('packeta', 'cz#Zásilkovna'), ('store', 'cz#Osobní odběr')], default='store', max_length=20)),
|
||||
('state', models.CharField(choices=[('ordered', 'cz#Objednávka se připravuje'), ('shipped', 'cz#Odesláno'), ('delivered', 'cz#Doručeno'), ('ready_to_pickup', 'cz#Připraveno k vyzvednutí')], default='ordered', max_length=20)),
|
||||
('shipping_method', models.CharField(choices=[('packeta', 'Zásilkovna'), ('deutschepost', 'Deutsche Post'), ('store', 'Osobní odběr')], default='store', max_length=20)),
|
||||
('state', models.CharField(choices=[('ordered', 'Objednávka se připravuje'), ('shipped', 'Odesláno'), ('delivered', 'Doručeno'), ('ready_to_pickup', 'Připraveno k vyzvednutí')], default='ordered', max_length=20)),
|
||||
('weight', models.DecimalField(blank=True, decimal_places=2, help_text='Hmotnost zásilky v kg', max_digits=10, null=True)),
|
||||
('returning', models.BooleanField(default=False, help_text='Zda je tato zásilka na vrácení')),
|
||||
('shipping_price', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
|
||||
('shipping_price', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
|
||||
('deutschepost', models.ManyToManyField(blank=True, related_name='carriers', to='deutschepost.deutschepostorder')),
|
||||
('zasilkovna', models.ManyToManyField(blank=True, related_name='carriers', to='zasilkovna.zasilkovnapacket')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Cart',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('session_key', models.CharField(blank=True, help_text='Session key for anonymous users', max_length=40, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cart', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Cart',
|
||||
'verbose_name_plural': 'Carts',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Category',
|
||||
fields=[
|
||||
@@ -61,7 +79,7 @@ class Migration(migrations.Migration):
|
||||
('code', models.CharField(max_length=50, unique=True)),
|
||||
('description', models.CharField(blank=True, max_length=255)),
|
||||
('percent', models.PositiveIntegerField(blank=True, help_text='Procento sleva 0-100', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])),
|
||||
('amount', models.DecimalField(blank=True, decimal_places=2, help_text='Fixní sleva v CZK', max_digits=10, null=True)),
|
||||
('amount', models.DecimalField(blank=True, decimal_places=2, help_text='Fixed discount amount in site currency', max_digits=10, null=True)),
|
||||
('valid_from', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('valid_to', models.DateTimeField(blank=True, null=True)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
@@ -74,7 +92,8 @@ class Migration(migrations.Migration):
|
||||
name='Payment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('payment_method', models.CharField(choices=[('Site', 'cz#Platba v obchodě'), ('stripe', 'cz#Bankovní převod'), ('cash_on_delivery', 'cz#Dobírka')], default='Site', max_length=30)),
|
||||
('payment_method', models.CharField(choices=[('shop', 'Platba v obchodě'), ('stripe', 'Platební Brána'), ('cash_on_delivery', 'Dobírka')], default='shop', max_length=30)),
|
||||
('payed_at_shop', models.BooleanField(blank=True, default=False, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('stripe', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='payment', to='stripe.stripemodel')),
|
||||
@@ -84,9 +103,9 @@ class Migration(migrations.Migration):
|
||||
name='Order',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(blank=True, choices=[('created', 'cz#Vytvořeno'), ('cancelled', 'cz#Zrušeno'), ('completed', 'cz#Dokončeno'), ('refunding', 'cz#Vrácení v procesu'), ('refunded', 'cz#Vráceno')], default='created', max_length=20, null=True)),
|
||||
('total_price', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
|
||||
('currency', models.CharField(default='CZK', max_length=10)),
|
||||
('status', models.CharField(blank=True, choices=[('created', 'Vytvořeno'), ('cancelled', 'Zrušeno'), ('completed', 'Dokončeno'), ('refunding', 'Vrácení v procesu'), ('refunded', 'Vráceno')], default='created', max_length=20, null=True)),
|
||||
('total_price', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
|
||||
('currency', models.CharField(default='', help_text='Order currency - captured from site configuration at order creation and never changes', max_length=10)),
|
||||
('first_name', models.CharField(max_length=100)),
|
||||
('last_name', models.CharField(max_length=100)),
|
||||
('email', models.EmailField(max_length=254)),
|
||||
@@ -98,11 +117,11 @@ class Migration(migrations.Migration):
|
||||
('note', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('carrier', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='commerce.carrier')),
|
||||
('carrier', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order', to='commerce.carrier')),
|
||||
('discount', models.ManyToManyField(blank=True, related_name='orders', to='commerce.discountcode')),
|
||||
('invoice', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order', to='commerce.invoice')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='orders', to=settings.AUTH_USER_MODEL)),
|
||||
('payment', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='commerce.payment')),
|
||||
('payment', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order', to='commerce.payment')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@@ -112,16 +131,18 @@ class Migration(migrations.Migration):
|
||||
('name', models.CharField(max_length=200)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('code', models.CharField(blank=True, max_length=100, null=True, unique=True)),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('price', models.DecimalField(decimal_places=2, help_text='Net price (without VAT)', max_digits=10)),
|
||||
('url', models.SlugField(unique=True)),
|
||||
('stock', models.PositiveIntegerField(default=0)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('limited_to', models.DateTimeField(blank=True, null=True)),
|
||||
('include_in_week_summary_email', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='products', to='commerce.category')),
|
||||
('default_carrier', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_products', to='commerce.carrier')),
|
||||
('variants', models.ManyToManyField(blank=True, help_text='Symetrické varianty produktu: pokud přidáte variantu A → B, Django automaticky přidá i variantu B → A. Všechny varianty jsou rovnocenné a zobrazí se vzájemně.', related_name='variant_of', to='commerce.product')),
|
||||
('variants', models.ManyToManyField(blank=True, help_text='Symetrické varianty produktu: pokud přidáte variantu A → B, Django automaticky přidá i variantu B → A. Všechny varianty jsou rovnocenné a zobrazí se vzájemně.', to='commerce.product')),
|
||||
('vat_rate', models.ForeignKey(blank=True, help_text='VAT rate for this product. Leave empty to use default rate.', null=True, on_delete=django.db.models.deletion.PROTECT, to='configuration.vatrate')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@@ -145,18 +166,67 @@ class Migration(migrations.Migration):
|
||||
('image', models.ImageField(upload_to='products/')),
|
||||
('alt_text', models.CharField(blank=True, max_length=150)),
|
||||
('is_main', models.BooleanField(default=False)),
|
||||
('order', models.PositiveIntegerField(default=0, help_text='Display order (lower numbers first)')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='commerce.product')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order', '-is_main', 'id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Refund',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('reason_choice', models.CharField(choices=[('retuning_before_fourteen_day_period', 'cz#Vrácení před uplynutím 14-ti denní lhůty'), ('damaged_product', 'cz#Poškozený produkt'), ('wrong_item', 'cz#Špatná položka'), ('other', 'cz#Jiný důvod')], max_length=40)),
|
||||
('reason_choice', models.CharField(choices=[('retuning_before_fourteen_day_period', 'Vrácení před uplynutím 14-ti denní lhůty'), ('damaged_product', 'Poškozený produkt'), ('wrong_item', 'Špatná položka'), ('other', 'Jiný důvod')], max_length=40)),
|
||||
('reason_text', models.TextField(blank=True)),
|
||||
('verified', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='refunds', to='commerce.order')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Wishlist',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('products', models.ManyToManyField(blank=True, help_text='Products saved by the user', related_name='wishlisted_by', to='commerce.product')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='wishlist', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Wishlist',
|
||||
'verbose_name_plural': 'Wishlists',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CartItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity', models.PositiveIntegerField(default=1)),
|
||||
('added_at', models.DateTimeField(auto_now_add=True)),
|
||||
('cart', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='commerce.cart')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='commerce.product')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Cart Item',
|
||||
'verbose_name_plural': 'Cart Items',
|
||||
'unique_together': {('cart', 'product')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Review',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('rating', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)])),
|
||||
('comment', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='commerce.product')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['product', 'rating'], name='commerce_re_product_9cd1a8_idx'), models.Index(fields=['created_at'], name='commerce_re_created_fe14ef_idx')],
|
||||
'unique_together': {('product', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-17 01:37
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('commerce', '0001_initial'),
|
||||
('deutschepost', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='productimage',
|
||||
options={'ordering': ['order', '-is_main', 'id']},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='carrier',
|
||||
name='deutschepost',
|
||||
field=models.ManyToManyField(blank=True, related_name='carriers', to='deutschepost.deutschepostorder'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='productimage',
|
||||
name='order',
|
||||
field=models.PositiveIntegerField(default=0, help_text='Display order (lower numbers first)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='carrier',
|
||||
name='shipping_method',
|
||||
field=models.CharField(choices=[('packeta', 'cz#Zásilkovna'), ('deutschepost', 'cz#Deutsche Post'), ('store', 'cz#Osobní odběr')], default='store', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='product',
|
||||
name='variants',
|
||||
field=models.ManyToManyField(blank=True, help_text='Symetrické varianty produktu: pokud přidáte variantu A → B, Django automaticky přidá i variantu B → A. Všechny varianty jsou rovnocenné a zobrazí se vzájemně.', to='commerce.product'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Cart',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('session_key', models.CharField(blank=True, help_text='Session key for anonymous users', max_length=40, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cart', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Cart',
|
||||
'verbose_name_plural': 'Carts',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Review',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('rating', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)])),
|
||||
('comment', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='commerce.product')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CartItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity', models.PositiveIntegerField(default=1)),
|
||||
('added_at', models.DateTimeField(auto_now_add=True)),
|
||||
('cart', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='commerce.cart')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='commerce.product')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Cart Item',
|
||||
'verbose_name_plural': 'Cart Items',
|
||||
'unique_together': {('cart', 'product')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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,9 @@ 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,22 +260,62 @@ 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):
|
||||
is_new = self.pk is None
|
||||
old_status = None
|
||||
|
||||
# Track old status for change detection
|
||||
if not is_new:
|
||||
try:
|
||||
old_instance = Order.objects.get(pk=self.pk)
|
||||
old_status = old_instance.status
|
||||
except Order.DoesNotExist:
|
||||
pass
|
||||
|
||||
# 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()
|
||||
|
||||
is_new = self.pk is None
|
||||
|
||||
if self.user and is_new:
|
||||
self.import_data_from_user()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Send email notification for new orders
|
||||
if is_new and self.user:
|
||||
if is_new:
|
||||
from .tasks import notify_order_successfuly_created
|
||||
notify_order_successfuly_created.delay(order=self, user=self.user)
|
||||
notify_order_successfuly_created.delay(order=self)
|
||||
|
||||
# Send email notification when status changes to CANCELLED
|
||||
if not is_new and old_status != self.OrderStatus.CANCELLED and self.status == self.OrderStatus.CANCELLED:
|
||||
from .tasks import notify_order_cancelled
|
||||
notify_order_cancelled.delay(order=self)
|
||||
|
||||
# Send email notification when status changes to COMPLETED
|
||||
if not is_new and old_status != self.OrderStatus.COMPLETED and self.status == self.OrderStatus.COMPLETED:
|
||||
from .tasks import notify_order_completed
|
||||
notify_order_completed.delay(order=self)
|
||||
|
||||
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 ------------------
|
||||
@@ -328,7 +371,7 @@ class Carrier(models.Model):
|
||||
self.shipping_method == self.SHIPPING.STORE):
|
||||
|
||||
if hasattr(self, 'order') and self.order:
|
||||
notify_Ready_to_pickup.delay(order=self.order, user=self.order.user)
|
||||
notify_Ready_to_pickup.delay(order=self.order)
|
||||
|
||||
def get_price(self, order=None):
|
||||
if self.shipping_method == self.SHIPPING.ZASILKOVNA:
|
||||
@@ -357,7 +400,7 @@ class Carrier(models.Model):
|
||||
self.returning = False
|
||||
self.save()
|
||||
|
||||
notify_zasilkovna_sended.delay(order=self.order, user=self.order.user)
|
||||
notify_zasilkovna_sended.delay(order=self.order)
|
||||
|
||||
elif self.shipping_method == self.SHIPPING.DEUTSCHEPOST:
|
||||
# Import here to avoid circular imports
|
||||
@@ -376,7 +419,7 @@ class Carrier(models.Model):
|
||||
self.state = self.STATE.READY_TO_PICKUP
|
||||
self.save()
|
||||
|
||||
notify_Ready_to_pickup.delay(order=self.order, user=self.order.user)
|
||||
notify_Ready_to_pickup.delay(order=self.order)
|
||||
|
||||
else:
|
||||
raise ValidationError("Tato metoda dopravy nepodporuje objednání přepravy.")
|
||||
@@ -480,7 +523,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)
|
||||
@@ -683,7 +726,7 @@ class Refund(models.Model):
|
||||
self.order.save(update_fields=["status", "updated_at"])
|
||||
|
||||
|
||||
notify_refund_accepted.delay(order=self.order, user=self.order.user)
|
||||
notify_refund_accepted.delay(order=self.order)
|
||||
|
||||
|
||||
def generate_refund_pdf_for_customer(self):
|
||||
|
||||
@@ -22,147 +22,161 @@ def delete_expired_orders():
|
||||
|
||||
# Zásilkovna
|
||||
@shared_task
|
||||
def notify_zasilkovna_sended(order = None, user = None, **kwargs):
|
||||
if not order or not user:
|
||||
raise ValueError("Order and User must be provided for notification.")
|
||||
def notify_zasilkovna_sended(order = None, **kwargs):
|
||||
if not order:
|
||||
raise ValueError("Order must be provided for notification.")
|
||||
|
||||
if kwargs:
|
||||
print("Additional kwargs received in notify_order_sended:", kwargs)
|
||||
print("Additional kwargs received in notify_zasilkovna_sended:", kwargs)
|
||||
|
||||
send_email_with_context(
|
||||
recipients=user.email,
|
||||
recipients=order.email,
|
||||
subject="Your order has been shipped",
|
||||
template_path="email/order_sended.html",
|
||||
template_path="email/shipping/zasilkovna/zasilkovna_sended.html",
|
||||
context={
|
||||
"user": user,
|
||||
"order": order,
|
||||
})
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# Shop
|
||||
@shared_task
|
||||
def notify_Ready_to_pickup(order = None, user = None, **kwargs):
|
||||
if not order or not user:
|
||||
raise ValueError("Order and User must be provided for notification.")
|
||||
def notify_Ready_to_pickup(order = None, **kwargs):
|
||||
if not order:
|
||||
raise ValueError("Order must be provided for notification.")
|
||||
|
||||
if kwargs:
|
||||
print("Additional kwargs received in notify_order_sended:", kwargs)
|
||||
print("Additional kwargs received in notify_Ready_to_pickup:", kwargs)
|
||||
|
||||
send_email_with_context(
|
||||
recipients=user.email,
|
||||
recipients=order.email,
|
||||
subject="Your order is ready for pickup",
|
||||
template_path="email/order_ready_pickup.html",
|
||||
template_path="email/shipping/ready_to_pickup/ready_to_pickup.html",
|
||||
context={
|
||||
"user": user,
|
||||
"order": order,
|
||||
})
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# -- NOTIFICATIONS ORDER --
|
||||
|
||||
@shared_task
|
||||
def notify_order_successfuly_created(order = None, user = None, **kwargs):
|
||||
if not order or not user:
|
||||
raise ValueError("Order and User must be provided for notification.")
|
||||
def notify_order_successfuly_created(order = None, **kwargs):
|
||||
if not order:
|
||||
raise ValueError("Order must be provided for notification.")
|
||||
|
||||
if kwargs:
|
||||
print("Additional kwargs received in notify_order_successfuly_created:", kwargs)
|
||||
|
||||
send_email_with_context(
|
||||
recipients=user.email,
|
||||
recipients=order.email,
|
||||
subject="Your order has been successfully created",
|
||||
template_path="email/order_created.html",
|
||||
context={
|
||||
"user": user,
|
||||
"order": order,
|
||||
})
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@shared_task
|
||||
def notify_order_payed(order = None, user = None, **kwargs):
|
||||
if not order or not user:
|
||||
raise ValueError("Order and User must be provided for notification.")
|
||||
def notify_order_payed(order = None, **kwargs):
|
||||
if not order:
|
||||
raise ValueError("Order must be provided for notification.")
|
||||
|
||||
if kwargs:
|
||||
print("Additional kwargs received in notify_order_paid:", kwargs)
|
||||
print("Additional kwargs received in notify_order_payed:", kwargs)
|
||||
|
||||
send_email_with_context(
|
||||
recipients=user.email,
|
||||
recipients=order.email,
|
||||
subject="Your order has been paid",
|
||||
template_path="email/order_paid.html",
|
||||
context={
|
||||
"user": user,
|
||||
"order": order,
|
||||
})
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@shared_task
|
||||
def notify_about_missing_payment(order = None, user = None, **kwargs):
|
||||
if not order or not user:
|
||||
raise ValueError("Order and User must be provided for notification.")
|
||||
def notify_about_missing_payment(order = None, **kwargs):
|
||||
if not order:
|
||||
raise ValueError("Order must be provided for notification.")
|
||||
|
||||
if kwargs:
|
||||
print("Additional kwargs received in notify_about_missing_payment:", kwargs)
|
||||
|
||||
send_email_with_context(
|
||||
recipients=user.email,
|
||||
recipients=order.email,
|
||||
subject="Payment missing for your order",
|
||||
template_path="email/order_missing_payment.html",
|
||||
context={
|
||||
"user": user,
|
||||
"order": order,
|
||||
})
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# -- NOTIFICATIONS REFUND --
|
||||
|
||||
@shared_task
|
||||
def notify_refund_items_arrived(order = None, user = None, **kwargs):
|
||||
if not order or not user:
|
||||
raise ValueError("Order and User must be provided for notification.")
|
||||
def notify_refund_items_arrived(order = None, **kwargs):
|
||||
if not order:
|
||||
raise ValueError("Order must be provided for notification.")
|
||||
|
||||
if kwargs:
|
||||
print("Additional kwargs received in notify_refund_items_arrived:", kwargs)
|
||||
|
||||
send_email_with_context(
|
||||
recipients=user.email,
|
||||
recipients=order.email,
|
||||
subject="Your refund items have arrived",
|
||||
template_path="email/order_refund_items_arrived.html",
|
||||
context={
|
||||
"user": user,
|
||||
"order": order,
|
||||
})
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# Refund accepted, retuning money
|
||||
# Refund accepted, returning money
|
||||
@shared_task
|
||||
def notify_refund_accepted(order = None, user = None, **kwargs):
|
||||
if not order or not user:
|
||||
raise ValueError("Order and User must be provided for notification.")
|
||||
def notify_refund_accepted(order = None, **kwargs):
|
||||
if not order:
|
||||
raise ValueError("Order must be provided for notification.")
|
||||
|
||||
if kwargs:
|
||||
print("Additional kwargs received in notify_refund_accepted:", kwargs)
|
||||
|
||||
send_email_with_context(
|
||||
recipients=user.email,
|
||||
recipients=order.email,
|
||||
subject="Your refund has been accepted",
|
||||
template_path="email/order_refund_accepted.html",
|
||||
context={
|
||||
"user": user,
|
||||
"order": order,
|
||||
})
|
||||
|
||||
|
||||
# -- NOTIFICATIONS ORDER STATUS --
|
||||
|
||||
@shared_task
|
||||
def notify_order_cancelled(order = None, **kwargs):
|
||||
if not order:
|
||||
raise ValueError("Order must be provided for notification.")
|
||||
|
||||
pass
|
||||
if kwargs:
|
||||
print("Additional kwargs received in notify_order_cancelled:", kwargs)
|
||||
|
||||
send_email_with_context(
|
||||
recipients=order.email,
|
||||
subject="Your order has been cancelled",
|
||||
template_path="email/order_cancelled.html",
|
||||
context={
|
||||
"order": order,
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
@shared_task
|
||||
def notify_order_completed(order = None, **kwargs):
|
||||
if not order:
|
||||
raise ValueError("Order must be provided for notification.")
|
||||
|
||||
if kwargs:
|
||||
print("Additional kwargs received in notify_order_completed:", kwargs)
|
||||
|
||||
send_email_with_context(
|
||||
recipients=order.email,
|
||||
subject="Your order has been completed",
|
||||
template_path="email/order_completed.html",
|
||||
context={
|
||||
"order": order,
|
||||
})
|
||||
|
||||
50
backend/commerce/templates/email/order_cancelled.html
Normal file
50
backend/commerce/templates/email/order_cancelled.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<h3 style="color:#d9534f; font-size:18px; margin-top:0;">Order Cancelled</h3>
|
||||
|
||||
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
|
||||
|
||||
<p>Your order has been cancelled.</p>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Information</h4>
|
||||
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Order ID:</td>
|
||||
<td style="padding:8px;">{{ order.id }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Total Amount:</td>
|
||||
<td style="padding:8px;">{{ order.total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Cancellation Date:</td>
|
||||
<td style="padding:8px;">{{ order.updated_at|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Items</h4>
|
||||
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
|
||||
<thead>
|
||||
<tr style="background-color:#f9f9f9;">
|
||||
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
|
||||
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
|
||||
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
|
||||
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
|
||||
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if order.payment.status == 'paid' %}
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Refund Information</h4>
|
||||
<p>Since your order was already paid, you will receive a refund of {{ order.total_price }} {{ order.get_currency }}. The refund will be processed within 3-5 business days.</p>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin-top:20px; color:#666;">
|
||||
If you have any questions, please contact our support team.
|
||||
</p>
|
||||
49
backend/commerce/templates/email/order_completed.html
Normal file
49
backend/commerce/templates/email/order_completed.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<h3 style="color:#5cb85c; font-size:18px; margin-top:0;">✓ Order Completed</h3>
|
||||
|
||||
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
|
||||
|
||||
<p>Great news! Your order has been completed and delivered. Thank you for your purchase!</p>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Information</h4>
|
||||
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Order ID:</td>
|
||||
<td style="padding:8px;">{{ order.id }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Total Amount:</td>
|
||||
<td style="padding:8px;">{{ order.total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Completed:</td>
|
||||
<td style="padding:8px;">{{ order.updated_at|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Items</h4>
|
||||
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
|
||||
<thead>
|
||||
<tr style="background-color:#f9f9f9;">
|
||||
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
|
||||
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
|
||||
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
|
||||
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
|
||||
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:20px; padding:15px; background-color:#f0f8f0; border-left:4px solid #5cb85c;">
|
||||
<strong>We hope you enjoyed your purchase!</strong> If you have any feedback or need to return an item, please let us know.
|
||||
</p>
|
||||
|
||||
<p style="margin-top:20px; color:#666;">
|
||||
Thank you for shopping with us!
|
||||
</p>
|
||||
50
backend/commerce/templates/email/order_created.html
Normal file
50
backend/commerce/templates/email/order_created.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<h3 style="color:#333; font-size:18px; margin-top:0;">Order Confirmation</h3>
|
||||
|
||||
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
|
||||
|
||||
<p>Thank you for your order! Your order has been successfully created and is being prepared for shipment.</p>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Details</h4>
|
||||
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
|
||||
<thead>
|
||||
<tr style="background-color:#f9f9f9;">
|
||||
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
|
||||
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
|
||||
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
|
||||
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
|
||||
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Summary</h4>
|
||||
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr>
|
||||
<td style="text-align:right; padding:8px;">Subtotal:</td>
|
||||
<td style="text-align:right; padding:8px; font-weight:bold;">{{ order.total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Shipping Address</h4>
|
||||
<p style="margin:0;">
|
||||
{{ order.first_name }} {{ order.last_name }}<br>
|
||||
{{ order.address }}<br>
|
||||
{{ order.postal_code }} {{ order.city }}<br>
|
||||
{{ order.country }}
|
||||
</p>
|
||||
|
||||
{% if order.note %}
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Special Instructions</h4>
|
||||
<p style="margin:0;">{{ order.note }}</p>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin-top:20px; color:#666;">
|
||||
We will notify you as soon as your order ships. If you have any questions, please contact us.
|
||||
</p>
|
||||
50
backend/commerce/templates/email/order_missing_payment.html
Normal file
50
backend/commerce/templates/email/order_missing_payment.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<h3 style="color:#d9534f; font-size:18px; margin-top:0;">⚠ Payment Reminder</h3>
|
||||
|
||||
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
|
||||
|
||||
<p>We haven't received payment for your order yet. Your order is being held and may be cancelled if payment is not completed soon.</p>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Details</h4>
|
||||
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Order ID:</td>
|
||||
<td style="padding:8px;">{{ order.id }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Amount Due:</td>
|
||||
<td style="padding:8px;">{{ order.total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Created:</td>
|
||||
<td style="padding:8px;">{{ order.created_at|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Items</h4>
|
||||
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
|
||||
<thead>
|
||||
<tr style="background-color:#f9f9f9;">
|
||||
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
|
||||
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
|
||||
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
|
||||
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
|
||||
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:20px; padding:15px; background-color:#f9f9f9; border-left:4px solid #d9534f;">
|
||||
<strong>Please complete your payment as soon as possible to avoid order cancellation.</strong>
|
||||
If you have questions or need assistance, contact us right away.
|
||||
</p>
|
||||
|
||||
<p style="margin-top:20px; color:#666;">
|
||||
Thank you for your business!
|
||||
</p>
|
||||
45
backend/commerce/templates/email/order_paid.html
Normal file
45
backend/commerce/templates/email/order_paid.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<h3 style="color:#333; font-size:18px; margin-top:0;">✓ Payment Received</h3>
|
||||
|
||||
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
|
||||
|
||||
<p>Thank you! Your payment has been successfully received and processed. Your order is now confirmed and will be prepared for shipment.</p>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Information</h4>
|
||||
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Order ID:</td>
|
||||
<td style="padding:8px;">{{ order.id }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Amount Paid:</td>
|
||||
<td style="padding:8px;">{{ order.total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px; font-weight:bold;">Payment Date:</td>
|
||||
<td style="padding:8px;">{{ order.payment.created_at|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Items</h4>
|
||||
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
|
||||
<thead>
|
||||
<tr style="background-color:#f9f9f9;">
|
||||
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
|
||||
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
|
||||
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
|
||||
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
|
||||
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:20px; color:#666;">
|
||||
Your order will be prepared and shipped as soon as possible. You will receive a shipping notification with tracking details.
|
||||
</p>
|
||||
53
backend/commerce/templates/email/order_refund_accepted.html
Normal file
53
backend/commerce/templates/email/order_refund_accepted.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<h3 style="color:#5cb85c; font-size:18px; margin-top:0;">✓ Refund Processed</h3>
|
||||
|
||||
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
|
||||
|
||||
<p>Excellent! Your refund has been approved and processed. The funds will appear in your account within 3-5 business days, depending on your financial institution.</p>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Refund Details</h4>
|
||||
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Original Order ID:</td>
|
||||
<td style="padding:8px;">{{ order.id }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Refund Amount:</td>
|
||||
<td style="padding:8px;">{{ order.total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Processing Date:</td>
|
||||
<td style="padding:8px;">{{ order.updated_at|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px; font-weight:bold;">Status:</td>
|
||||
<td style="padding:8px; color:#5cb85c; font-weight:bold;">✓ Completed</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Refunded Items</h4>
|
||||
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
|
||||
<thead>
|
||||
<tr style="background-color:#f9f9f9;">
|
||||
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
|
||||
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
|
||||
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Refund</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
|
||||
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
|
||||
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:20px; padding:15px; background-color:#f0f8f0; border-left:4px solid #5cb85c;">
|
||||
<strong>Timeline:</strong> Your refund should appear in your account within 3-5 business days. Some banks may take longer during weekends or holidays.
|
||||
</p>
|
||||
|
||||
<p style="margin-top:20px; color:#666;">
|
||||
Thank you for giving us the opportunity to serve you. If you need anything else, please don't hesitate to contact us.
|
||||
</p>
|
||||
@@ -0,0 +1,49 @@
|
||||
<h3 style="color:#333; font-size:18px; margin-top:0;">Return Items Received</h3>
|
||||
|
||||
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
|
||||
|
||||
<p>Thank you! We have received your returned items from order #{{ order.id }}. Our team is now inspecting the items and processing your refund.</p>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Information</h4>
|
||||
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Order ID:</td>
|
||||
<td style="padding:8px;">{{ order.id }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Total Refund Amount:</td>
|
||||
<td style="padding:8px;">{{ order.total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px; font-weight:bold;">Received Date:</td>
|
||||
<td style="padding:8px;">{{ order.updated_at|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Returned Items</h4>
|
||||
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
|
||||
<thead>
|
||||
<tr style="background-color:#f9f9f9;">
|
||||
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
|
||||
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
|
||||
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Refund</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
|
||||
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
|
||||
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:20px; padding:15px; background-color:#f9f9f9; border-left:4px solid #5bc0de;">
|
||||
<strong>What's Next?</strong> We'll inspect the items and confirm the refund within 2-3 business days. You'll receive another confirmation email when your refund has been processed.
|
||||
</p>
|
||||
|
||||
<p style="margin-top:20px; color:#666;">
|
||||
If you have any questions about your return, please contact us.
|
||||
</p>
|
||||
@@ -0,0 +1,49 @@
|
||||
<h3 style="color:#5cb85c; font-size:18px; margin-top:0;">✓ Your Order is Ready for Pickup!</h3>
|
||||
|
||||
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
|
||||
|
||||
<p>Excellent news! Your order is now ready for pickup. You can collect your package at your convenience during store hours.</p>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Pickup Information</h4>
|
||||
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Order ID:</td>
|
||||
<td style="padding:8px;">{{ order.id }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Ready Since:</td>
|
||||
<td style="padding:8px;">{{ order.carrier.updated_at|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px; font-weight:bold;">Pickup Location:</td>
|
||||
<td style="padding:8px;">Our Store</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Items</h4>
|
||||
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
|
||||
<thead>
|
||||
<tr style="background-color:#f9f9f9;">
|
||||
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
|
||||
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
|
||||
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
|
||||
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
|
||||
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:20px; padding:15px; background-color:#f0f8f0; border-left:4px solid #5cb85c;">
|
||||
<strong>What to Bring:</strong> Please bring a valid ID and your order confirmation (this email). Your package is being held for you and will be released upon presentation of these documents.
|
||||
</p>
|
||||
|
||||
<p style="margin-top:20px; color:#666;">
|
||||
Thank you for your business! If you have any questions, please don't hesitate to contact us.
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<h3 style="color:#5cb85c; font-size:18px; margin-top:0;">📦 Your Package is on its Way!</h3>
|
||||
|
||||
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
|
||||
|
||||
<p>Great news! Your order has been shipped via Zásilkovna and is on its way to you.</p>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Shipping Information</h4>
|
||||
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Order ID:</td>
|
||||
<td style="padding:8px;">{{ order.id }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Carrier:</td>
|
||||
<td style="padding:8px;">Zásilkovna</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Shipped Date:</td>
|
||||
<td style="padding:8px;">{{ order.carrier.updated_at|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Delivery Instructions</h4>
|
||||
<p>Your package will be delivered to your selected Zásilkovna pickup point. You will receive an SMS/email notification from Zásilkovna when the package arrives at the pickup point.</p>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Items</h4>
|
||||
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
|
||||
<thead>
|
||||
<tr style="background-color:#f9f9f9;">
|
||||
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
|
||||
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
|
||||
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
|
||||
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
|
||||
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:20px; padding:15px; background-color:#f9f9f9; border-left:4px solid #5cb85c;">
|
||||
<strong>Delivery Address:</strong><br>
|
||||
{{ order.first_name }} {{ order.last_name }}<br>
|
||||
{{ order.address }}<br>
|
||||
{{ order.postal_code }} {{ order.city }}
|
||||
</p>
|
||||
|
||||
<p style="margin-top:20px; color:#666;">
|
||||
You can track your package on the Zásilkovna website. If you have any questions, please contact us.
|
||||
</p>
|
||||
|
||||
@@ -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:
|
||||
@@ -1025,16 +1019,6 @@ class AnalyticsView(APIView):
|
||||
tags=["commerce", "analytics"],
|
||||
summary="Generate custom analytics report",
|
||||
description="Generate custom analytics based on specified modules and parameters",
|
||||
request={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"modules": {"type": "array", "items": {"type": "string", "enum": ["sales", "products", "customers", "shipping", "reviews"]}},
|
||||
"start_date": {"type": "string", "format": "date-time"},
|
||||
"end_date": {"type": "string", "format": "date-time"},
|
||||
"period": {"type": "string", "enum": ["daily", "weekly", "monthly"]},
|
||||
"options": {"type": "object"}
|
||||
}
|
||||
}
|
||||
)
|
||||
def post(self, request):
|
||||
"""Generate custom analytics report based on specified modules"""
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-18 15:11
|
||||
# Generated by Django 5.2.7 on 2026-01-24 22:44
|
||||
|
||||
import django.core.validators
|
||||
from decimal import Decimal
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -27,17 +29,39 @@ class Migration(migrations.Migration):
|
||||
('youtube_url', models.URLField(blank=True, null=True)),
|
||||
('tiktok_url', models.URLField(blank=True, null=True)),
|
||||
('whatsapp_number', models.CharField(blank=True, max_length=20, null=True)),
|
||||
('zasilkovna_shipping_price', models.DecimalField(decimal_places=2, default=50, max_digits=10)),
|
||||
('zasilkovna_shipping_price', models.DecimalField(decimal_places=2, default=Decimal('50.00'), max_digits=10)),
|
||||
('zasilkovna_api_key', models.CharField(blank=True, help_text='API klíč pro přístup k Zásilkovna API (zatím není využito)', max_length=255, null=True)),
|
||||
('zasilkovna_api_password', models.CharField(blank=True, help_text='API heslo pro přístup k Zásilkovna API (zatím není využito)', max_length=255, null=True)),
|
||||
('free_shipping_over', models.DecimalField(decimal_places=2, default=2000, max_digits=10)),
|
||||
('free_shipping_over', models.DecimalField(decimal_places=2, default=Decimal('2000.00'), max_digits=10)),
|
||||
('deutschepost_api_url', models.URLField(default='https://gw.sandbox.deutschepost.com', help_text='Deutsche Post API URL (sandbox/production)', max_length=255)),
|
||||
('deutschepost_client_id', models.CharField(blank=True, help_text='Deutsche Post OAuth Client ID', max_length=255, null=True)),
|
||||
('deutschepost_client_secret', models.CharField(blank=True, help_text='Deutsche Post OAuth Client Secret', max_length=255, null=True)),
|
||||
('deutschepost_customer_ekp', models.CharField(blank=True, help_text='Deutsche Post Customer EKP number', max_length=20, null=True)),
|
||||
('deutschepost_shipping_price', models.DecimalField(decimal_places=2, default=Decimal('6.00'), help_text='Default Deutsche Post shipping price in EUR', max_digits=10)),
|
||||
('multiplying_coupons', models.BooleanField(default=True, help_text='Násobení 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')),
|
||||
('currency', models.CharField(choices=[('CZK', 'cz#Czech Koruna'), ('EUR', 'cz#Euro')], default='CZK', max_length=10)),
|
||||
('currency', models.CharField(choices=[('EUR', 'Euro'), ('CZK', 'Czech Koruna'), ('USD', 'US Dollar'), ('GBP', 'British Pound'), ('PLN', 'Polish Zloty'), ('HUF', 'Hungarian Forint'), ('SEK', 'Swedish Krona'), ('DKK', 'Danish Krone'), ('NOK', 'Norwegian Krone'), ('CHF', 'Swiss Franc')], default='EUR', max_length=10)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Shop Configuration',
|
||||
'verbose_name_plural': 'Shop Configuration',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VATRate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text="E.g. 'German Standard', 'German Reduced', 'Czech Standard'", max_length=100)),
|
||||
('description', models.TextField(blank=True, help_text="Optional description: 'Standard rate for most products', 'Books and food', etc.")),
|
||||
('rate', models.DecimalField(decimal_places=4, help_text='VAT rate as percentage (e.g. 19.00 for 19%)', max_digits=5, validators=[django.core.validators.MinValueValidator(Decimal('0')), django.core.validators.MaxValueValidator(Decimal('100'))])),
|
||||
('is_default', models.BooleanField(default=False, help_text='Default rate for new products')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this VAT rate is active and available for use')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'VAT Rate',
|
||||
'verbose_name_plural': 'VAT Rates',
|
||||
'ordering': ['-is_default', 'rate', 'name'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-17 01:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('configuration', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='siteconfiguration',
|
||||
name='deutschepost_api_url',
|
||||
field=models.URLField(default='https://gw.sandbox.deutschepost.com', help_text='Deutsche Post API URL (sandbox/production)', max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='siteconfiguration',
|
||||
name='deutschepost_client_id',
|
||||
field=models.CharField(blank=True, help_text='Deutsche Post OAuth Client ID', max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='siteconfiguration',
|
||||
name='deutschepost_client_secret',
|
||||
field=models.CharField(blank=True, help_text='Deutsche Post OAuth Client Secret', max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='siteconfiguration',
|
||||
name='deutschepost_customer_ekp',
|
||||
field=models.CharField(blank=True, help_text='Deutsche Post Customer EKP number', max_length=20, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='siteconfiguration',
|
||||
name='deutschepost_shipping_price',
|
||||
field=models.DecimalField(decimal_places=2, default=150, help_text='Default Deutsche Post shipping price', max_digits=10),
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-17 01:37
|
||||
# Generated by Django 5.2.7 on 2026-01-24 22:44
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
|
||||
43
backend/thirdparty/deutschepost/migrations/0002_deutschepostbulkorder_bulk_label_pdf_and_more.py
vendored
Normal file
43
backend/thirdparty/deutschepost/migrations/0002_deutschepostbulkorder_bulk_label_pdf_and_more.py
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-24 22:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('deutschepost', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='deutschepostbulkorder',
|
||||
name='bulk_label_pdf',
|
||||
field=models.FileField(blank=True, help_text='Bulk shipment label PDF', null=True, upload_to='deutschepost/bulk_labels/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='deutschepostbulkorder',
|
||||
name='paperwork_pdf',
|
||||
field=models.FileField(blank=True, help_text='Bulk shipment paperwork PDF', null=True, upload_to='deutschepost/paperwork/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='deutschepostorder',
|
||||
name='label_pdf',
|
||||
field=models.FileField(blank=True, help_text='Shipping label PDF', null=True, upload_to='deutschepost/labels/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='deutschepostorder',
|
||||
name='label_size',
|
||||
field=models.CharField(choices=[('A4', 'A4 (210x297mm)'), ('A5', 'A5 (148x210mm)'), ('A6', 'A6 (105x148mm)')], default='A4', max_length=10),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='deutschepostbulkorder',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('CREATED', 'Vytvořeno'), ('PROCESSING', 'Zpracovává se'), ('COMPLETED', 'Dokončeno'), ('ERROR', 'Chyba')], default='CREATED', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='deutschepostorder',
|
||||
name='state',
|
||||
field=models.CharField(choices=[('CREATED', 'Vytvořeno'), ('FINALIZED', 'Dokončeno'), ('SHIPPED', 'Odesláno'), ('DELIVERED', 'Doručeno'), ('CANCELLED', 'Zrušeno'), ('ERROR', 'Chyba')], default='CREATED', max_length=20),
|
||||
),
|
||||
]
|
||||
12
backend/thirdparty/stripe/client.py
vendored
12
backend/thirdparty/stripe/client.py
vendored
@@ -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,
|
||||
}],
|
||||
|
||||
18
backend/thirdparty/zasilkovna/migrations/0002_alter_zasilkovnapacket_state.py
vendored
Normal file
18
backend/thirdparty/zasilkovna/migrations/0002_alter_zasilkovnapacket_state.py
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-24 22:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('zasilkovna', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='zasilkovnapacket',
|
||||
name='state',
|
||||
field=models.CharField(choices=[('WAITING_FOR_ORDERING_SHIPMENT', 'Čeká na objednání zásilkovny'), ('PENDING', 'Podáno'), ('SENDED', 'Odesláno'), ('ARRIVED', 'Doručeno'), ('CANCELED', 'Zrušeno'), ('RETURNING', 'Posláno zpátky'), ('RETURNED', 'Vráceno')], default='PENDING', max_length=35),
|
||||
),
|
||||
]
|
||||
0
backups/backup-20260124-224819.sql
Normal file
0
backups/backup-20260124-224819.sql
Normal file
@@ -14,11 +14,6 @@ http {
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# Content Security Policy - organized for better readability
|
||||
map $request_uri $csp_policy {
|
||||
default "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src * data: blob:; connect-src 'self' http://127.0.0.1:8000 http://localhost:8000 ws: wss: https://api.paylibo.com; font-src 'self' data: https://fonts.gstatic.com";
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
@@ -32,7 +27,7 @@ http {
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# Ensure CSP is present on SPA document responses too
|
||||
add_header Content-Security-Policy $csp_policy always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://api.paylibo.com; connect-src 'self' http://127.0.0.1:8000 http://localhost:8000 ws: wss: https://api.paylibo.com; font-src 'self' data:" always;
|
||||
}
|
||||
|
||||
# -------------------------
|
||||
@@ -64,7 +59,7 @@ http {
|
||||
client_max_body_size 50m;
|
||||
|
||||
# Ensure CSP is also present on proxied responses
|
||||
add_header Content-Security-Policy $csp_policy always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://api.paylibo.com; connect-src 'self' http://127.0.0.1:8000 http://localhost:8000 ws: wss: https://api.paylibo.com; font-src 'self' data:" always;
|
||||
}
|
||||
|
||||
# -------------------------
|
||||
@@ -74,10 +69,7 @@ http {
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# CSP Policy - Centrally defined above for better maintainability
|
||||
# To add new domains, update the $csp_policy map above
|
||||
# Development: More permissive for external resources
|
||||
# Production: Should be more restrictive and use nonces/hashes where possible
|
||||
add_header Content-Security-Policy $csp_policy always;
|
||||
# Minimal, valid CSP for development (apply on all responses)
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://api.paylibo.com; connect-src 'self' http://127.0.0.1:8000 http://localhost:8000 ws: wss: https://api.paylibo.com; font-src 'self' data:" always;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,33 +8,48 @@
|
||||
"build": "tsc -b && tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"api:gen": "orval --config src/orval.config.ts"
|
||||
"api:gen": "orval --config src/orval.config.ts && node scripts/generate-choice-labels.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"@fortawesome/react-fontawesome": "^3.1.1",
|
||||
"@headlessui/react": "^2.2.9",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@mantine/core": "^8.3.11",
|
||||
"@mantine/dates": "^8.3.11",
|
||||
"@mantine/hooks": "^8.3.11",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@tabler/icons-react": "^3.36.1",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/react-router": "^5.1.20",
|
||||
"axios": "^1.13.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"dayjs": "^1.11.19",
|
||||
"framer-motion": "^12.25.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hook-form": "^7.70.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.8.1",
|
||||
"react-toastify": "^11.0.5",
|
||||
"tailwindcss": "^4.1.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@types/node": "^24.10.4",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"orval": "^7.13.2",
|
||||
"orval": "^8.0.2",
|
||||
"prettier": "^3.7.4",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import axios from "axios";
|
||||
|
||||
// Single config point
|
||||
const config = { schemaUrl: "/api/schema/", baseUrl: "/api/" };
|
||||
|
||||
async function main() {
|
||||
const outDir = path.resolve("./src/openapi");
|
||||
const outFile = path.join(outDir, "schema.json");
|
||||
const base = process.env.VITE_API_BASE_URL || "http://localhost:8000";
|
||||
const url = new URL(config.schemaUrl, base).toString();
|
||||
|
||||
console.log(`[openapi] Fetching schema from ${url}`);
|
||||
const res = await axios.get(url, { headers: { Accept: "application/json" } });
|
||||
|
||||
await fs.promises.mkdir(outDir, { recursive: true });
|
||||
await fs.promises.writeFile(outFile, JSON.stringify(res.data, null, 2), "utf8");
|
||||
console.log(`[openapi] Wrote ${outFile}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[openapi] Failed to fetch schema:", err?.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
228
frontend/scripts/generate-choice-labels.cjs
Normal file
228
frontend/scripts/generate-choice-labels.cjs
Normal file
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Generates TypeScript file with choice labels from Django TextChoices in Python models
|
||||
*
|
||||
* Usage: node scripts/generate-choice-labels.cjs
|
||||
*
|
||||
* This script reads Django models.py files from backend apps and extracts
|
||||
* TextChoices class definitions (format: VALUE = 'value', 'Czech Label')
|
||||
* and generates a TypeScript file with runtime-accessible label mappings.
|
||||
*
|
||||
* This is more reliable than parsing OpenAPI schema since it reads directly
|
||||
* from the source of truth (Python models).
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Configuration - Backend Python apps to scan
|
||||
const PYTHON_APPS = [
|
||||
path.join(__dirname, '../../backend/account'),
|
||||
path.join(__dirname, '../../backend/booking'),
|
||||
path.join(__dirname, '../../backend/commerce'),
|
||||
path.join(__dirname, '../../backend/servicedesk'),
|
||||
path.join(__dirname, '../../backend/product'),
|
||||
path.join(__dirname, '../../backend/configuration'),
|
||||
];
|
||||
const OUTPUT_PATH = path.join(__dirname, '../src/constants/choiceLabels.ts');
|
||||
|
||||
// Convert camelCase to UPPER_SNAKE_CASE
|
||||
function camelToSnakeCase(str) {
|
||||
return str
|
||||
.replace(/([A-Z])/g, '_$1')
|
||||
.replace(/^_/, '') // Remove leading underscore
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
// Extract TextChoices from Python models.py file
|
||||
function parsePythonModels(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const results = {};
|
||||
|
||||
// First, find all model class definitions to track context
|
||||
// Match: class ModelName(SoftDeleteModel): or class ModelName(models.Model):
|
||||
const modelRegex = /class\s+(\w+)\([^)]*(?:Model|SoftDeleteModel)[^)]*\):/g;
|
||||
const modelPositions = [];
|
||||
let modelMatch;
|
||||
|
||||
while ((modelMatch = modelRegex.exec(content)) !== null) {
|
||||
modelPositions.push({
|
||||
name: modelMatch[1],
|
||||
start: modelMatch.index,
|
||||
});
|
||||
}
|
||||
|
||||
// Match TextChoices class definitions:
|
||||
// class RoleChoices(models.TextChoices): or class StatusChoices(models.TextChoices):
|
||||
const classRegex = /class\s+(\w+Choices)\s*\([^)]*TextChoices[^)]*\):\s*\n((?:\s+\w+\s*=\s*[^\n]+\n?)+)/g;
|
||||
|
||||
let classMatch;
|
||||
while ((classMatch = classRegex.exec(content)) !== null) {
|
||||
const [, className, classBody] = classMatch;
|
||||
const choicePosition = classMatch.index;
|
||||
|
||||
// Find the parent model class (closest model before this choice)
|
||||
let parentModel = null;
|
||||
for (let i = modelPositions.length - 1; i >= 0; i--) {
|
||||
if (modelPositions[i].start < choicePosition) {
|
||||
parentModel = modelPositions[i].name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate enum name based on parent model + choice class name
|
||||
// MarketSlot + StatusChoices -> MarketSlotStatusEnum
|
||||
// Reservation + StatusChoices -> ReservationStatusEnum (but spec uses Status9e5Enum)
|
||||
// Order + StatusChoices -> OrderStatusEnum
|
||||
// For top-level choices (no parent), use just the choice name
|
||||
let enumName;
|
||||
if (parentModel && className === 'StatusChoices') {
|
||||
// Use parent model name for nested StatusChoices
|
||||
enumName = `${parentModel}${className.replace(/Choices$/, 'Enum')}`;
|
||||
} else {
|
||||
// Top-level choices like RoleChoices, AccountTypeChoices
|
||||
enumName = className.replace(/Choices$/, 'Enum');
|
||||
}
|
||||
|
||||
// Parse individual choice lines: ADMIN = 'admin', 'Administrátor'
|
||||
const choiceRegex = /\w+\s*=\s*['"]([^'"]+)['"],\s*['"]([^'"]+)['"]/g;
|
||||
const labels = {};
|
||||
|
||||
let choiceMatch;
|
||||
while ((choiceMatch = choiceRegex.exec(classBody)) !== null) {
|
||||
const [, value, label] = choiceMatch;
|
||||
labels[value] = label;
|
||||
}
|
||||
|
||||
if (Object.keys(labels).length > 0) {
|
||||
results[enumName] = labels;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Main function
|
||||
function generateLabels() {
|
||||
console.log('🔍 Scanning Python models for TextChoices...');
|
||||
|
||||
const enums = {};
|
||||
let totalFiles = 0;
|
||||
|
||||
// Scan each backend app for models.py
|
||||
for (const appDir of PYTHON_APPS) {
|
||||
const appName = path.basename(appDir);
|
||||
const modelsFile = path.join(appDir, 'models.py');
|
||||
|
||||
if (!fs.existsSync(modelsFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`📁 Scanning ${appName}/models.py...`);
|
||||
totalFiles++;
|
||||
|
||||
// Parse the models file
|
||||
const results = parsePythonModels(modelsFile);
|
||||
|
||||
for (const [enumName, labels] of Object.entries(results)) {
|
||||
if (enums[enumName]) {
|
||||
// Check for conflicts
|
||||
const existing = JSON.stringify(enums[enumName]);
|
||||
const current = JSON.stringify(labels);
|
||||
|
||||
if (existing !== current) {
|
||||
console.warn(` ⚠️ ${enumName}: Conflict in ${appName}, keeping first version`);
|
||||
}
|
||||
} else {
|
||||
enums[enumName] = labels;
|
||||
console.log(` ✓ ${enumName}: ${Object.keys(labels).length} labels`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalFiles === 0) {
|
||||
console.error('❌ No models.py files found in backend apps');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (Object.keys(enums).length === 0) {
|
||||
console.error('❌ No TextChoices found in Python models');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Generate TypeScript file
|
||||
console.log('📝 Generating TypeScript file...');
|
||||
|
||||
const tsContent = `/**
|
||||
* Auto-generated choice labels from Django TextChoices in Python models
|
||||
* Generated by: scripts/generate-choice-labels.cjs
|
||||
*
|
||||
* DO NOT EDIT MANUALLY - Run \`npm run api:gen\` to regenerate
|
||||
*
|
||||
* Source: backend (TextChoices classes)
|
||||
*/
|
||||
|
||||
${Object.entries(enums).map(([enumName, labels]) => {
|
||||
const enumBaseName = enumName.replace(/Enum$/, '');
|
||||
const constantName = camelToSnakeCase(enumBaseName) + '_LABELS';
|
||||
|
||||
return `/**
|
||||
* Labels for ${enumName}
|
||||
* ${Object.entries(labels).map(([value, label]) => `* ${value}: ${label}`).join('\n * ')}
|
||||
*/
|
||||
export const ${constantName} = ${JSON.stringify(labels, null, 2)} as const;
|
||||
`;
|
||||
}).join('\n')}
|
||||
/**
|
||||
* Type-safe helper to get choice label
|
||||
*/
|
||||
export function getChoiceLabel<T extends string>(
|
||||
labels: Record<string, string>,
|
||||
value: T | undefined | null
|
||||
): string {
|
||||
if (!value) return '';
|
||||
return labels[value] || value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get options array for select dropdowns
|
||||
*/
|
||||
export function getChoiceOptions<T extends string>(
|
||||
labels: Record<T, string>
|
||||
): Array<{ value: T; label: string }> {
|
||||
return Object.entries(labels).map(([value, label]) => ({
|
||||
value: value as T,
|
||||
label: label as string,
|
||||
}));
|
||||
}
|
||||
|
||||
// Auto-generate all OPTIONS exports
|
||||
${Object.entries(enums).map(([enumName]) => {
|
||||
const enumBaseName = enumName.replace(/Enum$/, '');
|
||||
const labelsConstName = camelToSnakeCase(enumBaseName) + '_LABELS';
|
||||
const optionsConstName = camelToSnakeCase(enumBaseName) + '_OPTIONS';
|
||||
return `export const ${optionsConstName} = getChoiceOptions(${labelsConstName});`;
|
||||
}).join('\n')}
|
||||
`;
|
||||
|
||||
// Ensure output directory exists
|
||||
const outputDir = path.dirname(OUTPUT_PATH);
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write file
|
||||
fs.writeFileSync(OUTPUT_PATH, tsContent, 'utf8');
|
||||
|
||||
console.log(`✅ Generated ${OUTPUT_PATH}`);
|
||||
console.log(` Found ${Object.keys(enums).length} enum types with labels`);
|
||||
}
|
||||
|
||||
// Run the script
|
||||
try {
|
||||
generateLabels();
|
||||
} catch (error) {
|
||||
console.error('❌ Error generating choice labels:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineConfig } from "orval";
|
||||
|
||||
const backendUrl = process.env.VITE_BACKEND_URL || "http://localhost:8000";
|
||||
const backendUrl =
|
||||
process.env.VITE_BACKEND_URL || 'http://localhost:8000';
|
||||
|
||||
// může se hodit pokud nechceme při buildu generovat klienta (nechat false pro produkci nebo vynechat)
|
||||
const SKIP_ORVAL = process.env.SKIP_ORVAL === "true";
|
||||
|
||||
Reference in New Issue
Block a user