diff --git a/backend/Dockerfile b/backend/Dockerfile index f69e6ce..19e8268 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,7 +2,13 @@ FROM python:3.12-slim WORKDIR /app -RUN apt update && apt install ffmpeg -y +RUN apt update && apt install -y \ + weasyprint \ + libcairo2 \ + pango1.0-tools \ + libpango-1.0-0 \ + libgobject-2.0-0 \ + ffmpeg COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/backend/account/migrations/0001_initial.py b/backend/account/migrations/0001_initial.py deleted file mode 100644 index 41ba5ce..0000000 --- a/backend/account/migrations/0001_initial.py +++ /dev/null @@ -1,54 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-28 22:28 - -import account.models -import django.contrib.auth.validators -import django.core.validators -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), - ] - - operations = [ - migrations.CreateModel( - name='CustomUser', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('is_deleted', models.BooleanField(default=False)), - ('deleted_at', models.DateTimeField(blank=True, null=True)), - ('role', models.CharField(choices=[('admin', 'Admin'), ('mod', 'Moderator'), ('regular', 'Regular')], default='regular', max_length=20)), - ('phone_number', models.CharField(blank=True, max_length=16, null=True, unique=True, validators=[django.core.validators.RegexValidator('^\\+?\\d{9,15}$', message='Zadejte platné telefonní číslo.')])), - ('email_verified', models.BooleanField(default=False)), - ('email', models.EmailField(db_index=True, max_length=254, unique=True)), - ('gdpr', models.BooleanField(default=False)), - ('is_active', models.BooleanField(default=False)), - ('create_time', models.DateTimeField(auto_now_add=True)), - ('city', models.CharField(blank=True, max_length=100, null=True)), - ('street', models.CharField(blank=True, max_length=200, null=True)), - ('postal_code', models.CharField(blank=True, max_length=5, null=True, validators=[django.core.validators.RegexValidator(code='invalid_postal_code', message='Postal code must contain exactly 5 digits.', regex='^\\d{5}$')])), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to.', related_name='customuser_set', related_query_name='customuser', to='auth.group')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='customuser_set', related_query_name='customuser', to='auth.permission')), - ], - options={ - 'abstract': False, - }, - managers=[ - ('objects', account.models.CustomUserManager()), - ('active', account.models.ActiveUserManager()), - ], - ), - ] diff --git a/backend/account/migrations/0002_customuser_email_verification_sent_at_and_more.py b/backend/account/migrations/0002_customuser_email_verification_sent_at_and_more.py deleted file mode 100644 index 56b21b1..0000000 --- a/backend/account/migrations/0002_customuser_email_verification_sent_at_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-31 07:36 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('account', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='customuser', - name='email_verification_sent_at', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name='customuser', - name='email_verification_token', - field=models.CharField(blank=True, db_index=True, max_length=128, null=True), - ), - ] diff --git a/backend/account/views.py b/backend/account/views.py index 4660148..74bc90e 100644 --- a/backend/account/views.py +++ b/backend/account/views.py @@ -6,7 +6,7 @@ from .serializers import * from .permissions import * from .models import CustomUser from .tokens import * -from .tasks import send_password_reset_email_task, send_email_verification_task, send_email_clerk_accepted_task +from .tasks import send_password_reset_email_task, send_email_verification_task, send_email_clerk_accepted_task # FIXME: send_email_clerk_accepted_task neexistuje !!! from django.conf import settings import logging logger = logging.getLogger(__name__) diff --git a/backend/commerce/migrations/0001_initial.py b/backend/commerce/migrations/0001_initial.py deleted file mode 100644 index 97b32df..0000000 --- a/backend/commerce/migrations/0001_initial.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-28 22:28 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='Carrier', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), - ('base_price', models.DecimalField(decimal_places=2, default=0, max_digits=10)), - ('delivery_time', models.CharField(blank=True, max_length=100)), - ('is_active', models.BooleanField(default=True)), - ('logo', models.ImageField(blank=True, null=True, upload_to='carriers/')), - ('external_id', models.CharField(blank=True, max_length=50, null=True)), - ], - ), - migrations.CreateModel( - name='Product', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=200)), - ('description', models.TextField(blank=True)), - ('price', models.DecimalField(decimal_places=2, max_digits=10)), - ('currency', models.CharField(default='czk', max_length=10)), - ('stock', models.PositiveIntegerField(default=0)), - ('is_active', models.BooleanField(default=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('default_carrier', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_products', to='commerce.carrier')), - ], - ), - ] diff --git a/backend/commerce/models.py b/backend/commerce/models.py index 0c68cef..23e2b59 100644 --- a/backend/commerce/models.py +++ b/backend/commerce/models.py @@ -1,12 +1,18 @@ from django.db import models from django.conf import settings from django.utils import timezone -from django.utils.translation import gettext_lazy as _ +from django.core.exceptions import ValidationError from decimal import Decimal +from django.template.loader import render_to_string +from django.core.files.base import ContentFile +from django.core.validators import MaxValueValidator, MinValueValidator + +from weasyprint import HTML +import os from configuration.models import ShopConfiguration from thirdparty.zasilkovna.models import ZasilkovnaPacket -from thirdparty.stripe.models import StripePayment +from thirdparty.stripe.models import StripeModel #FIXME: přidat soft delete pro všchny modely !!!! @@ -40,6 +46,17 @@ class Product(models.Model): code = models.CharField(max_length=100, unique=True, blank=True, null=True) + variants = models.ManyToManyField( + "self", + symmetrical=True, + blank=True, + related_name="variant_of", + 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ě." + ), + ) category = models.ForeignKey(Category, related_name='products', on_delete=models.PROTECT) @@ -50,7 +67,7 @@ class Product(models.Model): stock = models.PositiveIntegerField(default=0) is_active = models.BooleanField(default=True) - #limitka (volitelné) + #časový limit (volitelné) limited_to = models.DateTimeField(null=True, blank=True) default_carrier = models.ForeignKey( @@ -85,21 +102,22 @@ class ProductImage(models.Model): class Order(models.Model): class Status(models.TextChoices): - PENDING = "pending", _("Čeká na platbu") - PAID = "paid", _("Zaplaceno") - CANCELLED = "cancelled", _("Zrušeno") - SHIPPED = "shipped", _("Odesláno") - #COMPLETED = "completed", _("Dokončeno") + CREATED = "created", "Vytvořeno" + CANCELLED = "cancelled", "Zrušeno" + COMPLETED = "completed", "Dokončeno" + + REFUNDING = "refunding", "Vrácení v procesu" + REFUNDED = "refunded", "Vráceno" status = models.CharField( - max_length=20, choices=Status.choices, default=Status.PENDING + max_length=20, choices=Status.choices, null=True, blank=True, default=Status.CREATED ) # Stored order grand total; recalculated on save total_price = models.DecimalField(max_digits=10, decimal_places=2, default=0) currency = models.CharField(max_length=10, default="CZK") - # fakturační údaje (zkopírované z user profilu při objednávce) FIXME: rozhodnout se co dát do on_delete + # fakturační údaje (zkopírované z user profilu při objednávce) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.DO_NOTHING, related_name="orders", null=True, blank=True ) @@ -134,6 +152,8 @@ class Order(models.Model): blank=True ) + invoice = models.OneToOneField("Invoice", on_delete=models.CASCADE, related_name="order", null=True, blank=True) + discount = models.ManyToManyField("DiscountCode", blank=True, related_name="orders") def calculate_total_price(self): @@ -181,15 +201,24 @@ class Order(models.Model): # ------------------ DOPRAVCI A ZPŮSOBY DOPRAVY ------------------ + class Carrier(models.Model): class SHIPPING(models.TextChoices): ZASILKOVNA = "packeta", "Zásilkovna" STORE = "store", "Osobní odběr" shipping_method = models.CharField(max_length=20, choices=SHIPPING.choices, default=SHIPPING.STORE) + class STATE(models.TextChoices): + PREPARING = "ordered", "Objednávka se připravuje" + SHIPPED = "shipped", "Odesláno" + DELIVERED = "delivered", "Doručeno" + READY_TO_PICKUP = "ready_to_pickup", "Připraveno k vyzvednutí" + #RETURNING = "returning", "Vracení objednávky" + state = models.CharField(max_length=20, choices=STATE.choices, default=STATE.PREPARING) + # prodejce to přidá později - zasilkovna = models.ForeignKey( - ZasilkovnaPacket, on_delete=models.DO_NOTHING, null=True, blank=True, related_name="carriers" + zasilkovna = models.ManyToManyField( + ZasilkovnaPacket, blank=True, related_name="carriers" ) weight = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, help_text="Hmotnost zásilky v kg") @@ -197,7 +226,6 @@ class Carrier(models.Model): returning = models.BooleanField(default=False, help_text="Zda je tato zásilka na vrácení") def save(self, *args, **kwargs): - super().save(*args, **kwargs) def get_price(self): @@ -210,18 +238,30 @@ class Carrier(models.Model): #tohle bude vyvoláno pomocí admina přes api!!! def start_ordering_shipping(self): if self.shipping_method == self.SHIPPING.ZASILKOVNA: - #už při vytvoření se volá na api Zásilkovny - self.zasilkovna = ZasilkovnaPacket.objects.create() + # Uživatel může objednat více zásilek pokud potřeba + self.zasilkovna.add(ZasilkovnaPacket.objects.create()) + self.returning = False + self.save() + + else: + raise ValidationError("Tato metoda dopravy nepodporuje objednání přepravy.") #... další logika pro jiné způsoby dopravy #TODO: přidat notifikace uživateli, jak pro zásilkovnu, tak pro vyzvednutí v obchodě! - def returning_shipping(self): - self.returning = True + def ready_to_pickup(self): + if self.shipping_method == self.SHIPPING.STORE: + self.state = self.STATE.READY_TO_PICKUP + self.save() + else: + raise ValidationError("Tato metoda dopravy nepodporuje připravení k vyzvednutí.") - if self.shipping_method == self.SHIPPING.ZASILKOVNA: - #volá se na api Zásilkovny - self.zasilkovna.returning_packet() + # def returning_shipping(self, int:id): + # self.returning = True + + # if self.shipping_method == self.SHIPPING.ZASILKOVNA: + # #volá se na api Zásilkovny + # self.zasilkovna.get(id=id).returning_packet() # ------------------ PLATEBNÍ MODELY ------------------ @@ -233,20 +273,33 @@ class Payment(models.Model): CASH_ON_DELIVERY = "cash_on_delivery", "Dobírka" payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.SHOP) - #active = models.BooleanField(default=True) - #FIXME: potvrdit že logika platby funguje správně #veškera logika a interakce bude na stripu (třeba aktualizovaní objednávky na zaplacenou apod.) stripe = models.OneToOneField( - StripePayment, on_delete=models.CASCADE, null=True, blank=True, related_name="payment" + StripeModel, on_delete=models.CASCADE, null=True, blank=True, related_name="payment" ) def save(self, *args, **kwargs): - order = Order.objects.get(payment=self) - - if self.payment_method == self.PAYMENT.SHOP and Carrier.objects.get(orders=order).shipping_method != Carrier.SHIPPING.STORE: - raise ValueError("Platba v obchodě je možná pouze pro osobní odběr.") + if self.order: + order = Order.objects.get(payment=self) + + #validace platebních metod + if self.payment_method == self.PAYMENT.SHOP and Carrier.objects.get(orders=order).shipping_method != Carrier.SHIPPING.STORE: + raise ValueError("Platba v obchodě je možná pouze pro osobní odběr.") + + #validace dobírky (jestli není použitá pro osobní odběr) + elif self.payment_method == self.PAYMENT.CASH_ON_DELIVERY and Carrier.objects.get(orders=order).shipping_method == Carrier.SHIPPING.STORE: + raise ValueError("Dobírka není možná pro osobní odběr.") + + #vytvoření platebních metod pokud nový objekt + if not self.pk: + if self.payment_method == self.PAYMENT.STRIPE: + self.stripe = StripePayment.objects.create(amount=order.total_price) + else: + self.stripe = None + + super().save(*args, **kwargs) @@ -257,7 +310,13 @@ class DiscountCode(models.Model): description = models.CharField(max_length=255, blank=True) # sleva v procentech (0–100) - percent = models.PositiveIntegerField(max_value=100, help_text="Procento sleva 0-100", null=True, blank=True) + percent = models.PositiveIntegerField( + validators=[MinValueValidator(0), MaxValueValidator(100)], + help_text="Procento sleva 0-100", + null=True, + blank=True + ) + # nebo fixní částka amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, help_text="Fixní sleva v CZK") @@ -288,6 +347,8 @@ class DiscountCode(models.Model): if self.usage_limit and self.used_count >= self.usage_limit: return False + return True + def __str__(self): return f"{self.code} ({self.percent}% or {self.amount} CZK)" @@ -369,8 +430,10 @@ class OrderItem(models.Model): if applicable_amount_discounts: if config.addition_of_coupons_amount: total_amount = sum(applicable_amount_discounts) + else: total_amount = max(applicable_amount_discounts) + final_price = final_price - total_amount if final_price < Decimal('0'): @@ -379,4 +442,87 @@ class OrderItem(models.Model): return final_price.quantize(Decimal('0.01')) def __str__(self): - return f"{self.product.name} x{self.quantity}" \ No newline at end of file + return f"{self.product.name} x{self.quantity}" + + def save(self, *args, **kwargs): + if self.pk is None: + if self.order.payment.payment_method: + raise ValueError("Nelze upravit položky z objednávky s již zvolenou platební metodou.") + + else: + #nová položka objednávky, snížit skladové zásoby + if self.product.stock < self.quantity: + raise ValueError("Nedostatečný skladový zásob pro produkt.") + + self.product.stock -= self.quantity + self.product.save(update_fields=["stock"]) + + super().save(*args, **kwargs) + + +class Refund(models.Model): + order = models.ForeignKey(Order, related_name="refunds", on_delete=models.CASCADE) + + class Reason(models.TextChoices): + RETUNING_PERIOD = "retuning_before_fourteen_day_period", "Vrácení před uplynutím 14-ti denní lhůty" + DAMAGED_PRODUCT = "damaged_product", "Poškozený produkt" + WRONG_ITEM = "wrong_item", "Špatná položka" + OTHER = "other", "Jiný důvod" + reason_choice = models.CharField(max_length=30, choices=Reason.choices) + reason_text = models.TextField(blank=True) + + verified = models.BooleanField(default=False) + + created_at = models.DateTimeField(auto_now_add=True) + + #VRACENÍ ZÁSILKY, LOGIKA (DISABLED FOR NOW) + # def save(self, *args, **kwargs): + # # Automaticky aktualizovat stav objednávky na "vráceno" + # if self.pk is None: + # self.order.status = Order.Status.REFUNDING + # self.order.save(update_fields=["status", "updated_at"]) + + # shipping_method = self.order.carrier.shipping_method + + # if shipping_method == Carrier.SHIPPING.ZASILKOVNA: + + # carrier = self.order.carrier; + + # # poslední odeslána/vytvořená zásilka + # # Iniciovat vrácení přes Zásilkovnu + # carrier.zasilkovna.latest('created_at').returning_packet() + # carrier.save() + + # else: + # # Logika pro jiné způsoby dopravy + # pass + + # super().save(*args, **kwargs) + + def refund_completed(self): + # Aktualizovat stav objednávky na "vráceno" + self.order.payment.stripe.refund() + self.order.status = Order.Status.REFUNDED + self.order.save(update_fields=["status", "updated_at"]) + + +class Invoice(models.Model): + invoice_number = models.CharField(max_length=50, unique=True) + + issued_at = models.DateTimeField(auto_now_add=True) + due_date = models.DateTimeField() + + pdf_file = models.FileField(upload_to='invoices/') + + def __str__(self): + return f"Invoice {self.invoice_number} for Order {self.order.id}" + + def generate_invoice_pdf(self): + order = Order.objects.get(invoice=self) + # Render HTML + html_string = render_to_string("invoice/invoice.html", {"invoice": self}) + pdf_bytes = HTML(string=html_string).write_pdf() + + # Save directly to FileField + self.pdf_file.save(f"{self.invoice_number}.pdf", ContentFile(pdf_bytes)) + self.save() \ No newline at end of file diff --git a/backend/commerce/serializers.py b/backend/commerce/serializers.py index ad5ba6b..1a48f9c 100644 --- a/backend/commerce/serializers.py +++ b/backend/commerce/serializers.py @@ -1,4 +1,249 @@ from rest_framework import serializers from drf_spectacular.utils import extend_schema_field -from .models import Category, Product, ProductImage, DiscountCode, Order, OrderItem, Carrier +from decimal import Decimal +from django.contrib.auth import get_user_model + +from .models import ( + Category, + Product, + ProductImage, + DiscountCode, + Refund, + Order, + OrderItem, + Carrier, + Payment, +) + +User = get_user_model() + + +class UserBriefSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["id", "first_name", "last_name", "email"] + + +class ProductBriefSerializer(serializers.ModelSerializer): + class Meta: + model = Product + fields = ["id", "name", "price"] + + +class OrderItemReadSerializer(serializers.ModelSerializer): + product = ProductBriefSerializer(read_only=True) + total_price = serializers.SerializerMethodField() + + def get_total_price(self, obj): + return obj.get_total_price(list(obj.order.discount.all()) if getattr(obj, "order", None) else None) + + class Meta: + model = OrderItem + fields = ["id", "product", "quantity", "total_price"] + read_only_fields = ["id", "total_price"] + + +class CarrierReadSerializer(serializers.ModelSerializer): + class Meta: + model = Carrier + fields = ["id", "shipping_method", "weight", "returning"] + read_only_fields = ["id"] + + +class PaymentReadSerializer(serializers.Serializer): + payment_method = serializers.CharField(read_only=True) + stripe_id = serializers.IntegerField(source="stripe.id", read_only=True, allow_null=True) + + +class OrderReadSerializer(serializers.ModelSerializer): + items = OrderItemReadSerializer(many=True, read_only=True) + carrier = CarrierReadSerializer(read_only=True) + payment = PaymentReadSerializer(source="payment", read_only=True) + user = serializers.SerializerMethodField() + + def get_user(self, obj): + request = self.context.get("request") if hasattr(self, "context") else None + if request and getattr(request, "user", None) and request.user.is_authenticated and obj.user: + return UserBriefSerializer(obj.user).data + return None + + class Meta: + model = Order + fields = [ + "id", + "status", + "total_price", + "currency", + "first_name", + "last_name", + "email", + "phone", + "address", + "city", + "postal_code", + "country", + "note", + "created_at", + "updated_at", + "carrier", + "payment", + "user", + "items", + ] + read_only_fields = [ + "id", + "status", + "total_price", + "currency", + "created_at", + "updated_at", + ] + + +class OrderMiniSerializer(serializers.ModelSerializer): + amount = serializers.DecimalField(max_digits=10, decimal_places=2, source="total_price", read_only=True) + shipping_method = serializers.CharField(source="carrier.shipping_method", read_only=True) + + class Meta: + model = Order + fields = ["id", "amount", "status", "email", "shipping_method"] + read_only_fields = fields + + +# ----------------- CREATE PAYLOAD SERIALIZERS (PUBLIC) ----------------- + +class OrderItemCreateSerializer(serializers.Serializer): + product_id = serializers.IntegerField(label="Product ID") + quantity = serializers.IntegerField(min_value=1, label="Quantity") + + +class CarrierCreateSerializer(serializers.Serializer): + shipping_method = serializers.ChoiceField( + choices=Carrier.SHIPPING.choices, + label="Shipping Method", + help_text="Choose 'store' (pickup) or 'packeta'", + ) + weight = serializers.DecimalField( + required=False, + max_digits=10, + decimal_places=2, + label="Weight (kg)", + ) + + +class PaymentCreateSerializer(serializers.Serializer): + payment_method = serializers.ChoiceField( + choices=Payment.PAYMENT.choices, + label="Payment Method", + help_text="Choose 'shop', 'stripe' or 'cash_on_delivery'", + ) + + +class OrderCreateSerializer(serializers.Serializer): + # Customer/billing + first_name = serializers.CharField(max_length=100, label="First Name") + last_name = serializers.CharField(max_length=100, label="Last Name") + email = serializers.EmailField(label="Email") + phone = serializers.CharField(max_length=20, required=False, allow_blank=True, label="Phone") + address = serializers.CharField(max_length=255, label="Address") + city = serializers.CharField(max_length=100, label="City") + postal_code = serializers.CharField(max_length=20, label="Postal Code") + country = serializers.CharField(max_length=100, required=False, default="Czech Republic", label="Country") + note = serializers.CharField(required=False, allow_blank=True, label="Note") + + # Nested + items = OrderItemCreateSerializer(many=True) + carrier = CarrierCreateSerializer() + payment = PaymentCreateSerializer() + discount_codes = serializers.ListField( + child=serializers.CharField(), required=False, allow_empty=True, label="Discount Codes" + ) + + def validate(self, attrs): + if not attrs.get("items"): + raise serializers.ValidationError({"items": "At least one item is required."}) + return attrs + + +# ----------------- ADMIN/READ MODELS ----------------- + +class CategorySerializer(serializers.ModelSerializer): + class Meta: + model = Category + fields = [ + "id", + "name", + "url", + "parent", + "description", + "image", + ] + + +class ProductImageSerializer(serializers.ModelSerializer): + class Meta: + model = ProductImage + fields = [ + "id", + "product", + "image", + "alt_text", + "is_main", + ] + + +class ProductSerializer(serializers.ModelSerializer): + class Meta: + model = Product + fields = [ + "id", + "name", + "description", + "code", + "category", + "price", + "url", + "stock", + "is_active", + "limited_to", + "default_carrier", + "created_at", + "updated_at", + ] + read_only_fields = ["created_at", "updated_at"] + + +class DiscountCodeSerializer(serializers.ModelSerializer): + class Meta: + model = DiscountCode + fields = [ + "id", + "code", + "description", + "percent", + "amount", + "valid_from", + "valid_to", + "active", + "usage_limit", + "used_count", + "specific_products", + "specific_categories", + ] + read_only_fields = ["used_count"] + + +class RefundSerializer(serializers.ModelSerializer): + class Meta: + model = Refund + fields = [ + "id", + "order", + "reason_choice", + "reason_text", + "verified", + "created_at", + ] + read_only_fields = ["id", "verified", "created_at"] + diff --git a/backend/commerce/tasks.py b/backend/commerce/tasks.py index e69de29..60a9829 100644 --- a/backend/commerce/tasks.py +++ b/backend/commerce/tasks.py @@ -0,0 +1,10 @@ +from .models import Order +from django.utils import timezone + +def delete_expired_orders(): + expired_orders = Order.objects.filter(status=Order.STATUS_CHOICES.CANCELLED, created_at__lt=timezone.now() - timezone.timedelta(hours=24)) + count = expired_orders.count() + expired_orders.delete() + return count + + diff --git a/backend/commerce/templates/invoice/Order.html b/backend/commerce/templates/invoice/Order.html new file mode 100644 index 0000000..fc0ee19 --- /dev/null +++ b/backend/commerce/templates/invoice/Order.html @@ -0,0 +1,41 @@ + + + + + Faktura {{ invoice.invoice_number }} + + + +

