This commit is contained in:
2025-10-02 00:54:34 +02:00
commit 84b34c9615
200 changed files with 42048 additions and 0 deletions

View File

30
backend/commerce/admin.py Normal file
View File

@@ -0,0 +1,30 @@
from django.contrib import admin
from trznice.admin import custom_admin_site
from .models import Order
class OrderAdmin(admin.ModelAdmin):
list_display = ("id", "status", "user", "price_to_pay", "reservation", "is_deleted")
list_filter = ("user", "status", "reservation", "is_deleted")
search_fields = ("user__email", "reservation__event")
ordering = ("id",)
base_fields = ["status", "reservation", "created_at", "user", "price_to_pay", "payed_at", "note"]
readonly_fields = ("id", "created_at", "payed_at")
def get_fields(self, request, obj=None):
fields = self.base_fields.copy()
if request.user.role == "admin":
fields += ['is_deleted', 'deleted_at']
return fields
def get_queryset(self, request):
# Use the all_objects manager to show even soft-deleted entries
if request.user.role == "admin":
qs = self.model.all_objects.all()
else:
qs = self.model.objects.all()
return qs
custom_admin_site.register(Order, OrderAdmin)

6
backend/commerce/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CommerceConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'commerce'

View File

@@ -0,0 +1,12 @@
import django_filters
from .models import Order
class OrderFilter(django_filters.FilterSet):
reservation = django_filters.NumberFilter(field_name="reservation__id")
user = django_filters.NumberFilter(field_name="user__id")
status = django_filters.ChoiceFilter(choices=Order.STATUS_CHOICES)
class Meta:
model = Order
fields = ["reservation", "user", "status"]

View File

@@ -0,0 +1,37 @@
# Generated by Django 5.2.4 on 2025-08-07 15:13
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('booking', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Order',
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)),
('created_at', models.DateTimeField(auto_now_add=True)),
('status', models.CharField(choices=[('payed', 'Zaplaceno'), ('pending', 'Čeká na zaplacení'), ('cancelled', 'Stornovano')], default='pending', max_length=20)),
('note', models.TextField(blank=True, null=True)),
('price_to_pay', models.DecimalField(blank=True, decimal_places=2, default=0, help_text='Cena k zaplacení. Počítá se automaticky z Rezervace.', max_digits=8, validators=[django.core.validators.MinValueValidator(0)])),
('payed_at', models.DateTimeField(blank=True, null=True)),
('reservation', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='order', to='booking.reservation')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

View File

113
backend/commerce/models.py Normal file
View File

@@ -0,0 +1,113 @@
import uuid
from django.db import models
from django.conf import settings
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
from trznice.models import SoftDeleteModel
from booking.models import Reservation
from account.models import CustomUser
class Order(SoftDeleteModel):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="orders", null=False, blank=False)
reservation = models.OneToOneField(Reservation, on_delete=models.CASCADE, related_name="order", null=False, blank=False)
created_at = models.DateTimeField(auto_now_add=True)
STATUS_CHOICES = [
("payed", "Zaplaceno"),
("pending", "Čeká na zaplacení"),
("cancelled", "Stornovano"),
]
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
note = models.TextField(blank=True, null=True)
price_to_pay = models.DecimalField(blank=True,
default=0,
max_digits=8,
decimal_places=2,
validators=[MinValueValidator(0)],
help_text="Cena k zaplacení. Počítá se automaticky z Rezervace.",
)
payed_at = models.DateTimeField(null=True, blank=True)
def __str__(self):
return f"Objednávka {self.id} od uživatele {self.user}"
def clean(self):
if not self.user_id:
raise ValidationError("Zadejte ID Uživatele.")
if not self.reservation_id:
raise ValidationError("Zadejte ID Rezervace.")
# Safely get product and event objects for error messages and validation
try:
reservation_obj = Reservation.objects.get(pk=self.reservation_id)
except Reservation.DoesNotExist:
raise ValidationError("Neplatné ID Rezervace.")
"""try:
user_obj = CustomUser.objects.get(pk=self.user_id)
if reservation_obj.user != user_obj:
raise ValidationError("Tato rezervace naleží jinému Uživatelovi.")
except CustomUser.DoesNotExist:
raise ValidationError("Neplatné ID Uživatele.")"""
# Overlapping sales window check
overlapping = Order.objects.exclude(id=self.id).filter(
reservation_id=self.reservation_id,
)
if overlapping.exists():
raise ValidationError("Tato Rezervace už je zaplacena.")
errors = {}
# If order is marked as payed, it must have a payed_at timestamp
if self.status == "payed" and not self.payed_at:
errors["payed_at"] = "Musíte zadat datum a čas zaplacení, pokud je objednávka zaplacena."
# If order is not payed, payed_at must be null
if self.status != "payed" and self.payed_at:
errors["payed_at"] = "Datum zaplacení může být uvedeno pouze u zaplacených objednávek."
if self.reservation.final_price:
self.price_to_pay = self.reservation.final_price
else:
errors["price_to_pay"] = "Chyba v Rezervaci, neplatná cena."
# Price must be greater than zero
if self.price_to_pay:
if self.price_to_pay < 0:
errors["price_to_pay"] = "Cena musí být větší než 0."
# if self.price_to_pay == 0 and self.reservation:
else:
errors["price_to_pay"] = "Nemůže být prázdné."
if errors:
raise ValidationError(errors)
def save(self, *args, **kwargs):
self.full_clean()
if self.status == "cancelled":
self.reservation.status = "cancelled"
else:
self.reservation.status = "reserved"
self.reservation.save()
# if self.reservation:
# self.price_to_pay = self.reservation.final_price
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
self.reservation.status = "cancelled"
self.reservation.save()
return super().delete(*args, **kwargs)

