diff --git a/apps/orders/__init__.py b/apps/orders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/orders/admin.py b/apps/orders/admin.py new file mode 100644 index 0000000..23c5ce5 --- /dev/null +++ b/apps/orders/admin.py @@ -0,0 +1,40 @@ +from django.contrib import admin +from .models import Order, OrderItem + + +@admin.register(Order) +class OrderAdmin(admin.ModelAdmin): + list_display = ( + "id", + "user", + "restaurant", + "status", + "created_at", + "updated_at", + "total_price", + ) + list_filter = ("status", "restaurant") + search_fields = ("user__email", "restaurant__name") + date_hierarchy = "created_at" + + def get_queryset(self, request): + return super().get_queryset(request).select_related("user", "restaurant") + + +@admin.register(OrderItem) +class OrderItemAdmin(admin.ModelAdmin): + list_display = ( + "id", + "order", + "product", + "quantity", + "price", + "created_at", + "updated_at", + ) + list_filter = ("order", "product") + search_fields = ("order__user__email", "product__name") + date_hierarchy = "created_at" + + def get_queryset(self, request): + return super().get_queryset(request).select_related("order", "product") diff --git a/apps/orders/apps.py b/apps/orders/apps.py new file mode 100644 index 0000000..2e5018e --- /dev/null +++ b/apps/orders/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OrdersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "orders" diff --git a/apps/orders/enums.py b/apps/orders/enums.py new file mode 100644 index 0000000..668e4b4 --- /dev/null +++ b/apps/orders/enums.py @@ -0,0 +1,9 @@ +from django.db import models + + +class OrderStatus(models.TextChoices): + PENDING_PAYMENT = "pp", "Pending Payment" + PROCESSING = "pr", "Processing" + SHIPPED = "sh", "Shipped" + DELIVERED = "de", "Delivered" + CANCELLED = "ca", "Cancelled" diff --git a/apps/orders/exceptions.py b/apps/orders/exceptions.py new file mode 100644 index 0000000..50eeb64 --- /dev/null +++ b/apps/orders/exceptions.py @@ -0,0 +1,2 @@ +class EmptyCartException(Exception): + pass diff --git a/apps/orders/migrations/0001_initial.py b/apps/orders/migrations/0001_initial.py new file mode 100644 index 0000000..9c13cc0 --- /dev/null +++ b/apps/orders/migrations/0001_initial.py @@ -0,0 +1,97 @@ +# Generated by Django 5.0.7 on 2024-08-08 08:39 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("products", "0003_product_is_deleted"), + ("restaurants", "0003_restaurant_is_soft_deleted"), + 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", + ), + ), + ( + "uuid", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ( + "status", + models.CharField( + choices=[ + ("pp", "Pending Payment"), + ("pr", "Processing"), + ("sh", "Shipped"), + ("de", "Delivered"), + ("ca", "Cancelled"), + ], + default="pp", + max_length=2, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("total", models.DecimalField(decimal_places=2, max_digits=10)), + ( + "restaurant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="restaurants.restaurant", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="OrderItem", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("quantity", models.PositiveIntegerField()), + ("price", models.DecimalField(decimal_places=2, max_digits=10)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "order", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="orders.order"), + ), + ( + "product", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="products.product", + ), + ), + ], + ), + ] diff --git a/apps/orders/migrations/0002_rename_total_order_total_price.py b/apps/orders/migrations/0002_rename_total_order_total_price.py new file mode 100644 index 0000000..979a943 --- /dev/null +++ b/apps/orders/migrations/0002_rename_total_order_total_price.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.7 on 2024-08-08 08:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("orders", "0001_initial"), + ] + + operations = [ + migrations.RenameField( + model_name="order", + old_name="total", + new_name="total_price", + ), + ] diff --git a/apps/orders/migrations/0003_order_is_removed_from_balance.py b/apps/orders/migrations/0003_order_is_removed_from_balance.py new file mode 100644 index 0000000..6689a58 --- /dev/null +++ b/apps/orders/migrations/0003_order_is_removed_from_balance.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.7 on 2024-08-08 09:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("orders", "0002_rename_total_order_total_price"), + ] + + operations = [ + migrations.AddField( + model_name="order", + name="is_removed_from_balance", + field=models.BooleanField(default=False), + ), + ] diff --git a/apps/orders/migrations/__init__.py b/apps/orders/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/orders/models.py b/apps/orders/models.py new file mode 100644 index 0000000..e041c88 --- /dev/null +++ b/apps/orders/models.py @@ -0,0 +1,85 @@ +from django.db import models, transaction +from django.core.cache import cache +from decouple import config +from django.conf import settings +from django.db.models import QuerySet, F +from uuid import uuid4 +from orders.exceptions import EmptyCartException +from orders.enums import OrderStatus +from restaurants.models import Restaurant +from products.models import Product + + +class Order(models.Model): + uuid = models.UUIDField(default=uuid4, editable=False, unique=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + restaurant = models.ForeignKey(Restaurant, on_delete=models.CASCADE) + status = models.CharField(max_length=2, choices=OrderStatus.choices, default=OrderStatus.PENDING_PAYMENT) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + total_price = models.DecimalField(max_digits=10, decimal_places=2) + is_removed_from_balance = models.BooleanField(default=False) + + def __str__(self): + return f"{self.id} - {self.user.email} - {self.restaurant.name} - {self.status}" + + def save(self, *args, **kwargs): + if self.status == OrderStatus.PROCESSING and not self.is_removed_from_balance: + self.user.balance -= self.total_price + self.user.save() + return super().save(*args, **kwargs) + + @classmethod + def create_order(cls, user: settings.AUTH_USER_MODEL, cart: dict) -> QuerySet: + if not cart: + raise EmptyCartException + + restaurant_ids = set(item["restaurant_id"] for item in cart.values()) + orders = { + restaurant_id: cls(user=user, restaurant_id=restaurant_id, total_price=0) + for restaurant_id in restaurant_ids + } + + # Bulk create Order instances + Order.objects.bulk_create(orders.values()) + + # Prepare for bulk creation of OrderItem instances + order_items = [] + product_ids = [item["product_id"] for item in cart.values()] + products = {product.id: product for product in Product.objects.filter(id__in=product_ids)} + + for item in cart.values(): + product = products[item["product_id"]] + order = orders[item["restaurant_id"]] + order_item = OrderItem( + order=order, + product=product, + quantity=item["quantity"], + price=product.price, + ) + order.total_price += order_item.price * order_item.quantity + order_items.append(order_item) + + with transaction.atomic(): + OrderItem.objects.bulk_create(order_items) + for order in orders.values(): + order.save() + + # Clear the cache for the user's cart + cache.delete_many( + [config("CART_CACHE_KEY").format(user_id=user.id, product_id=product_id) for product_id in product_ids] + ) + + return Order.objects.filter(id__in=[order.id for order in orders.values()]) + + +class OrderItem(models.Model): + order = models.ForeignKey(Order, on_delete=models.CASCADE) + product = models.ForeignKey(Product, on_delete=models.CASCADE) + quantity = models.PositiveIntegerField() + price = models.DecimalField(max_digits=10, decimal_places=2) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.id} - {self.order.id} - {self.product.name} - {self.quantity}" diff --git a/apps/orders/serializers.py b/apps/orders/serializers.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/orders/tests/__init__.py b/apps/orders/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/orders/tests/fixtures.py b/apps/orders/tests/fixtures.py new file mode 100644 index 0000000..160eb5d --- /dev/null +++ b/apps/orders/tests/fixtures.py @@ -0,0 +1,93 @@ +import pytest +from orders.models import Order, OrderItem +from orders.enums import OrderStatus + + +@pytest.fixture(scope="session") +def pending_order(django_db_setup, django_db_blocker, user, available_food_product) -> Order: + with django_db_blocker.unblock(): + order = Order.objects.create( + user=user, + status=OrderStatus.PENDING_PAYMENT, + restaurant=available_food_product.restaurant, + total_price=0, + ) + OrderItem.objects.create( + order=order, + product=available_food_product, + quantity=1, + price=available_food_product.price, + ) + yield order + + +@pytest.fixture(scope="session") +def processing_order(django_db_setup, django_db_blocker, user, available_food_product) -> Order: + with django_db_blocker.unblock(): + order = Order.objects.create( + user=user, + status=OrderStatus.PROCESSING, + restaurant=available_food_product.restaurant, + total_price=0, + ) + OrderItem.objects.create( + order=order, + product=available_food_product, + quantity=1, + price=available_food_product.price, + ) + yield order + + +@pytest.fixture(scope="session") +def shipped_order(django_db_setup, django_db_blocker, user, available_food_product) -> Order: + with django_db_blocker.unblock(): + order = Order.objects.create( + user=user, + status=OrderStatus.SHIPPED, + restaurant=available_food_product.restaurant, + total_price=0, + ) + OrderItem.objects.create( + order=order, + product=available_food_product, + quantity=1, + price=available_food_product.price, + ) + yield order + + +@pytest.fixture(scope="session") +def delivered_order(django_db_setup, django_db_blocker, user, available_food_product) -> Order: + with django_db_blocker.unblock(): + order = Order.objects.create( + user=user, + status=OrderStatus.DELIVERED, + restaurant=available_food_product.restaurant, + total_price=0, + ) + OrderItem.objects.create( + order=order, + product=available_food_product, + quantity=1, + price=available_food_product.price, + ) + yield order + + +@pytest.fixture(scope="session") +def cancelled_order(django_db_setup, django_db_blocker, user, available_food_product) -> Order: + with django_db_blocker.unblock(): + order = Order.objects.create( + user=user, + status=OrderStatus.CANCELLED, + restaurant=available_food_product.restaurant, + total_price=0, + ) + OrderItem.objects.create( + order=order, + product=available_food_product, + quantity=1, + price=available_food_product.price, + ) + yield order diff --git a/apps/orders/tests/test_models.py b/apps/orders/tests/test_models.py new file mode 100644 index 0000000..e0d9a71 --- /dev/null +++ b/apps/orders/tests/test_models.py @@ -0,0 +1,35 @@ +import pytest +from django.core.cache import cache +from decouple import config +from orders.models import Order, OrderItem +from orders.enums import OrderStatus +from orders.exceptions import EmptyCartException +from carts.services import CartService + + +@pytest.mark.django_db +class TestOrderModel: + def test_create_with_empty_cart_fails(self, user): + with pytest.raises(EmptyCartException): + Order.create_order(user, {}) + + def test_create_only_one_order(self, user, available_food_product): + CartService.add_item(user, available_food_product, 1) + Order.create_order(user, CartService.get_items(user)) + assert Order.objects.filter(user=user, restaurant=available_food_product.restaurant).exists() + assert not cache.get(config("CART_CACHE_KEY").format(user_id=user.id, product_id=available_food_product.id)) + + def test_create_for_two_restaurants(self, user, available_food_product, available_drink_product): + CartService.add_item(user, available_food_product, 1) + CartService.add_item(user, available_drink_product, 1) + orders = Order.create_order(user, CartService.get_items(user)) + assert orders.count() == 2 + assert Order.objects.filter(user=user, restaurant=available_food_product.restaurant).exists() + assert Order.objects.filter(user=user, restaurant=available_drink_product.restaurant).exists() + + def test_remove_total_from_balance(self, pending_order): + user_balance = pending_order.user.balance + pending_order.status = OrderStatus.PROCESSING + pending_order.save() + pending_order.user.refresh_from_db() + assert pending_order.user.balance == user_balance - pending_order.total_price diff --git a/apps/orders/urls.py b/apps/orders/urls.py new file mode 100644 index 0000000..7ceb628 --- /dev/null +++ b/apps/orders/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include + +app_name = "orders" + +VENDOR_URLS = [] +CUSTOMER_URLS = [] + +urlpatterns = [ + path("vendor/", include(VENDOR_URLS)), + path("customer/", include(CUSTOMER_URLS)), +] diff --git a/apps/orders/views/__init__.py b/apps/orders/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/products/tests/fixtures.py b/apps/products/tests/fixtures.py index a6a3a51..3d42416 100644 --- a/apps/products/tests/fixtures.py +++ b/apps/products/tests/fixtures.py @@ -32,10 +32,10 @@ def unavailable_food_product(django_db_setup, django_db_blocker, approved_restau @pytest.fixture(scope="session") -def available_drink_product(django_db_setup, django_db_blocker, approved_restaurant) -> Product: +def available_drink_product(django_db_setup, django_db_blocker, another_approved_restaurant) -> Product: with django_db_blocker.unblock(): yield Product.objects.create( - restaurant=approved_restaurant, + restaurant=another_approved_restaurant, name="Available Drink Product", description="Available Drink Product Description", price=5.00, diff --git a/apps/restaurants/tests/fixtures.py b/apps/restaurants/tests/fixtures.py index 4babcd0..0603a19 100644 --- a/apps/restaurants/tests/fixtures.py +++ b/apps/restaurants/tests/fixtures.py @@ -30,6 +30,19 @@ def approved_restaurant(django_db_setup, django_db_blocker, user) -> Restaurant: ) +@pytest.fixture(scope="session") +def another_approved_restaurant(django_db_setup, django_db_blocker, user) -> Restaurant: + with django_db_blocker.unblock(): + yield Restaurant.objects.create( + owner=user, + name="Another Approved", + description="Approved Description", + phone="987654321", + location=Point(-72.935242, 40.730610), + status=RestaurantStatus.APPROVED, + ) + + @pytest.fixture(scope="session") def denied_restaurant(django_db_setup, django_db_blocker, user) -> Restaurant: with django_db_blocker.unblock(): diff --git a/apps/transactions/migrations/0004_alter_transaction_status_alter_transaction_type.py b/apps/transactions/migrations/0004_alter_transaction_status_alter_transaction_type.py new file mode 100644 index 0000000..1e72d6f --- /dev/null +++ b/apps/transactions/migrations/0004_alter_transaction_status_alter_transaction_type.py @@ -0,0 +1,39 @@ +# Generated by Django 5.0.7 on 2024-08-08 08:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("transactions", "0003_alter_transaction_type"), + ] + + operations = [ + migrations.AlterField( + model_name="transaction", + name="status", + field=models.CharField( + choices=[ + ("p", "Pending"), + ("s", "Success"), + ("f", "Failed"), + ("c", "Cancelled"), + ("e", "Expired"), + ], + max_length=20, + ), + ), + migrations.AlterField( + model_name="transaction", + name="type", + field=models.CharField( + choices=[ + ("d", "Deposit"), + ("w", "Withdrawal"), + ("c", "Charge"), + ("o", "Cost"), + ], + max_length=20, + ), + ), + ] diff --git a/apps/transactions/models.py b/apps/transactions/models.py index d69d6e9..6e7cd6c 100644 --- a/apps/transactions/models.py +++ b/apps/transactions/models.py @@ -1,5 +1,6 @@ from django.db import models from django.conf import settings +from decimal import Decimal from uuid import uuid4 from transactions.enums import TransactionType, TransactionStatus from transactions.tasks import do_withdraw @@ -28,10 +29,10 @@ def save(self, *args, **kwargs): return super().save(*args, **kwargs) def _handle_deposit(self): - self.user.balance += self.amount + self.user.balance += Decimal(self.amount) self.user.save() def _handle_withdrawal(self): - self.user.balance -= self.amount + self.user.balance -= Decimal(self.amount) self.user.save() do_withdraw.delay(self.user.id, self.id) diff --git a/apps/transactions/tests/test_models.py b/apps/transactions/tests/test_models.py index 614fd4c..655d5d5 100644 --- a/apps/transactions/tests/test_models.py +++ b/apps/transactions/tests/test_models.py @@ -1,4 +1,5 @@ import pytest +from decimal import Decimal from transactions.models import Transaction from transactions.enums import TransactionType, TransactionStatus @@ -13,7 +14,7 @@ def test_user_balance_increased_after_successful_deposit(self, user): type=TransactionType.DEPOSIT, status=TransactionStatus.SUCCESS, ) - assert user.balance == user_balance + successful_deposit.amount + assert user.balance == user_balance + Decimal(successful_deposit.amount) def test_user_balance_decreased_after_successful_withdrawal(self, user): user_balance = user.balance @@ -23,4 +24,4 @@ def test_user_balance_decreased_after_successful_withdrawal(self, user): type=TransactionType.WITHDRAWAL, status=TransactionStatus.SUCCESS, ) - assert user.balance == user_balance - successful_withdrawal.amount + assert user.balance == user_balance - Decimal(successful_withdrawal.amount) diff --git a/config/settings/core.py b/config/settings/core.py index 3013ee9..87db953 100644 --- a/config/settings/core.py +++ b/config/settings/core.py @@ -20,6 +20,7 @@ "apps.products.apps.ProductsConfig", "apps.carts.apps.CartsConfig", "apps.transactions.apps.TransactionsConfig", + "apps.orders.apps.OrdersConfig", ] THIRD_PARTY_APPS = [ "rest_framework", diff --git a/config/urls.py b/config/urls.py index 45be37e..0734d87 100644 --- a/config/urls.py +++ b/config/urls.py @@ -12,6 +12,7 @@ path("products/", include("products.urls")), path("carts/", include("carts.urls")), path("transactions/", include("transactions.urls")), + path("orders/", include("orders.urls")), ] if settings.DEBUG: diff --git a/conftest.py b/conftest.py index 3036ae6..6aa0f6f 100644 --- a/conftest.py +++ b/conftest.py @@ -8,6 +8,7 @@ from apps.restaurants.tests.fixtures import * # noqa from apps.products.tests.fixtures import * # noqa from apps.transactions.tests.fixtures import * # noqa +from apps.orders.tests.fixtures import * # noqa @pytest.fixture(scope="class")