init
This commit is contained in:
0
backend/commerce/__init__.py
Normal file
0
backend/commerce/__init__.py
Normal file
30
backend/commerce/admin.py
Normal file
30
backend/commerce/admin.py
Normal 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
6
backend/commerce/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommerceConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'commerce'
|
||||
12
backend/commerce/filters.py
Normal file
12
backend/commerce/filters.py
Normal 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"]
|
||||
37
backend/commerce/migrations/0001_initial.py
Normal file
37
backend/commerce/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/commerce/migrations/__init__.py
Normal file
0
backend/commerce/migrations/__init__.py
Normal file
113
backend/commerce/models.py
Normal file
113
backend/commerce/models.py
Normal 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)
|
||||
178
backend/commerce/serializers.py
Normal file
178
backend/commerce/serializers.py
Normal 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)
|
||||
3
backend/commerce/tests.py
Normal file
3
backend/commerce/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
11
backend/commerce/urls.py
Normal file
11
backend/commerce/urls.py
Normal 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
74
backend/commerce/views.py
Normal 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)
|
||||
Reference in New Issue
Block a user