Compare commits

...

6 Commits

Author SHA1 Message Date
David Bruno Vontor
304194d7ec Update generate-choice-labels.cjs 2026-01-26 09:54:56 +01:00
7c768c9be3 Update CSP, dependencies, and add choice label generator
Replaces nginx.conf CSP map with inline policy and updates the policy for development. Adds new dependencies including Mantine, Radix, Tabler, FontAwesome, and others. Removes the fetch-openapi.js script and introduces generate-choice-labels.cjs to auto-generate TypeScript choice label constants from Orval enums, updating the api:gen script to run this generator. Also updates orval and other dev dependencies, and makes minor formatting changes in orval.config.ts.
2026-01-26 00:10:47 +01:00
ed1b7de7a7 Update views.py 2026-01-25 23:19:56 +01:00
ca62e8895a Add order status email notifications and templates
Introduces email notifications for order status changes (created, cancelled, completed, paid, missing payment, refund events) and adds corresponding HTML email templates. Refactors notification tasks to use only the order object and updates model logic to trigger notifications on relevant status changes.
2026-01-25 22:21:00 +01:00
679cff2366 Consolidate and update initial migrations and models
Regenerated initial migrations for account, advertisement, commerce, configuration, and social apps to include recent schema changes and remove obsolete migration files. Added new migrations for Deutsche Post and Zasilkovna integrations, including new fields and choices. Updated commerce models to improve currency handling and discount code logic. This unifies the database schema and prepares for new features and integrations.
2026-01-25 00:40:52 +01:00
775709bd08 Migrate to global currency system in commerce app
Removed per-product currency in favor of a global site currency managed via SiteConfiguration. Updated models, views, templates, and Stripe integration to use the global currency. Added migration, management command for migration, and API endpoint for currency info. Improved permissions and filtering for orders, reviews, and carts. Expanded supported currencies in configuration.
2026-01-24 21:51:56 +01:00
43 changed files with 1312 additions and 356 deletions

10
.idea/.gitignore generated vendored Normal file
View 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

View 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
View 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
View 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
View 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
View 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="&lt;map/&gt;" />
<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>

View File

@@ -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)),

View File

@@ -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()

View File

@@ -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

View File

@@ -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,
}
)

View File

@@ -60,7 +60,7 @@
{% if product.price %}
<div class="product-price">
{{ product.price|floatformat:0 }} {{ product.currency|default:"" }}
{{ product.price|floatformat:0 }} {{ site_currency|default:"" }}
</div>
{% endif %}

View 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': '',
'USD': '$',
'GBP': '£',
'PLN': '',
'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
]
})

View File

@@ -0,0 +1 @@
# Management commands module

View File

@@ -0,0 +1 @@
# Commerce management commands

View File

@@ -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!")
)

View File

@@ -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')},
},
),
]

View File

@@ -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')},
},
),
]

View File

@@ -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):

View File

@@ -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,
})

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'),
]

View File

@@ -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"""

View File

@@ -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'],
},
),
]

View File

@@ -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),
),
]

View File

@@ -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"

View File

@@ -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

View 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),
),
]

View File

@@ -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,
}],

View 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),
),
]

View File

View 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;
}
}

View File

@@ -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",

View File

@@ -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);
});

View 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);
}

View File

@@ -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";