View File

@@ -0,0 +1,178 @@
from rest_framework import serializers
from django.utils import timezone
from trznice.utils import RoundedDateTimeField
from account.serializers import CustomUserSerializer
from booking.serializers import ReservationSerializer
from account.models import CustomUser
from booking.models import Event, MarketSlot, Reservation
from .models import Order
from decimal import Decimal
import logging
logger = logging.getLogger(__name__)
#počítaní ceny!!! (taky validní)
class SlotPriceInputSerializer(serializers.Serializer):
slot_id = serializers.PrimaryKeyRelatedField(queryset=MarketSlot.objects.all())
used_extension = serializers.FloatField(min_value=0)
#počítaní ceny!!! (počítá správně!!)
class PriceCalculationSerializer(serializers.Serializer):
slot = serializers.PrimaryKeyRelatedField(queryset=MarketSlot.objects.all())
reserved_from = RoundedDateTimeField()
reserved_to = RoundedDateTimeField()
used_extension = serializers.FloatField(min_value=0, required=False)
final_price = serializers.DecimalField(max_digits=8, decimal_places=2, read_only=True)
def validate(self, data):
from django.utils.timezone import make_aware, is_naive
reserved_from = data["reserved_from"]
reserved_to = data["reserved_to"]
if is_naive(reserved_from):
reserved_from = make_aware(reserved_from)
if is_naive(reserved_to):
reserved_to = make_aware(reserved_to)
duration = reserved_to - reserved_from
days = duration.days + 1 # zahrnujeme první den
data["reserved_from"] = reserved_from
data["reserved_to"] = reserved_to
data["duration"] = days
market_slot = data["slot"]
event = market_slot.event if hasattr(market_slot, "event") else None
if not event or not event.square:
raise serializers.ValidationError("Slot musí být přiřazen k akci, která má náměstí.")
# Get width and height from market_slot
area = market_slot.width * market_slot.height
price_per_m2 = market_slot.price_per_m2 if market_slot.price_per_m2 and market_slot.price_per_m2 > 0 else event.price_per_m2
if not price_per_m2 or price_per_m2 < 0:
raise serializers.ValidationError("Cena za m² není dostupná nebo je záporná.")
# Calculate final price using slot area and reserved days
final_price = Decimal(area) * Decimal(price_per_m2) * Decimal(days)
final_price = final_price.quantize(Decimal("0.01"))
data["final_price"] = final_price
return data
class OrderSerializer(serializers.ModelSerializer):
created_at = RoundedDateTimeField(read_only=True, required=False)
payed_at = RoundedDateTimeField(read_only=True, required=False)
user = CustomUserSerializer(read_only=True)
reservation = ReservationSerializer(read_only=True)
user_id = serializers.PrimaryKeyRelatedField(
queryset=CustomUser.objects.all(), source="user", write_only=True, required=False, allow_null=True
)
reservation_id = serializers.PrimaryKeyRelatedField(
queryset=Reservation.objects.all(), source="reservation", write_only=True
)
price_to_pay = serializers.DecimalField(
max_digits=10, decimal_places=2, required=False, allow_null=True
)
class Meta:
model = Order
fields = [
"id",
"user", # nested read-only
"user_id", # required in POST/PUT
"reservation", # nested read-only
"reservation_id", # required in POST/PUT
"created_at",
"status",
"note",
"price_to_pay",
"payed_at",
]
read_only_fields = ["id", "created_at", "price_to_pay", "payed_at"]
extra_kwargs = {
"user_id": {"help_text": "ID uživatele, který objednávku vytvořil", "required": False},
"reservation_id": {"help_text": "ID rezervace, ke které se objednávka vztahuje", "required": True},
"status": {"help_text": "Stav objednávky (např. new / paid / cancelled)", "required": False},
"note": {"help_text": "Poznámka k objednávce (volitelné)", "required": False},
"price_to_pay": {
"help_text": "Celková cena, kterou má uživatel zaplatit. Pokud není zadána, převezme se z rezervace.",
"required": False,
"allow_null": True,
},
"payed_at": {"help_text": "Datum a čas, kdy byla objednávka zaplacena", "required": False},
}
def validate(self, data):
if "status" in data and data["status"] not in dict(Order.STATUS_CHOICES):
raise serializers.ValidationError({"status": "Neplatný stav objednávky."})
# status = data.get("status", getattr(self.instance, "status", "pending"))
# payed_at = data.get("payed_at", getattr(self.instance, "payed_at", None))
reservation = data.get("reservation", getattr(self.instance, "reservation", None))
price = data.get("price_to_pay", getattr(self.instance, "price_to_pay", 0))
errors = {}
# if status == "payed" and not payed_at:
# errors["payed_at"] = "Musíte zadat datum a čas zaplacení, pokud je objednávka zaplacena."
# if status != "payed" and payed_at:
# errors["payed_at"] = "Datum zaplacení může být uvedeno pouze u zaplacených objednávek."
if price is not None and price < 0:
errors["price_to_pay"] = "Cena musí být větší nebo rovna 0."
if reservation:
if self.instance is None and hasattr(reservation, "order"):
errors["reservation"] = "Tato rezervace již má přiřazenou objednávku."
user = data.get("user")
request_user = self.context["request"].user if "request" in self.context else None
# If user is not specified, use the logged-in user
if user is None and request_user is not None:
user = request_user
data["user"] = user
# If user is specified and differs from logged-in user, check permissions
if user is not None and request_user is not None and user != request_user:
if request_user.role not in ["admin", "cityClerk", "squareManager"]:
errors["user"] = "Pouze administrátor, úředník nebo správce tržiště může vytvářet rezervace pro jiné uživatele."
if errors:
raise serializers.ValidationError(errors)
return data
def create(self, validated_data):
if validated_data.get("reservation"):
validated_data["price_to_pay"] = validated_data["reservation"].final_price
validated_data["user"] = validated_data.pop("user_id", validated_data.get("user"))
validated_data["reservation"] = validated_data.pop("reservation_id", validated_data.get("reservation"))
return super().create(validated_data)
def update(self, instance, validated_data):
old_status = instance.status
new_status = validated_data.get("status", old_status)
logger.debug(f"\n\nUpdating order {instance.id} from status {old_status} to {new_status}\n\n")
if old_status != "payed" and new_status == "payed":
validated_data["payed_at"] = timezone.now()
return super().update(instance, validated_data)

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