Faktura {{ invoice.invoice_number }}

+

Datum vystavení: {{ invoice.issue_date.strftime("%Y-%m-%d") }}

+

Zákazník: {{ invoice.order.customer_name }}

+ + + + + + + + + + + + {% for item in invoice.order.items.all %} + + + + + + + {% endfor %} + +
ProduktMnožstvíCenaCelkem
{{ item.product.name }}{{ item.quantity }}{{ item.price }}{{ item.price * item.quantity }}
+ +

Celkem k úhradě: {{ invoice.total_amount }} Kč

+ + diff --git a/backend/commerce/urls.py b/backend/commerce/urls.py index 5518a66..ecf49dd 100644 --- a/backend/commerce/urls.py +++ b/backend/commerce/urls.py @@ -1,15 +1,24 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .views import CategoryViewSet, ProductViewSet, DiscountCodeViewSet, OrderViewSet +from .views import ( + OrderViewSet, + ProductViewSet, + CategoryViewSet, + ProductImageViewSet, + DiscountCodeViewSet, + RefundViewSet, +) router = DefaultRouter() -router.register(r'categories', CategoryViewSet) -router.register(r'products', ProductViewSet) -router.register(r'discounts', DiscountCodeViewSet) router.register(r'orders', OrderViewSet) +router.register(r'products', ProductViewSet, basename='product') +router.register(r'categories', CategoryViewSet, basename='category') +router.register(r'product-images', ProductImageViewSet, basename='product-image') +router.register(r'discount-codes', DiscountCodeViewSet, basename='discount-code') +router.register(r'refunds', RefundViewSet, basename='refund') urlpatterns = [ path('', include(router.urls)), ] -# NOTE: Carrier endpoints intentionally omitted (TODO) +# NOTE: Other endpoints (categories/products/discounts) can be added later diff --git a/backend/commerce/views.py b/backend/commerce/views.py index e582197..13c2a8a 100644 --- a/backend/commerce/views.py +++ b/backend/commerce/views.py @@ -1,4 +1,382 @@ -from rest_framework import viewsets -from rest_framework.permissions import AllowAny -from drf_spectacular.utils import extend_schema, extend_schema_view +from django.db import transaction +from rest_framework import viewsets, mixins, status +from rest_framework.permissions import AllowAny, IsAdminUser, SAFE_METHODS +from rest_framework.decorators import action +from rest_framework.response import Response +from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiExample + +from .models import ( + Order, + OrderItem, + Carrier, + Payment, + Product, + DiscountCode, + Category, + ProductImage, + Refund, +) +from .serializers import ( + OrderReadSerializer, + OrderMiniSerializer, + OrderCreateSerializer, + OrderItemReadSerializer, + CarrierReadSerializer, + PaymentReadSerializer, + ProductSerializer, + CategorySerializer, + ProductImageSerializer, + DiscountCodeSerializer, + RefundSerializer, +) + + +@extend_schema_view( + list=extend_schema(tags=["Orders"], summary="List Orders (public)"), + retrieve=extend_schema(tags=["Orders"], summary="Retrieve Order (public)"), +) +class OrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): + queryset = Order.objects.select_related("carrier", "payment").prefetch_related( + "items__product", "discount" + ).order_by("-created_at") + permission_classes = [AllowAny] + + def get_serializer_class(self): + if self.action == "mini": + return OrderMiniSerializer + if self.action in ["list", "retrieve"]: + return OrderReadSerializer + if self.action == "create": + return OrderCreateSerializer + return OrderReadSerializer + + @extend_schema( + tags=["Orders"], + 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) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + user = request.user + + #VELMI DŮLEŽITELÉ: vše vytvořit v transakci, aby se nepřidávaly neúplné objednávky + with transaction.atomic(): + + # Create base order (customer details only for now) + order = Order.objects.create( + user=user if getattr(user, "is_authenticated", False) else None, + first_name=data["first_name"], + last_name=data["last_name"], + email=data["email"], + phone=data.get("phone", ""), + address=data["address"], + city=data["city"], + postal_code=data["postal_code"], + country=data.get("country", "Czech Republic"), + note=data.get("note", ""), + ) + + # Carrier + carrier_payload = data["carrier"] + carrier = Carrier.objects.create( + shipping_method=carrier_payload["shipping_method"], + weight=carrier_payload.get("weight"), + ) + order.carrier = carrier + order.save(update_fields=["carrier", "updated_at"]) # recalc later after items + + # Items + items_payload = data["items"] + order_items = [] + for item in items_payload: + product = Product.objects.get(pk=item["product_id"]) # raises 404 if missing + qty = int(item["quantity"]) + order_items.append(OrderItem(order=order, product=product, quantity=qty)) + OrderItem.objects.bulk_create(order_items) + + # Discount codes (optional) + codes = data.get("discount_codes") or [] + if codes: + discounts = list(DiscountCode.objects.filter(code__in=codes)) + order.discount.add(*discounts) + + # Recalculate now that items/discounts/carrier are linked + order.save() + + # Payment and validation + pay_payload = data["payment"] + payment_method = pay_payload["payment_method"] + + # Validate combos (mirror of Payment.save but here we have order) + if payment_method == Payment.PAYMENT.SHOP and order.carrier.shipping_method != Carrier.SHIPPING.STORE: + return Response( + {"payment": "Platba v obchodě je možná pouze pro osobní odběr."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if payment_method == Payment.PAYMENT.CASH_ON_DELIVERY and order.carrier.shipping_method == Carrier.SHIPPING.STORE: + return Response( + {"payment": "Dobírka není možná pro osobní odběr."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Create payment WITHOUT triggering Payment.save (which expects reverse link first) + payment = Payment(payment_method=payment_method) + # Bypass custom save by bulk_create + Payment.objects.bulk_create([payment]) + order.payment = payment + order.save(update_fields=["payment", "updated_at"]) + + # If Stripe, create StripePayment now and attach + if payment_method == Payment.PAYMENT.STRIPE: + from thirdparty.stripe.models import StripePayment + + stripe_obj = StripePayment.objects.create(amount=order.total_price) + payment.stripe = stripe_obj + payment.save(update_fields=["stripe"]) + + out = self.get_serializer(order) + return Response(out.data, status=status.HTTP_201_CREATED) + + # -- List mini orders -- (public) -- + @action(detail=False, methods=["get"], url_path="detail") + @extend_schema( + tags=["Orders"], + summary="List mini orders (public)", + responses={200: OrderMiniSerializer(many=True)}, + ) + def mini(self, request, *args, **kwargs): + qs = self.get_queryset() + page = self.paginate_queryset(qs) + + if page is not None: + ser = OrderMiniSerializer(page, many=True) + return self.get_paginated_response(ser.data) + + ser = OrderMiniSerializer(qs, many=True) + return Response(ser.data) + + # -- Get order items -- (public) -- + @action(detail=True, methods=["get"], url_path="items") + @extend_schema( + tags=["Orders"], + summary="List order items (public)", + responses={200: OrderItemReadSerializer(many=True)}, + ) + def items(self, request, pk=None): + order = self.get_object() + qs = order.items.select_related("product").all() + ser = OrderItemReadSerializer(qs, many=True) + return Response(ser.data) + + # -- Get order carrier -- (public) -- + @action(detail=True, methods=["get"], url_path="carrier") + @extend_schema( + tags=["Orders"], + summary="Get order carrier (public)", + responses={200: CarrierReadSerializer}, + ) + def carrier_detail(self, request, pk=None): + order = self.get_object() + ser = CarrierReadSerializer(order.carrier) + return Response(ser.data) + + # -- Get order payment -- (public) -- + @action(detail=True, methods=["get"], url_path="payment") + @extend_schema( + tags=["Orders"], + summary="Get order payment (public)", + responses={200: PaymentReadSerializer}, + ) + def payment_detail(self, request, pk=None): + order = self.get_object() + ser = PaymentReadSerializer(order.payment) + return Response(ser.data) + + # -- Mark carrier ready to pickup(store) (admin) -- + @action( + detail=True, + methods=["patch"], + url_path="carrier/ready-to-pickup", + permission_classes=[IsAdminUser], + ) + @extend_schema( + tags=["Orders"], + summary="Mark carrier ready to pickup (admin)", + request=None, + responses={200: CarrierReadSerializer}, + ) + def carrier_ready_to_pickup(self, request, pk=None): + order = self.get_object() + if not order.carrier: + return Response({"detail": "Carrier not set."}, status=400) + order.carrier.ready_to_pickup() + order.carrier.refresh_from_db() + ser = CarrierReadSerializer(order.carrier) + return Response(ser.data) + + # -- Start ordering shipping (admin) -- + @action( + detail=True, + methods=["patch"], + url_path="carrier/start-ordering-shipping", + permission_classes=[IsAdminUser], + ) + @extend_schema( + tags=["Orders"], + summary="Start ordering shipping (admin)", + request=None, + responses={200: CarrierReadSerializer}, + ) + def carrier_start_ordering_shipping(self, request, pk=None): + order = self.get_object() + if not order.carrier: + return Response({"detail": "Carrier not set."}, status=400) + order.carrier.start_ordering_shipping() + order.carrier.refresh_from_db() + ser = CarrierReadSerializer(order.carrier) + return Response(ser.data) + + +# -- Invoice PDF for Order -- +class OrderInvoice(viewsets.ViewSet): + @action(detail=True, methods=["get"], url_path="generate-invoice") + @extend_schema( + tags=["Orders"], + summary="Get Invoice PDF for Order (public)", + responses={200: "PDF File"}, + ) + def get(order_id, request): + try: + order = Order.objects.get(pk=order_id) + except Order.DoesNotExist: + return Response({"detail": "Order not found."}, status=status.HTTP_404_NOT_FOUND) + + return Response(order.invoice.pdf_file, content_type='application/pdf') + + +# ---------- Permissions helpers ---------- + +class AdminWriteOnlyOrReadOnly(AllowAny.__class__): + def has_permission(self, request, view): + if request.method in SAFE_METHODS: + return True + return IsAdminUser().has_permission(request, view) + + +class AdminOnlyForPatchOtherwisePublic(AllowAny.__class__): + def has_permission(self, request, view): + if request.method in SAFE_METHODS or request.method == "POST": + return True + if request.method == "PATCH": + return IsAdminUser().has_permission(request, view) + # default to admin for other unsafe + return IsAdminUser().has_permission(request, view) + + +# ---------- Public/admin viewsets ---------- + +# -- Product -- +@extend_schema_view( + list=extend_schema(tags=["Products"], summary="List products (public)"), + retrieve=extend_schema(tags=["Products"], summary="Retrieve product (public)"), + create=extend_schema(tags=["Products"], summary="Create product (admin)"), + partial_update=extend_schema(tags=["Products"], summary="Update product (admin)"), + update=extend_schema(tags=["Products"], summary="Replace product (admin)"), + destroy=extend_schema(tags=["Products"], summary="Delete product (admin)"), +) +class ProductViewSet(viewsets.ModelViewSet): + queryset = Product.objects.all().order_by("-created_at") + serializer_class = ProductSerializer + permission_classes = [AdminWriteOnlyOrReadOnly] + + +# -- Category -- +@extend_schema_view( + list=extend_schema(tags=["Categories"], summary="List categories (public)"), + retrieve=extend_schema(tags=["Categories"], summary="Retrieve category (public)"), + create=extend_schema(tags=["Categories"], summary="Create category (admin)"), + partial_update=extend_schema(tags=["Categories"], summary="Update category (admin)"), + update=extend_schema(tags=["Categories"], summary="Replace category (admin)"), + destroy=extend_schema(tags=["Categories"], summary="Delete category (admin)"), +) +class CategoryViewSet(viewsets.ModelViewSet): + queryset = Category.objects.all().order_by("name") + serializer_class = CategorySerializer + permission_classes = [AdminWriteOnlyOrReadOnly] + + +# -- Product Image -- +@extend_schema_view( + list=extend_schema(tags=["Product Images"], summary="List product images (public)"), + retrieve=extend_schema(tags=["Product Images"], summary="Retrieve product image (public)"), + create=extend_schema(tags=["Product Images"], summary="Create product image (admin)"), + partial_update=extend_schema(tags=["Product Images"], summary="Update product image (admin)"), + update=extend_schema(tags=["Product Images"], summary="Replace product image (admin)"), + destroy=extend_schema(tags=["Product Images"], summary="Delete product image (admin)"), +) +class ProductImageViewSet(viewsets.ModelViewSet): + queryset = ProductImage.objects.all().order_by("-id") + serializer_class = ProductImageSerializer + permission_classes = [AdminWriteOnlyOrReadOnly] + + +# -- Discount Code -- +@extend_schema_view( + list=extend_schema(tags=["Discount Codes"], summary="List discount codes (public)"), + retrieve=extend_schema(tags=["Discount Codes"], summary="Retrieve discount code (public)"), + create=extend_schema(tags=["Discount Codes"], summary="Create discount code (admin)"), + partial_update=extend_schema(tags=["Discount Codes"], summary="Update discount code (admin)"), + update=extend_schema(tags=["Discount Codes"], summary="Replace discount code (admin)"), + destroy=extend_schema(tags=["Discount Codes"], summary="Delete discount code (admin)"), +) +class DiscountCodeViewSet(viewsets.ModelViewSet): + queryset = DiscountCode.objects.all().order_by("-id") + serializer_class = DiscountCodeSerializer + permission_classes = [AdminWriteOnlyOrReadOnly] + + +# -- Refund -- +@extend_schema_view( + list=extend_schema(tags=["Refunds"], summary="List refunds (public)"), + retrieve=extend_schema(tags=["Refunds"], summary="Retrieve refund (public)"), + create=extend_schema(tags=["Refunds"], summary="Create refund (public)"), + partial_update=extend_schema(tags=["Refunds"], summary="Update refund (admin)"), +) +class RefundViewSet(mixins.CreateModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet): + queryset = Refund.objects.select_related("order").all().order_by("-created_at") + serializer_class = RefundSerializer + permission_classes = [AdminOnlyForPatchOtherwisePublic] + + + diff --git a/backend/configuration/apps.py b/backend/configuration/apps.py index d3265d0..8cc1282 100644 --- a/backend/configuration/apps.py +++ b/backend/configuration/apps.py @@ -1,6 +1,5 @@ from django.apps import AppConfig from django.db.utils import OperationalError, ProgrammingError -from .models import ShopConfiguration class ConfigurationConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' @@ -13,9 +12,11 @@ class ConfigurationConfig(AppConfig): makemigrations/migrate don't fail when the table does not yet exist. """ try: - ShopConfiguration.get_solo() + from .models import ShopConfiguration # local import to avoid premature app registry access + ShopConfiguration.get_solo() # creates if missing except (OperationalError, ProgrammingError): - ShopConfiguration.objects.create() + # DB not ready (e.g., before initial migrate); ignore silently + pass diff --git a/backend/social/chat/migrations/__init__.py b/backend/social/chat/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/thirdparty/downloader/migrations/0001_initial.py b/backend/thirdparty/downloader/migrations/0001_initial.py deleted file mode 100644 index 33b2b97..0000000 --- a/backend/thirdparty/downloader/migrations/0001_initial.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-29 14:53 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='DownloaderRecord', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('is_deleted', models.BooleanField(default=False)), - ('deleted_at', models.DateTimeField(blank=True, null=True)), - ('url', models.URLField()), - ('download_time', models.DateTimeField(auto_now_add=True)), - ('format', models.CharField(max_length=50)), - ('length_of_media', models.IntegerField(help_text='Length of media in seconds')), - ('file_size', models.BigIntegerField(help_text='File size in bytes')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/backend/thirdparty/downloader/migrations/__init__.py b/backend/thirdparty/downloader/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/thirdparty/stripe/admin.py b/backend/thirdparty/stripe/admin.py index d23cc8e..c6fe108 100644 --- a/backend/thirdparty/stripe/admin.py +++ b/backend/thirdparty/stripe/admin.py @@ -1,23 +1,2 @@ from django.contrib import admin -from .models import Order -@admin.register(Order) -class OrderAdmin(admin.ModelAdmin): - list_display = ("id", "amount", "currency", "status", "created_at") - list_filter = ("status", "currency", "created_at") - search_fields = ("id", "stripe_session_id", "stripe_payment_intent") - readonly_fields = ("created_at", "stripe_session_id", "stripe_payment_intent") - - fieldsets = ( - (None, { - "fields": ("amount", "currency", "status") - }), - ("Stripe info", { - "fields": ("stripe_session_id", "stripe_payment_intent"), - "classes": ("collapse",), - }), - ("Metadata", { - "fields": ("created_at",), - }), - ) - ordering = ("-created_at",) diff --git a/backend/thirdparty/stripe/client.py b/backend/thirdparty/stripe/client.py new file mode 100644 index 0000000..d0c7e6e --- /dev/null +++ b/backend/thirdparty/stripe/client.py @@ -0,0 +1,54 @@ +import stripe +from django.conf import settings +import json +import os + +FRONTEND_URL = os.getenv("FRONTEND_URL") if not settings.DEBUG else os.getenv("DEBUG_DOMAIN") +SSL = "https://" if os.getenv("USE_SSL") == "true" else "http://" + +stripe.api_key = os.getenv("STRIPE_SECRET_KEY") + +class StripeClient: + + def create_checkout_session(order): + """ + Vytvoří Stripe Checkout Session pro danou objednávku. + Args: + order (Order): Instance objednávky pro kterou se vytváří session. + + Returns: + stripe.checkout.Session: Vytvořená Stripe Checkout Session. + """ + + session = stripe.checkout.Session.create( + mode="payment", + payment_method_types=["card"], + + success_url=f"{SSL}{FRONTEND_URL}/payment/success?order={order.id}", #jenom na grafickou část (webhook reálně ověří stav) + cancel_url=f"{SSL}{FRONTEND_URL}/payment/cancel?order={order.id}", + + client_reference_id=str(order.id), + line_items=[{ + "price_data": { + "currency": "czk", + "product_data": { + "name": f"Objednávka {order.id}", + }, + "unit_amount": int(order.total_price * 100), # cena v haléřích + }, + "quantity": 1, + }], + ) + + return session + + + def refund_order(stripe_payment_intent): + try: + refund = stripe.Refund.create( + payment_intent=stripe_payment_intent + ) + return refund + + except Exception as e: + return json.dumps({"error": str(e)}) \ No newline at end of file diff --git a/backend/thirdparty/stripe/migrations/0001_initial.py b/backend/thirdparty/stripe/migrations/0001_initial.py deleted file mode 100644 index 9377f3e..0000000 --- a/backend/thirdparty/stripe/migrations/0001_initial.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-28 22:28 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='Order', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('amount', models.DecimalField(decimal_places=2, max_digits=10)), - ('currency', models.CharField(default='czk', max_length=10)), - ('status', models.CharField(choices=[('pending', 'Pending'), ('paid', 'Paid'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)), - ('stripe_session_id', models.CharField(blank=True, max_length=255, null=True)), - ('stripe_payment_intent', models.CharField(blank=True, max_length=255, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ], - ), - ] diff --git a/backend/thirdparty/stripe/migrations/__init__.py b/backend/thirdparty/stripe/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/thirdparty/stripe/models.py b/backend/thirdparty/stripe/models.py index 4207bc1..f80f3a3 100644 --- a/backend/thirdparty/stripe/models.py +++ b/backend/thirdparty/stripe/models.py @@ -1,25 +1,68 @@ from django.db import models +from django.apps import apps # Create your models here. #TODO: logika a interakce bude na stripu (třeba aktualizovaní objednávky na zaplacenou apod.) - -class StripePayment(models.Model): - STATUS_CHOICES = [ - ("pending", "Pending"), - ("paid", "Paid"), - ("failed", "Failed"), - ("cancelled", "Cancelled"), - ] - amount = models.DecimalField(max_digits=10, decimal_places=2) - currency = models.CharField(max_length=10, default="czk") - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending") +from .client import StripeClient + +class StripeModel(models.Model): + class STATUS_CHOICES(models.TextChoices): + PENDING = "pending", "Čeká se na platbu" + PAID = "paid", "Zaplaceno" + FAILED = "failed", "Neúspěšné" + CANCELLED = "cancelled", "Zrušeno" + REFUNDING = "refunding", "Platba se vrací" + REFUNDED = "refunded", "Platba úspěšně vrácena" + + status = models.CharField(max_length=20, choices=STATUS_CHOICES.choices, default=STATUS_CHOICES.PENDING) stripe_session_id = models.CharField(max_length=255, blank=True, null=True) stripe_payment_intent = models.CharField(max_length=255, blank=True, null=True) + stripe_session_url = models.URLField(blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True, null=True, blank=True) def __str__(self): - return f"Order {self.id} - {self.status}" \ No newline at end of file + return f"Order {self.id} - {self.status}" + + + + def save(self, *args, **kwargs): + #if new + if self.pk: + Order = apps.get_model('commerce', 'Order') + Payment = apps.get_model('commerce', 'Payment') + + order = Order.objects.get(payment=Payment.objects.get(stripe=self)) + + session = StripeClient.create_checkout_session(order)# <-- předáme self.StripePayment + + self.stripe_session_id = session.id + self.stripe_payment_intent = session.payment_intent + self.stripe_session_url = session.url + + else: + self.updated_at = models.DateTimeField(auto_now=True) + + super().save(*args, **kwargs) + + def paid(self): + self.status = self.STATUS_CHOICES.PAID + self.save() + + def refund(self): + StripeClient.refund_order(self.stripe_payment_intent) + self.status = self.STATUS_CHOICES.REFUNDING + self.save() + + def refund_confirmed(self): + self.status = self.STATUS_CHOICES.REFUNDED + self.save() + + def cancel(self): + StripeClient.cancel_checkout_session(self.stripe_session_id) + self.status = self.STATUS_CHOICES.CANCELLED + self.save() \ No newline at end of file diff --git a/backend/thirdparty/stripe/stripe.md b/backend/thirdparty/stripe/stripe.md new file mode 100644 index 0000000..11af82a --- /dev/null +++ b/backend/thirdparty/stripe/stripe.md @@ -0,0 +1,9 @@ +# Stripe Tutorial + +## Example of redirecting the webhook events to local Django endpoint +``` +stripe listen --forward-to localhost:8000/api/stripe/webhook/ +``` + + +# POUŽÍVEJTE SANDBOX/TESING REŽIM PŘI DEVELOPMENTU!!! \ No newline at end of file diff --git a/backend/account/migrations/__init__.py b/backend/thirdparty/stripe/tasks.py similarity index 100% rename from backend/account/migrations/__init__.py rename to backend/thirdparty/stripe/tasks.py diff --git a/backend/thirdparty/stripe/views.py b/backend/thirdparty/stripe/views.py index 561d29d..f940959 100644 --- a/backend/thirdparty/stripe/views.py +++ b/backend/thirdparty/stripe/views.py @@ -6,73 +6,73 @@ from rest_framework import generics from rest_framework.response import Response from rest_framework.views import APIView from drf_spectacular.utils import extend_schema - -from .models import Order -from .serializers import OrderSerializer import os +import logging + +from .models import StripeTransaction +from commerce.models import Order, Payment + +logger = logging.getLogger(__name__) import stripe stripe.api_key = os.getenv("STRIPE_SECRET_KEY") -class CreateCheckoutSessionView(APIView): +class StripeWebhook(APIView): @extend_schema( tags=["stripe"], ) def post(self, request): - serializer = OrderSerializer(data=request.data) #obecný serializer - serializer.is_valid(raise_exception=True) + payload = request.body + sig_header = request.META['HTTP_STRIPE_SIGNATURE'] - order = Order.objects.create( - amount=serializer.validated_data["amount"], - currency=serializer.validated_data.get("currency", "czk"), - ) + try: + #build stripe event + event = stripe.Webhook.construct_event( + payload, sig_header, os.getenv("STRIPE_WEBHOOK_SECRET") + ) - # Vytvoření Stripe Checkout Session - session = stripe.checkout.Session.create( - payment_method_types=["card"], - line_items=[{ - "price_data": { - "currency": order.currency, - "product_data": {"name": f"Order {order.id}"}, - "unit_amount": int(order.amount * 100), # v centech - }, - "quantity": 1, - }], - mode="payment", - success_url=request.build_absolute_uri(f"/payment/success/{order.id}"), - cancel_url=request.build_absolute_uri(f"/payment/cancel/{order.id}"), - ) + except ValueError as e: + logger.error(f"Invalid payload: {e}") + return HttpResponse(status=400) + + except stripe.error as e: + # stripe error + logger.error(f"Stripe error: {e}") + return HttpResponse(status=400) + - order.stripe_session_id = session.id - order.stripe_payment_intent = session.payment_intent - order.save() - - data = OrderSerializer(order).data - data["checkout_url"] = session.url - return Response(data) - + session = event['data']['object'] + # ZAPLACENO + if event['type'] == 'checkout.session.completed': + + stripe_transaction = StripeTransaction.objects.get(stripe_session_id=session.id) + if stripe_transaction: + stripe_transaction.paid() -@csrf_exempt -def stripe_webhook(request): - payload = request.body - sig_header = request.META.get("HTTP_STRIPE_SIGNATURE") - event = None + logger.info(f"Transaction {stripe_transaction.id} marked as paid.") - try: - event = stripe.Webhook.construct_event( - payload, sig_header, settings.STRIPE_WEBHOOK_SECRET - ) - except stripe.error.SignatureVerificationError: - return HttpResponse(status=400) + else: + logger.warning(f"No transaction found for session ID: {session.id}") - if event["type"] == "checkout.session.completed": - session = event["data"]["object"] - order = Order.objects.filter(stripe_session_id=session.get("id")).first() - if order: - order.status = "paid" + # EXPIRACE (zrušení objednávky) uživatel nezaplatil do 24 hodin! + elif event['type'] == 'checkout.session.expired': + order = Order.objects.get(payment=Payment.objects.get(stripe=StripeTransaction.objects.get(stripe_session_id=session.id))) + order.status = Order.STATUS_CHOICES.CANCELLED order.save() - return HttpResponse(status=200) + elif event['type'] == 'payment_intent.payment_failed': + #nothing to do for now + pass + + # REFUND POTVRZEN + elif event['type'] == 'payment_intent.refunded': + session = event['data']['object'] + stripe_transaction = StripeTransaction.objects.get(stripe_payment_intent=session.id) + + if stripe_transaction: + stripe_transaction.refund_confirmed() + + logger.info(f"Transaction {stripe_transaction.id} marked as refunded.") diff --git a/backend/thirdparty/stripe/where to find webhooks settings.png b/backend/thirdparty/stripe/where to find webhooks settings.png new file mode 100644 index 0000000..abcea44 Binary files /dev/null and b/backend/thirdparty/stripe/where to find webhooks settings.png differ diff --git a/backend/thirdparty/zasilkovna/migrations/__init__.py b/backend/thirdparty/zasilkovna/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/thirdparty/zasilkovna/models.py b/backend/thirdparty/zasilkovna/models.py index 831386a..21e9d09 100644 --- a/backend/thirdparty/zasilkovna/models.py +++ b/backend/thirdparty/zasilkovna/models.py @@ -21,10 +21,11 @@ from django.db import models from django.utils import timezone from django.core.validators import RegexValidator from django.core.files.base import ContentFile +from django.apps import apps from .client import PacketaAPI -from commerce.models import Order, Carrier -from configuration.models import Configuration + +from configuration.models import ShopConfiguration packeta_client = PacketaAPI() # single reusable instance @@ -55,6 +56,13 @@ class ZasilkovnaPacket(models.Model): help_text="Hmotnost zásilky v gramech" ) + # 🚚 návratové směrovací kódy (pro vrácení zásilky) + return_routing = models.JSONField( + default=list, + blank=True, + help_text="Seznam 2 routing stringů pro vrácení zásilky" + ) + class PDF_SIZE(models.TextChoices): A6_ON_A6 = ("A6 on A6", "105x148 mm (A6) label on a page of the same size") A7_ON_A7 = ("A7 on A7", "105x74 mm (A7) label on a page of the same size") @@ -63,19 +71,16 @@ class ZasilkovnaPacket(models.Model): A8_ON_A8 = ("A8 on A8", "50x74 mm (A8) label on a page of the same size") size_of_pdf = models.CharField(max_length=20, choices=PDF_SIZE.choices, default=PDF_SIZE.A6_ON_A6) - - # 🚚 návratové směrovací kódy (pro vrácení zásilky) - return_routing = models.JSONField( - default=list, - blank=True, - help_text="Seznam 2 routing stringů pro vrácení zásilky" - ) - def save(self, *args, **kwargs): - # On first save, create the packet remotely if packet_id is not set + # workaroud to avoid circular import + Carrier = apps.get_model('commerce', 'Carrier') + Order = apps.get_model('commerce', 'Order') + carrier = Carrier.objects.get(zasilkovna=self) order = Order.objects.get(carrier=carrier) + cash_on_delivery = order.payment.payment_method == order.payment.PAYMENT.CASH_ON_DELIVERY + if not self.packet_id: response = packeta_client.create_packet( address_id=self.addressId, @@ -85,14 +90,13 @@ class ZasilkovnaPacket(models.Model): surname=order.last_name, company=order.company, email=order.email, - addressId=Configuration.get_solo().zasilkovna_address_id, + addressId=ShopConfiguration.get_solo().zasilkovna_address_id, - #FIXME: udělat logiku pro počítaní dobírky a hodnoty zboží - cod=100.00, - value=100.00, + cod=order.total_price if cash_on_delivery else 0, # dobírka + value=order.total_price, - currency=Configuration.get_solo().currency, - eshop= Configuration.get_solo().name, + currency=ShopConfiguration.get_solo().currency, + eshop= ShopConfiguration.get_solo().name, ) self.packet_id = response['packet_id'] self.barcode = response['barcode'] diff --git a/backend/thirdparty/zasilkovna/serializers.py b/backend/thirdparty/zasilkovna/serializers.py index e69de29..d0fe9b8 100644 --- a/backend/thirdparty/zasilkovna/serializers.py +++ b/backend/thirdparty/zasilkovna/serializers.py @@ -0,0 +1,38 @@ +from rest_framework import serializers + +from .models import ZasilkovnaPacket, ZasilkovnaShipment + + +class ZasilkovnaPacketSerializer(serializers.ModelSerializer): + class Meta: + model = ZasilkovnaPacket + fields = [ + "id", + "created_at", + "packet_id", + "barcode", + "state", + "weight", + "return_routing", + ] + read_only_fields = fields + + +class TrackingURLSerializer(serializers.Serializer): + barcode = serializers.CharField(read_only=True) + tracking_url = serializers.URLField(read_only=True) + + +class ZasilkovnaShipmentSerializer(serializers.ModelSerializer): + packets = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + + class Meta: + model = ZasilkovnaShipment + fields = [ + "id", + "created_at", + "shipment_id", + "barcode", + "packets", + ] + read_only_fields = fields diff --git a/backend/thirdparty/zasilkovna/urls.py b/backend/thirdparty/zasilkovna/urls.py index e69de29..cd0592a 100644 --- a/backend/thirdparty/zasilkovna/urls.py +++ b/backend/thirdparty/zasilkovna/urls.py @@ -0,0 +1,15 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from .views import ZasilkovnaShipmentViewSet, ZasilkovnaPacketViewSet + + +router = DefaultRouter() +router.register(r"shipments", ZasilkovnaShipmentViewSet, basename="zasilkovna-shipment") +router.register(r"packets", ZasilkovnaPacketViewSet, basename="zasilkovna-packet") + +app_name = "zasilkovna" + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/backend/thirdparty/zasilkovna/views.py b/backend/thirdparty/zasilkovna/views.py index 04de252..022316c 100644 --- a/backend/thirdparty/zasilkovna/views.py +++ b/backend/thirdparty/zasilkovna/views.py @@ -1,8 +1,80 @@ -#views.py +from rest_framework import viewsets, mixins, status +from rest_framework.decorators import action +from rest_framework.response import Response -""" -TODO: OBJEDNAVANÍ SE VYVOLÁVA V CARRIER V COMMERCE.MODELS.PY - získaní labelu, - info o kurýrovi, vracení balíku, - vytvoření hromadné expedice -""" \ No newline at end of file +from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiResponse + +from .models import ZasilkovnaShipment, ZasilkovnaPacket +from .serializers import ( + ZasilkovnaShipmentSerializer, + ZasilkovnaPacketSerializer, + TrackingURLSerializer, +) + + +@extend_schema_view( + list=extend_schema( + tags=["Zásilkovna"], + summary="List shipments", + description="Returns a paginated list of Packeta (Zásilkovna) shipments.", + responses={200: ZasilkovnaShipmentSerializer}, + ), + retrieve=extend_schema( + tags=["Zásilkovna"], + summary="Retrieve a shipment", + description="Returns detail for a single shipment.", + responses={200: ZasilkovnaShipmentSerializer}, + ), +) +class ZasilkovnaShipmentViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ZasilkovnaShipment.objects.all().order_by("-created_at") + serializer_class = ZasilkovnaShipmentSerializer + + + +@extend_schema_view( + retrieve=extend_schema( + tags=["Zásilkovna"], + summary="Retrieve a packet", + description="Returns detail for a single packet.", + responses={200: ZasilkovnaPacketSerializer}, + ) +) +class ZasilkovnaPacketViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): + queryset = ZasilkovnaPacket.objects.all() + serializer_class = ZasilkovnaPacketSerializer + + @extend_schema( + tags=["Zásilkovna"], + summary="Get public tracking URL", + description=( + "Returns the public Zásilkovna tracking URL derived from the packet's barcode." + ), + responses={200: OpenApiResponse(response=TrackingURLSerializer)}, + ) + @action(detail=True, methods=["get"], url_path="tracking-url") + def tracking_url(self, request, pk=None): + packet: ZasilkovnaPacket = self.get_object() + data = { + "barcode": packet.barcode, + "tracking_url": packet.get_tracking_url(), + } + return Response(data) + + + @extend_schema( + tags=["Zásilkovna"], + summary="Cancel packet", + description=( + "Cancels the packet through the Packeta API and updates its state to CANCELED. " + "No request body is required." + ), + request=None, + responses={200: OpenApiResponse(response=ZasilkovnaPacketSerializer)}, + ) + @action(detail=True, methods=["patch"], url_path="cancel") + def cancel(self, request, pk=None): + packet: ZasilkovnaPacket = self.get_object() + packet.cancel_packet() + serializer = self.get_serializer(packet) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/backend/vontor_cz/asgi.py b/backend/vontor_cz/asgi.py index 626be7f..96c15cb 100644 --- a/backend/vontor_cz/asgi.py +++ b/backend/vontor_cz/asgi.py @@ -13,7 +13,7 @@ from channels.routing import ProtocolTypeRouter, URLRouter from channels.auth import AuthMiddlewareStack #import myapp.routing # your app's routing -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'trznice.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'vontor_cz.settings') application = ProtocolTypeRouter({ "http": get_asgi_application(), diff --git a/backend/vontor_cz/celery.py b/backend/vontor_cz/celery.py index 96a43e1..5d7dd4a 100644 --- a/backend/vontor_cz/celery.py +++ b/backend/vontor_cz/celery.py @@ -1,8 +1,8 @@ import os from celery import Celery -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "vontor_cz.settings") -app = Celery("backend") +app = Celery("vontor_cz") app.config_from_object("django.conf:settings", namespace="CELERY") app.autodiscover_tasks() diff --git a/backend/vontor_cz/settings.py b/backend/vontor_cz/settings.py index 1c06467..3eb8ad5 100644 --- a/backend/vontor_cz/settings.py +++ b/backend/vontor_cz/settings.py @@ -51,12 +51,13 @@ DATETIME_INPUT_FORMATS = [ "%Y-%m-%dT%H:%M:%S", # '2025-07-25T14:30:59' ] -LANGUAGE_CODE = 'cs' +# -------------------- LOKALIZACE ------------------------- -TIME_ZONE = 'Europe/Prague' +LANGUAGE_CODE = os.getenv("LANGUAGE_CODE", "cs") +TIME_ZONE = os.getenv("TIME_ZONE", "Europe/Prague") USE_I18N = True - +USE_L10N = True USE_TZ = True @@ -313,6 +314,10 @@ REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], + # Enable default pagination so custom list actions (e.g., /orders/detail) paginate + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 20, + 'DEFAULT_THROTTLE_RATES': { 'anon': '100/hour', # unauthenticated 'user': '2000/hour', # authenticated @@ -326,7 +331,8 @@ REST_FRAMEWORK = { MY_CREATED_APPS = [ 'account', 'commerce', - + 'configuration', + 'social.chat', 'thirdparty.downloader', diff --git a/backend/vontor_cz/urls.py b/backend/vontor_cz/urls.py index ecce746..18e2e96 100644 --- a/backend/vontor_cz/urls.py +++ b/backend/vontor_cz/urls.py @@ -39,4 +39,5 @@ urlpatterns = [ path('api/trading212/', include('thirdparty.trading212.urls')), path('api/downloader/', include('thirdparty.downloader.urls')), path("api/payments/gopay/", include("thirdparty.gopay.urls", namespace="gopay")), + path('api/zasilkovna/', include('thirdparty.zasilkovna.urls')), ] diff --git a/backend/commerce/migrations/__init__.py b/backups/backup-20251117-224608.sql similarity index 100% rename from backend/commerce/migrations/__init__.py rename to backups/backup-20251117-224608.sql diff --git a/docker-compose.yml b/docker-compose.yml index ab1fe8e..bda87e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: - ./backend:/app depends_on: - redis - command: daphne -b 0.0.0.0 -p 8000 backend.asgi:application + command: daphne -b 0.0.0.0 -p 8000 vontor_cz.asgi:application frontend: env_file: