Skip to content

Commit 1ef831d

Browse files
Merge pull request #13 from mohamad-liyaghi/customer-orders
Customer Orders
2 parents 64d27c4 + db5421a commit 1ef831d

File tree

13 files changed

+323
-5
lines changed

13 files changed

+323
-5
lines changed

apps/orders/exceptions.py

+4
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
class EmptyCartException(Exception):
22
pass
3+
4+
5+
class InsufficientBalanceException(Exception):
6+
pass
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 5.0.7 on 2024-08-08 09:31
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
("orders", "0003_order_is_removed_from_balance"),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name="orderitem",
15+
name="order",
16+
field=models.ForeignKey(
17+
on_delete=django.db.models.deletion.CASCADE,
18+
related_name="items",
19+
to="orders.order",
20+
),
21+
),
22+
]

apps/orders/models.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
from django.core.cache import cache
33
from decouple import config
44
from django.conf import settings
5-
from django.db.models import QuerySet, F
5+
from django.db.models import QuerySet
66
from uuid import uuid4
7-
from orders.exceptions import EmptyCartException
7+
from orders.exceptions import EmptyCartException, InsufficientBalanceException
88
from orders.enums import OrderStatus
99
from restaurants.models import Restaurant
1010
from products.models import Product
@@ -25,6 +25,8 @@ def __str__(self):
2525

2626
def save(self, *args, **kwargs):
2727
if self.status == OrderStatus.PROCESSING and not self.is_removed_from_balance:
28+
if self.user.balance < self.total_price:
29+
raise InsufficientBalanceException
2830
self.user.balance -= self.total_price
2931
self.user.save()
3032
return super().save(*args, **kwargs)
@@ -74,7 +76,7 @@ def create_order(cls, user: settings.AUTH_USER_MODEL, cart: dict) -> QuerySet:
7476

7577

7678
class OrderItem(models.Model):
77-
order = models.ForeignKey(Order, on_delete=models.CASCADE)
79+
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items")
7880
product = models.ForeignKey(Product, on_delete=models.CASCADE)
7981
quantity = models.PositiveIntegerField()
8082
price = models.DecimalField(max_digits=10, decimal_places=2)

apps/orders/serializers.py

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from rest_framework import serializers
2+
from carts.services import CartService
3+
from orders.exceptions import EmptyCartException
4+
from products.serializers import ProductSerializer
5+
from users.serializers import UserProfileSerializer
6+
from restaurants.serializers import RestaurantSerializer
7+
from .models import Order, OrderItem
8+
9+
10+
class OrderItemSerializer(serializers.ModelSerializer):
11+
product = ProductSerializer()
12+
13+
class Meta:
14+
model = OrderItem
15+
fields = ("product", "quantity", "price")
16+
17+
18+
class OrderSerializer(serializers.ModelSerializer):
19+
items = OrderItemSerializer(many=True, read_only=True)
20+
user = UserProfileSerializer(read_only=True)
21+
restaurant = RestaurantSerializer(read_only=True)
22+
23+
class Meta:
24+
model = Order
25+
fields = ("user", "restaurant", "get_status_display", "total_price", "items")
26+
read_only_fields = (
27+
"user",
28+
"restaurant",
29+
"get_status_display",
30+
"total_price",
31+
"items",
32+
)
33+
34+
def create(self, validated_data):
35+
user = self.context["user"]
36+
cart = CartService.get_items(user)
37+
try:
38+
return Order.create_order(user, cart)
39+
except EmptyCartException:
40+
raise serializers.ValidationError("Cart is empty")

apps/orders/tests/test_views/__init__.py

Whitespace-only changes.