11
backend/commerce/urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import OrderViewSet, CalculateReservationPriceView
router = DefaultRouter()
router.register(r'orders', OrderViewSet, basename='order')
urlpatterns = [
path('', include(router.urls)),
path("calculate_price/", CalculateReservationPriceView.as_view(), name="calculate_price"),
]

74
backend/commerce/views.py Normal file
View File

@@ -0,0 +1,74 @@
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import viewsets, filters, status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.decorators import api_view
from decimal import Decimal
from drf_spectacular.utils import extend_schema
from account.permissions import RoleAllowed
from rest_framework.permissions import IsAuthenticated
from .serializers import OrderSerializer, PriceCalculationSerializer
from .filters import OrderFilter
from .models import Order
@extend_schema(
tags=["Order"],
description=(
"Správa objednávek vytvoření, úprava a výpis. Filtrování podle rezervace, uživatele atd.\n\n"
"🔍 **Fulltextové vyhledávání (`?search=`)** prohledává:\n"
"- poznámku (`note`)\n"
"- e-mail uživatele (`user.email`)\n"
"- jméno a příjmení uživatele (`user.first_name`, `user.last_name`)\n"
"- poznámku rezervace (`reservation.note`)\n\n"
"**Příklady:** `?search=jan.novak@example.com`, `?search=poznámka`"
)
)
class OrderViewSet(viewsets.ModelViewSet):
queryset = Order.objects.all().select_related("user", "reservation").order_by("-created_at")
serializer_class = OrderSerializer
filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter]
filterset_class = OrderFilter
ordering_fields = ["created_at", "price_to_pay", "payed_at"]
search_fields = [
"note",
"user__email",
"user__first_name",
"user__last_name",
"reservation__note",
]
permission_classes = [RoleAllowed("admin", "cityClerk", "seller")]
# permission_classes = [IsAuthenticated]
def get_queryset(self):
queryset = Order.objects.select_related("user", "reservation").order_by("-created_at")
user = self.request.user
if hasattr(user, "role") and user.role == "seller":
return queryset.filter(user=user)
return queryset
class CalculateReservationPriceView(APIView):
@extend_schema(
request=PriceCalculationSerializer,
responses={200: {"type": "object", "properties": {"final_price": {"type": "number"}}}},
tags=["Order"],
summary="Calculate reservation price",
description="Spočítá celkovou cenu rezervace pro zvolený slot, použitá rozšíření a trvání rezervace"
)
def post(self, request):
serializer = PriceCalculationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
# PriceCalculationSerializer now returns 'final_price' in validated_data
return Response({"final_price": data["final_price"]}, status=status.HTTP_200_OK)