diff --git a/.gitignore b/.gitignore index 654e720..bf33a7d 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ share/python-wheels/ .installed.cfg *.egg MANIFEST +my-venv/ # PyInstaller # Usually these files are written by a python script from a template diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c7e7ce1..35838e9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,11 +15,11 @@ repos: hooks: - id: black - - repo: https://github.com/dhruvmanila/remove-print-statements - rev: 'v0.5.0' - hooks: - - id: remove-print-statements - args: ['--verbose'] # Show all the print statements to be removed + # - repo: https://github.com/dhruvmanila/remove-print-statements + # rev: 'v0.5.0' + # hooks: + # - id: remove-print-statements + # args: ['--verbose'] # Show all the print statements to be removed - repo: https://github.com/timothycrosley/isort rev: 5.12.0 diff --git a/apps/cart/views.py b/apps/cart/views.py index 5da25d5..e14099f 100644 --- a/apps/cart/views.py +++ b/apps/cart/views.py @@ -9,6 +9,7 @@ CartItemSerializer, ShoppingCartSerializer, ) +from apps.inventory.models import Inventory class ShoppingCartViewSet(ModelViewSet): @@ -47,6 +48,19 @@ def add_to_cart(self, request): cart = self.get_object() + try: + inventory = Inventory.objects.get(product=product) + if inventory.quantity < quantity: + return Response( + {"detail": f"Not enough stock for {product.name}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Inventory.DoesNotExist: + return Response( + {"detail": f"{product.name} out of stock"}, + status=status.HTTP_400_BAD_REQUEST, + ) + cart_item, _ = CartItem.objects.get_or_create(cart=cart, product=product) cart_item.quantity = quantity cart_item.price = product.price * quantity diff --git a/apps/finance/views.py b/apps/finance/views.py index a9d322f..5af2460 100644 --- a/apps/finance/views.py +++ b/apps/finance/views.py @@ -6,7 +6,7 @@ class Transactions(mixins.ListModelMixin, mixins.RetrieveModelMixin, GenericViewSet): - queryset = Transaction.objects.all() + queryset = Transaction.objects.all().select_related("user", "order") serializer_class = TransactionSerializer def get_queryset(self): diff --git a/apps/inventory/models.py b/apps/inventory/models.py index 61d4870..5748974 100644 --- a/apps/inventory/models.py +++ b/apps/inventory/models.py @@ -1,8 +1,7 @@ from django.db import models from apps.common import models as base_models - -from ..products.models import Product +from apps.products.models import Product class Inventory(base_models.BaseModel): diff --git a/apps/inventory/views.py b/apps/inventory/views.py index b74e194..20cf7fe 100644 --- a/apps/inventory/views.py +++ b/apps/inventory/views.py @@ -1,13 +1,13 @@ from rest_framework.permissions import IsAdminUser -from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.viewsets import ModelViewSet from .models import Inventory from .serializers import InventorySerializer -class InventoryView(ReadOnlyModelViewSet): - queryset = Inventory.objects.all() +class InventoryView(ModelViewSet): + queryset = Inventory.objects.all().select_related("product") serializer_class = InventorySerializer permission_classes = [IsAdminUser] - # http_method_names = [m for m in ModelViewSet.http_method_names if m not in ["put"]] + http_method_names = [m for m in ModelViewSet.http_method_names if m not in ["put"]] diff --git a/apps/orders/migrations/0005_delivery.py b/apps/orders/migrations/0005_delivery.py new file mode 100644 index 0000000..76dd303 --- /dev/null +++ b/apps/orders/migrations/0005_delivery.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.2 on 2025-09-04 10:04 + +import apps.orders.models +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0007_user_otp_secret_created_at'), + ('orders', '0004_alter_order_options'), + ] + + operations = [ + migrations.CreateModel( + name='Delivery', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created_at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('is_active', models.BooleanField(default=True)), + ('tracking_id', models.CharField(blank=True, max_length=50)), + ('delivery_date', models.DateTimeField(default=apps.orders.models.one_week_from_now)), + ('delivery_type', models.CharField(choices=[('P', 'pickup'), ('C', 'courier')], default='C', max_length=1)), + ('address', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.address')), + ('order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='delivery_details', to='orders.order')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/orders/models.py b/apps/orders/models.py index f0e2427..6175c7b 100644 --- a/apps/orders/models.py +++ b/apps/orders/models.py @@ -1,4 +1,7 @@ +from datetime import timedelta + from django.db import models +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from apps.common import models as base_models @@ -57,3 +60,23 @@ class OrderItem(base_models.BaseModel): ) price = models.DecimalField(max_digits=10, decimal_places=2) quantity = models.IntegerField() + + +def one_week_from_now(): + return timezone.now() + timedelta(days=7) + + +class Delivery(base_models.BaseModel): + class DeliveryType(models.TextChoices): + PICKUP = ("P", _("pickup")) + COURIER = ("C", _("courier")) + + order = models.OneToOneField( + Order, on_delete=models.CASCADE, related_name="delivery_details" + ) + tracking_id = models.CharField(max_length=50, blank=True) + delivery_date = models.DateTimeField(default=one_week_from_now) + delivery_type = models.CharField( + max_length=1, choices=DeliveryType.choices, default=DeliveryType.COURIER + ) + address = models.ForeignKey(usermodels.Address, on_delete=models.CASCADE) diff --git a/apps/orders/serializers.py b/apps/orders/serializers.py index bd0af1d..a201cac 100644 --- a/apps/orders/serializers.py +++ b/apps/orders/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from .models import Order, OrderItem +from .models import Delivery, Order, OrderItem class OrderItemSerializer(serializers.ModelSerializer): @@ -15,3 +15,16 @@ class OrderSerializer(serializers.ModelSerializer): class Meta: model = Order fields = "__all__" + + +class DeliverySerializer(serializers.ModelSerializer): + class Meta: + model = Delivery + fields = [ + "id", + "order", + "tracking_id", + "delivery_date", + "delivery_type", + "address", + ] diff --git a/apps/orders/urls.py b/apps/orders/urls.py index 7363791..e7b3b84 100644 --- a/apps/orders/urls.py +++ b/apps/orders/urls.py @@ -1,15 +1,17 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from apps.orders.views import OrderItemViewset, OrderViewSet +from apps.orders.views import DeliveryViewset, OrderItemViewset, OrderViewSet app_name = "order" router = DefaultRouter() router.register("item", OrderItemViewset, basename="order_item") +router.register("delivery", DeliveryViewset, basename="delivery_details") router.register("", OrderViewSet, basename="order") + # urlpatterns = router.urls urlpatterns = [ path("", include(router.urls)), diff --git a/apps/orders/views.py b/apps/orders/views.py index 5595dd2..7cebc80 100644 --- a/apps/orders/views.py +++ b/apps/orders/views.py @@ -1,4 +1,3 @@ -import time from django.db import transaction from django.shortcuts import get_object_or_404 from django.urls import reverse @@ -8,12 +7,17 @@ from rest_framework.viewsets import ModelViewSet from apps.cart.models import CartItem, ShoppingCart +from apps.common.email import send_email_template from apps.finance.models import Transaction from apps.finance.paystack import PaystackUtils -from apps.orders.models import Order, OrderItem -from apps.orders.serializers import OrderItemSerializer, OrderSerializer +from apps.inventory.models import Inventory +from apps.orders.models import Delivery, Order, OrderItem +from apps.orders.serializers import ( + DeliverySerializer, + OrderItemSerializer, + OrderSerializer, +) from apps.users.models import Address, Profile -from apps.common.email import send_email_template class OrderViewSet(ModelViewSet): @@ -38,14 +42,14 @@ def checkout(self, request): {"detail": "Cart is empty"}, status=status.HTTP_400_BAD_REQUEST ) + cart_items = CartItem.objects.filter(cart=cart.id).select_related("product") + shopping_items = cart.items.count() if shopping_items == 0: return Response( {"detail": "Add items to cart"}, status=status.HTTP_400_BAD_REQUEST ) - cart_items = CartItem.objects.filter(cart=cart.id).prefetch_related("product") - total_amount = sum(item.product.price * item.quantity for item in cart_items) delivery_cost = 10 total_cost = total_amount + delivery_cost @@ -72,6 +76,15 @@ def checkout(self, request): price=cart_item.price, ) + inventory = Inventory.objects.get(product=cart_item.product) + if inventory.quantity < cart_item.quantity: + return Response( + {"detail": f"Not enough stock for {cart_item.product.name}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + inventory.quantity -= cart_item.quantity + inventory.save() + serializer = self.get_serializer(order) # noqa kobo_amount = int(total_cost * 100) @@ -140,11 +153,22 @@ def payment_verify(self, request): except Profile.DoesNotExist: pass + delivery_instance = Delivery.objects.create( + order=order, tracking_id=order.id, address=order.delivery_address + ) + email = order.user.email - send_email_template(email, "d-f28075a5e4074706b817fe32b6260506", {email: { - "order_id": order.id, - "address": order.delivery_address, - }}) + send_email_template( + email, + "d-f28075a5e4074706b817fe32b6260506", + { + email: { + "order_id": order.id, + "address": order.delivery_address, + "tracking_id": delivery_instance.tracking_id, + } + }, + ) return Response( {"detail": "Payment successful"}, status=status.HTTP_200_OK @@ -161,9 +185,28 @@ def payment_verify(self, request): status=status.HTTP_400_BAD_REQUEST, ) + @action(detail=False, methods=["get"], url_path="cancel") + def cancel_order(self, request): + orderID = request.query_params.get("order_id") + if not orderID: + return Response( + {"detail": " No order ID provided"}, status=status.HTTP_400_BAD_REQUEST + ) + print(orderID) + + order = get_object_or_404(Order, id=orderID) + + if order.status in ["PC", "IND", "CP"]: + return Response({"detail": "This order cannot be cancelled"}) + + order.status = "CN" + order.save() + + return Response({"details": "Order successfully cancelled"}) + class OrderItemViewset(ModelViewSet): - queryset = OrderItem.objects.all() + queryset = OrderItem.objects.all().select_related("order", "product") serializer_class = OrderItemSerializer http_method_names = [ @@ -179,3 +222,22 @@ def list(self, request, *args, **kwargs): queryset = self.queryset.filter(order_id=order_id) serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) + + +class DeliveryViewset(ModelViewSet): + queryset = Delivery.objects.all() + serializer_class = DeliverySerializer + + http_method_names = [ + m + for m in ModelViewSet.http_method_names + if m not in ["put", "patch", "post", "delete"] + ] + + def list(self, request, *args, **kwargs): + delivery_id = self.request.query_params.get("delivery_id") + queryset = self.get_queryset() + if delivery_id: + queryset = self.queryset.filter(delivery_id=delivery_id) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) diff --git a/apps/products/models.py b/apps/products/models.py index 4950785..89f526d 100644 --- a/apps/products/models.py +++ b/apps/products/models.py @@ -33,6 +33,14 @@ class Meta: "created_at", ) + def save(self, *args, **kwargs): + created = not self.pk + super().save(*args, **kwargs) + if created: + from apps.inventory.models import Inventory + + Inventory.objects.create(product=self, quantity=1) + def __str__(self): return self.name diff --git a/apps/products/views.py b/apps/products/views.py index 7ba2485..ac0391d 100644 --- a/apps/products/views.py +++ b/apps/products/views.py @@ -24,7 +24,7 @@ def get_permissions(self): class ProductView(ModelViewSet): - queryset = Product.objects.all().prefetch_related("brand", "category") + queryset = Product.objects.all().select_related("brand", "category") serializer_class = ProductSerializer permission_classes = [AllowAny] @@ -37,7 +37,7 @@ def get_permissions(self): class ProductImageView(ModelViewSet): - queryset = ProductImage.objects.all().prefetch_related("product") + queryset = ProductImage.objects.all().select_related("product") serializer_class = ProductImageSerializer permission_classes = [AllowAny] @@ -50,7 +50,7 @@ def get_permissions(self): class FavoriteView(ModelViewSet): - queryset = Favorite.objects.all() + queryset = Favorite.objects.all().select_related("product", "user") serializer_class = FavoriteSerializer permission_classes = [IsAuthenticated] diff --git a/apps/users/migrations/0007_user_otp_secret_created_at.py b/apps/users/migrations/0007_user_otp_secret_created_at.py new file mode 100644 index 0000000..842a2d5 --- /dev/null +++ b/apps/users/migrations/0007_user_otp_secret_created_at.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.2 on 2024-11-16 07:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0006_alter_profile_profile_image_alter_user_member_type'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='otp_secret_created_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/apps/users/models.py b/apps/users/models.py index 3ffc683..4273e48 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -59,6 +59,7 @@ class MemberType(models.TextChoices): is_verified = models.BooleanField(default=False) # new deleted = models.BooleanField(default=False) otp_secret = models.CharField(max_length=100, null=True, blank=True) + otp_secret_created_at = models.DateTimeField(null=True, blank=True) member_type = models.CharField(max_length=3, choices=MemberType.choices) objects = UserManager() diff --git a/apps/users/serializers.py b/apps/users/serializers.py index 5e1de12..eb75a6f 100644 --- a/apps/users/serializers.py +++ b/apps/users/serializers.py @@ -2,6 +2,9 @@ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from rest_framework_simplejwt.tokens import RefreshToken +from django.utils import timezone +from django.utils.crypto import constant_time_compare +from django.contrib.auth.password_validation import validate_password import logging from apps.common.email import send_email_template @@ -155,29 +158,37 @@ class ForgotPasswordSerializer(serializers.Serializer): email = serializers.EmailField(required=True) + def validate(self, attrs): + email = attrs.get("email") + if not email: + raise serializers.ValidationError("Email must be provided") + return attrs + def create(self, validated_data: dict): """ if email code to a user, send an email with a code to reset password """ token = "" email = validated_data.get("email") - if user := User.objects.filter(email=email).first(): - code, token = OTPUtils.generate_otp(user) - - try: + try: + if user := User.objects.filter(email=email).first(): + code, token = OTPUtils.generate_otp(user) + user.otp_secret = code + user.otp_secret_created_at = timezone.now() + user.save(update_fields=["otp_secret", "otp_secret_created_at"]) send_email_template( email, "d-45557d1b684442b6aef71ae69d50c495", {email: {"code": code}} ) + else: + raise serializers.ValidationError("User with this email does not exist") + except Exception as e: + raise serializers.ValidationError(f"Error sending email: {e}") - except Exception as e: - logging.error(f"Error sending OTP email: {e}") return {"token": token} class ResetPasswordSerializer(serializers.Serializer): - """ """ - token = serializers.CharField(required=True) code = serializers.CharField(min_length=6, required=True) password = serializers.CharField(min_length=6, required=True) @@ -191,32 +202,49 @@ def create(self, validated_data): code = validated_data.get("code") password = validated_data.get("password") - data = OTPUtils.decode_token(token) - - if not data or not isinstance(data, dict): - raise serializers.ValidationError("Invalid token") - - if not (user := User.objects.filter(id=data.get("user_id")).first()): - raise serializers.ValidationError("User does not exist") - - # validate code - if not OTPUtils.verify_otp(code, data["secret"]): - raise serializers.ValidationError("Invalid code") - - # reset password - user.set_password(raw_password=password) - user.save() - try: - send_email_template(user.email, "d-e4bf355645044030af3f6fbb6f360153", - {user.email: {"username": user.username}}) + data = OTPUtils.decode_token(token) + if not data or not isinstance(data, dict): + raise serializers.ValidationError("Invalid token") + + if not (user := User.objects.filter(id=data.get("user_id")).first()): + raise serializers.ValidationError("User does not exist") + + if not constant_time_compare(user.otp_scret, code): + raise serializers.ValidationError("Invalid code") + + if user.otp_secret_created_at + timezone.timedelta(minutes=5) < timezone.now(): + raise serializers.ValidationError("otp code has expired") + + validate_password(password, user) + + # If we get here, verification was successful + user.set_password(raw_password=password) + user.otp_scret = None + user.otp_secret_created_at = None + user.is_verified = True + user.save(update_fields=[ + "password", + "otp_scret", + "otp_secret_created_at", + "is_verified" + ]) + + return {"email": user.email} + + except serializers.ValidationError: + if "user" in locals() and user is not None: + user.otp_secret = None + user.otp_secret_created_at = None + user.save(update_fields=["otp_scret", "otp_secret_created_at"]) + raise except Exception as e: - logging.error(f"Error sending password reset confirmation email: {e}") - - return { - "email": user.email, - } + if "user" in locals() and user is not None: + user.otp_secret = None + user.otp_secret_created_at = None + user.save(update_fields=["otp_secret", "otp_secret_created_at"]) + raise serializers.ValidationError(f"Password reset failed: {str(e)}") class ChangePasswordSerializer(serializers.Serializer): diff --git a/apps/users/views.py b/apps/users/views.py index c7806e4..02b9a49 100644 --- a/apps/users/views.py +++ b/apps/users/views.py @@ -55,7 +55,7 @@ class UserView(RetrieveModelMixin, UpdateModelMixin, ListModelMixin, GenericView filterset_fields = ["is_active", "deleted"] search_fields = [ "email", - "name", + "username", ] def get_queryset(self): @@ -152,7 +152,7 @@ def perform_create(self, serializer): class ProfileView(ModelViewSet): - queryset = Profile.objects.all().prefetch_related("user", "role") + queryset = Profile.objects.all().select_related("user", "role") serializer_class = ProfileSerializer filterset_fields = ("user", "role") parser_classes = (FormParser, MultiPartParser, JSONParser) diff --git a/config/settings/prod.py b/config/settings/prod.py index b9cd34c..4b2ef15 100644 --- a/config/settings/prod.py +++ b/config/settings/prod.py @@ -84,12 +84,14 @@ cloud_name=CLOUDINARY_NAME, api_key=CLOUDINARY_KEY, api_secret=CLOUDINARY_SECRET, + secure=True ) CLOUDINARY_STORAGE = { "CLOUD_NAME": CLOUDINARY_NAME, "API_SECRET": CLOUDINARY_SECRET, "API_KEY": CLOUDINARY_KEY, + "SECURE": True } STORAGES = { diff --git a/requirements/local.txt b/requirements/local.txt index b188452..a4ed0a6 100644 --- a/requirements/local.txt +++ b/requirements/local.txt @@ -3,7 +3,7 @@ -r base.txt -psycopg2==2.9.6 +psycopg2==2.9.10 pytest-django==4.5.2 pytest-factoryboy==2.5.1 django-extensions==3.2.3 diff --git a/requirements/prod.txt b/requirements/prod.txt index 15873da..7d008ae 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -3,7 +3,7 @@ -r base.txt -psycopg2==2.9.6 +psycopg2==2.9.10 gunicorn==20.1.0 django-storages==1.13.2 django-anymail==10.0