Major refactor of commerce and Stripe integration
Refactored commerce models to support refunds, invoices, and improved carrier/payment logic. Added new serializers and viewsets for products, categories, images, discount codes, and refunds. Introduced Stripe client integration and removed legacy Stripe admin/model code. Updated Dockerfile for PDF generation dependencies. Removed obsolete migration files and updated configuration app initialization. Added invoice template and tasks for order cleanup.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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__)
|
||||
|
||||
@@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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,19 +273,32 @@ 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'):
|
||||
@@ -380,3 +443,86 @@ class OrderItem(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
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()
|
||||
@@ -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"]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
41
backend/commerce/templates/invoice/Order.html
Normal file
41
backend/commerce/templates/invoice/Order.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Faktura {{ invoice.invoice_number }}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; font-size: 14px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
|
||||
th, td { border: 1px solid #000; padding: 8px; text-align: left; }
|
||||
th { background-color: #eee; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Faktura {{ invoice.invoice_number }}</h1>
|
||||
<p>Datum vystavení: {{ invoice.issue_date.strftime("%Y-%m-%d") }}</p>
|
||||
<p>Zákazník: {{ invoice.order.customer_name }}</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Produkt</th>
|
||||
<th>Množství</th>
|
||||
<th>Cena</th>
|
||||
<th>Celkem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in invoice.order.items.all %}
|
||||
<tr>
|
||||
<td>{{ item.product.name }}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
<td>{{ item.price }}</td>
|
||||
<td>{{ item.price * item.quantity }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p><strong>Celkem k úhradě: {{ invoice.total_amount }} Kč</strong></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
21
backend/thirdparty/stripe/admin.py
vendored
21
backend/thirdparty/stripe/admin.py
vendored
@@ -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",)
|
||||
|
||||
54
backend/thirdparty/stripe/client.py
vendored
Normal file
54
backend/thirdparty/stripe/client.py
vendored
Normal file
@@ -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)})
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
63
backend/thirdparty/stripe/models.py
vendored
63
backend/thirdparty/stripe/models.py
vendored
@@ -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"),
|
||||
]
|
||||
from .client import StripeClient
|
||||
|
||||
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")
|
||||
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}"
|
||||
|
||||
|
||||
|
||||
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()
|
||||
9
backend/thirdparty/stripe/stripe.md
vendored
Normal file
9
backend/thirdparty/stripe/stripe.md
vendored
Normal file
@@ -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!!!
|
||||
98
backend/thirdparty/stripe/views.py
vendored
98
backend/thirdparty/stripe/views.py
vendored
@@ -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)
|
||||
|
||||
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)
|
||||
except stripe.error as e:
|
||||
# stripe error
|
||||
logger.error(f"Stripe error: {e}")
|
||||
return HttpResponse(status=400)
|
||||
|
||||
|
||||
session = event['data']['object']
|
||||
|
||||
|
||||
# ZAPLACENO
|
||||
if event['type'] == 'checkout.session.completed':
|
||||
|
||||
@csrf_exempt
|
||||
def stripe_webhook(request):
|
||||
payload = request.body
|
||||
sig_header = request.META.get("HTTP_STRIPE_SIGNATURE")
|
||||
event = None
|
||||
stripe_transaction = StripeTransaction.objects.get(stripe_session_id=session.id)
|
||||
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
except stripe.error.SignatureVerificationError:
|
||||
return HttpResponse(status=400)
|
||||
if stripe_transaction:
|
||||
stripe_transaction.paid()
|
||||
|
||||
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"
|
||||
logger.info(f"Transaction {stripe_transaction.id} marked as paid.")
|
||||
|
||||
else:
|
||||
logger.warning(f"No transaction found for session ID: {session.id}")
|
||||
|
||||
# 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.")
|
||||
|
||||
BIN
backend/thirdparty/stripe/where to find webhooks settings.png
vendored
Normal file
BIN
backend/thirdparty/stripe/where to find webhooks settings.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
38
backend/thirdparty/zasilkovna/models.py
vendored
38
backend/thirdparty/zasilkovna/models.py
vendored
@@ -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']
|
||||
|
||||
38
backend/thirdparty/zasilkovna/serializers.py
vendored
38
backend/thirdparty/zasilkovna/serializers.py
vendored
@@ -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
|
||||
|
||||
15
backend/thirdparty/zasilkovna/urls.py
vendored
15
backend/thirdparty/zasilkovna/urls.py
vendored
@@ -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)),
|
||||
]
|
||||
|
||||
86
backend/thirdparty/zasilkovna/views.py
vendored
86
backend/thirdparty/zasilkovna/views.py
vendored
@@ -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
|
||||
"""
|
||||
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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,6 +331,7 @@ REST_FRAMEWORK = {
|
||||
MY_CREATED_APPS = [
|
||||
'account',
|
||||
'commerce',
|
||||
'configuration',
|
||||
|
||||
'social.chat',
|
||||
|
||||
|
||||
@@ -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')),
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user