apps/orders/tests/test_views/test_customer/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import pytest
2+
from django.urls import reverse
3+
from rest_framework import status
4+
from orders.enums import OrderStatus
5+
6+
7+
@pytest.mark.django_db
8+
class TestOrderCancelView:
9+
@pytest.fixture(autouse=True)
10+
def setup_method(self, user, api_client, pending_order):
11+
self.url = reverse("orders:customer-cancel", kwargs={"uuid": pending_order.uuid})
12+
self.client = api_client
13+
self.user = user
14+
self.order = pending_order
15+
16+
def test_cancel_unauthorized_fails(self):
17+
response = self.client.delete(self.url)
18+
assert response.status_code == status.HTTP_403_FORBIDDEN
19+
20+
def test_cancel_by_another_user_fails(self, another_user):
21+
self.client.force_authenticate(another_user)
22+
response = self.client.delete(self.url)
23+
assert response.status_code == status.HTTP_404_NOT_FOUND
24+
25+
def test_cancel_for_paid_order_fails(self, shipped_order):
26+
self.client.force_authenticate(self.user)
27+
response = self.client.delete(
28+
reverse("orders:customer-cancel", kwargs={"uuid": shipped_order.uuid}),
29+
)
30+
assert response.status_code == status.HTTP_404_NOT_FOUND
31+
32+
def test_update_with_valid_data_succeeds(self):
33+
self.client.force_authenticate(self.user)
34+
response = self.client.delete(self.url)
35+
assert response.status_code == status.HTTP_204_NO_CONTENT
36+
self.order.refresh_from_db()
37+
assert self.order.status == OrderStatus.CANCELLED
38+
self.order.status = OrderStatus.PENDING_PAYMENT
39+
self.order.save()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import pytest
2+
from django.urls import reverse
3+
from rest_framework import status
4+
from carts.services import CartService
5+
6+
7+
@pytest.mark.django_db
8+
class TestOrderCreateView:
9+
@pytest.fixture(autouse=True)
10+
def setup(self, api_client, user):
11+
self.url = reverse("orders:customer-list-create")
12+
self.client = api_client
13+
self.user = user
14+
self.data = {}
15+
16+
def test_create_unauthorized_fails(self):
17+
response = self.client.post(self.url, self.data)
18+
assert response.status_code == status.HTTP_403_FORBIDDEN
19+
20+
def test_create_with_empty_cart_fails(self):
21+
self.client.force_authenticate(self.user)
22+
response = self.client.post(self.url, self.data)
23+
assert response.status_code == status.HTTP_400_BAD_REQUEST
24+
assert response.json() == ["Cart is empty"]
25+
26+
def test_create_with_items_succeeds(self, available_drink_product):
27+
CartService.add_item(self.user, available_drink_product, 2)
28+
self.client.force_authenticate(self.user)
29+
response = self.client.post(self.url, self.data)
30+
assert response.status_code == status.HTTP_201_CREATED
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import pytest
2+
from django.urls import reverse
3+
from rest_framework import status
4+
5+
6+
@pytest.mark.django_db
7+
class TestOrderListView:
8+
@pytest.fixture(autouse=True)
9+
def setup(self, api_client, user):
10+
self.url = reverse("orders:customer-list-create")
11+
self.client = api_client
12+
self.user = user
13+
14+
def test_get_unauthorized_fails(self):
15+
response = self.client.get(self.url)
16+
assert response.status_code == status.HTTP_403_FORBIDDEN
17+
18+
def test_get_by_user_succeeds(self):
19+
self.client.force_authenticate(self.user)
20+
response = self.client.get(self.url)
21+
assert response.status_code == status.HTTP_200_OK
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import pytest
2+
from django.urls import reverse
3+
from rest_framework import status
4+
from orders.enums import OrderStatus
5+
6+
7+
@pytest.mark.django_db
8+
class TestOrderPayView:
9+
@pytest.fixture(autouse=True)
10+
def setup_method(self, user, api_client, pending_order):
11+
self.url = reverse("orders:customer-pay", kwargs={"uuid": pending_order.uuid})
12+
self.client = api_client
13+
self.user = user
14+
self.order = pending_order
15+
self.data = {}
16+
17+
def test_pay_unauthorized_fails(self):
18+
response = self.client.post(self.url, self.data)
19+
assert response.status_code == status.HTTP_403_FORBIDDEN
20+
21+
def test_pay_by_another_user_fails(self, another_user):
22+
self.client.force_authenticate(another_user)
23+
response = self.client.post(self.url, self.data)
24+
assert response.status_code == status.HTTP_404_NOT_FOUND
25+
26+
def test_pay_for_paid_order_fails(self, shipped_order):
27+
self.client.force_authenticate(self.user)
28+
response = self.client.post(
29+
reverse("orders:customer-pay", kwargs={"uuid": shipped_order.uuid}),
30+
self.data,
31+
)
32+
assert response.status_code == status.HTTP_404_NOT_FOUND
33+
34+
def test_update_with_valid_data_succeeds(self):
35+
self.client.force_authenticate(self.user)
36+
response = self.client.post(self.url, self.data)
37+
assert response.status_code == status.HTTP_200_OK
38+
self.order.refresh_from_db()
39+
assert self.order.status == OrderStatus.PROCESSING
40+
self.order.status = OrderStatus.PENDING_PAYMENT
41+
self.order.save()

apps/orders/urls.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
from django.urls import path, include
2+
from orders.views.customer import OrderListCreateView, OrderPayView, OrderCancelView
23

34
app_name = "orders"
45

56
VENDOR_URLS = []
6-
CUSTOMER_URLS = []
7+
CUSTOMER_URLS = [
8+
path("", OrderListCreateView.as_view(), name="customer-list-create"),
9+
path("<uuid:uuid>/pay/", OrderPayView.as_view(), name="customer-pay"),
10+
path("<uuid:uuid>/cancel/", OrderCancelView.as_view(), name="customer-cancel"),
11+
]
712

813
urlpatterns = [
914
path("vendor/", include(VENDOR_URLS)),

apps/orders/views/customer.py

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
from django.shortcuts import get_object_or_404
2+
from rest_framework.generics import ListCreateAPIView
3+
from rest_framework.permissions import IsAuthenticated
4+
from rest_framework.views import APIView
5+
from rest_framework.response import Response
6+
from rest_framework import status
7+
from drf_spectacular.utils import extend_schema_view, OpenApiResponse, extend_schema
8+
from orders.enums import OrderStatus
9+
from orders.exceptions import InsufficientBalanceException
10+
from orders.models import Order
11+
from orders.serializers import OrderSerializer
12+
13+
14+
@extend_schema_view(
15+
get=extend_schema(
16+
summary="List of a users orders",
17+
description="List all orders for",
18+
responses={
19+
200: OrderSerializer(many=True),
20+
403: OpenApiResponse(description="Unauthorized"),
21+
},
22+
tags=["Customer Orders"],
23+
),
24+
post=extend_schema(
25+
summary="Create an order",
26+
description="Create an order for the user",
27+
responses={
28+
201: OrderSerializer(),
29+
400: OpenApiResponse(description="Bad Request"),
30+
403: OpenApiResponse(description="Unauthorized"),
31+
},
32+
tags=["Customer Orders"],
33+
),
34+
)
35+
class OrderListCreateView(ListCreateAPIView):
36+
permission_classes = (IsAuthenticated,)
37+
serializer_class = OrderSerializer
38+
39+
def get_queryset(self):
40+
return (
41+
Order.objects.select_related("restaurant", "user")
42+
.prefetch_related("items", "items__product")
43+
.filter(user=self.request.user)
44+
.order_by("-created_at")
45+
)
46+
47+
def get_serializer_context(self):
48+
context = super().get_serializer_context()
49+
context["user"] = self.request.user
50+
return context
51+
52+
53+
@extend_schema_view(
54+
post=extend_schema(
55+
summary="Pay for an order",
56+
description="Pay for an order",
57+
responses={
58+
200: OrderSerializer(),
59+
400: OpenApiResponse(description="Bad Request"),
60+
403: OpenApiResponse(description="Unauthorized"),
61+
},
62+
tags=["Customer Orders"],
63+
),
64+
)
65+
class OrderPayView(APIView):
66+
permission_classes = (IsAuthenticated,)
67+
68+
def get_object(self):
69+
return get_object_or_404(
70+
Order,
71+
uuid=self.kwargs["uuid"],
72+
user=self.request.user,
73+
status=OrderStatus.PENDING_PAYMENT,
74+
)
75+
76+
def post(self, request, *args, **kwargs):
77+
order = self.get_object()
78+
try:
79+
order.status = OrderStatus.PROCESSING
80+
order.save()
81+
return Response({"message": "Order is being processed"}, status=status.HTTP_200_OK)
82+
83+
except InsufficientBalanceException:
84+
return Response({"error": "Insufficient balance"}, status=status.HTTP_400_BAD_REQUEST)
85+
86+
87+
@extend_schema_view(
88+
delete=extend_schema(
89+
summary="Cancel an order",
90+
description="Cancel an order",
91+
responses={
92+
204: OpenApiResponse(description="Cancelled"),
93+
400: OpenApiResponse(description="Bad Request"),
94+
403: OpenApiResponse(description="Unauthorized"),
95+
},
96+
tags=["Customer Orders"],
97+
),
98+
)
99+
class OrderCancelView(APIView):
100+
permission_classes = (IsAuthenticated,)
101+
102+
def get_object(self):
103+
return get_object_or_404(
104+
Order,
105+
uuid=self.kwargs["uuid"],
106+
user=self.request.user,
107+
status=OrderStatus.PENDING_PAYMENT,
108+
)
109+
110+
def delete(self, request, *args, **kwargs):
111+
order = self.get_object()
112+
order.status = OrderStatus.CANCELLED
113+
order.save()
114+
return Response({"message": "Order has been cancelled"}, status=status.HTTP_204_NO_CONTENT)

apps/transactions/views/withdrawals.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from rest_framework.permissions import IsAuthenticated
33
from drf_spectacular.utils import extend_schema_view, OpenApiResponse, extend_schema
44
from transactions.models import Transaction
5-
from transactions.enums import TransactionStatus, TransactionType
5+
from transactions.enums import TransactionType
66
from transactions.permissions import WithdrawalLimitPermission
77
from transactions.serializers import WithdrawalSerializer
88

0 commit comments

Comments
 (0)