From f15b7ca288f9fb3166326019d6029aa2f7c5235c Mon Sep 17 00:00:00 2001 From: miky-rola Date: Sat, 9 Nov 2024 19:34:43 +0000 Subject: [PATCH 01/14] feat: added secure to cloudinary to redirect to https instead of http --- config/settings/prod.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/settings/prod.py b/config/settings/prod.py index a2a187d..578780c 100644 --- a/config/settings/prod.py +++ b/config/settings/prod.py @@ -83,12 +83,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 = { From 19d65ade37491d9cdf8df5dc8f31d59050e2b699 Mon Sep 17 00:00:00 2001 From: miky-rola Date: Sat, 16 Nov 2024 07:29:17 +0000 Subject: [PATCH 02/14] stored reset password to user model --- apps/users/models.py | 1 + apps/users/serializers.py | 94 +++++++++++++++++++++++++++------------ 2 files changed, 66 insertions(+), 29 deletions(-) diff --git a/apps/users/models.py b/apps/users/models.py index b28cae2..386ab76 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -58,6 +58,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 f5ca3ca..d425a7c 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 from apps.common.email import send_email_template from apps.common.utils import OTPUtils @@ -151,24 +154,36 @@ 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) - send_email_template( - email, "d-45557d1b684442b6aef71ae69d50c495", {email: {"code": code}} - ) + 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}") 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) @@ -182,28 +197,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() - - # send_email_template(user.email, "d-e4bf355645044030af3f6fbb6f360153", \ - # {user.email: {"username": user.username}}) - - return { - "email": user.email, - } + try: + 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: + 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): From 2938f1dccf268b9b01ddc704ca54e9cd6023874d Mon Sep 17 00:00:00 2001 From: miky-rola Date: Sat, 16 Nov 2024 07:31:33 +0000 Subject: [PATCH 03/14] added opt created_at to user --- .../0007_user_otp_secret_created_at.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 apps/users/migrations/0007_user_otp_secret_created_at.py 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..5780c9a --- /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:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0006_alter_user_member_type'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='otp_secret_created_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] From 8b08ed9f39a8f023565f4cef532793c676793a04 Mon Sep 17 00:00:00 2001 From: miky-rola Date: Sat, 16 Nov 2024 07:46:24 +0000 Subject: [PATCH 04/14] feat: added lazy fetching and refactored reset password --- apps/cart/views.py | 2 +- apps/finance/views.py | 2 +- apps/inventory/views.py | 2 +- apps/orders/views.py | 4 ++-- apps/products/views.py | 6 +++--- .../0007_user_otp_secret_created_at.py | 18 ------------------ apps/users/models.py | 2 +- apps/users/serializers.py | 18 +++++++++--------- apps/users/views.py | 4 ++-- 9 files changed, 20 insertions(+), 38 deletions(-) delete mode 100644 apps/users/migrations/0007_user_otp_secret_created_at.py diff --git a/apps/cart/views.py b/apps/cart/views.py index 5da25d5..474d77a 100644 --- a/apps/cart/views.py +++ b/apps/cart/views.py @@ -21,7 +21,7 @@ class ShoppingCartViewSet(ModelViewSet): def get_queryset(self): user = self.request.user if user.is_authenticated: - return ShoppingCart.objects.filter(user=self.request.user).prefetch_related( + return ShoppingCart.objects.filter(user=self.request.user).select_related( "items__product", "items__product__brand", "items__product__category" ) return ShoppingCart.objects.none() 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/views.py b/apps/inventory/views.py index b74e194..d09cfce 100644 --- a/apps/inventory/views.py +++ b/apps/inventory/views.py @@ -6,7 +6,7 @@ class InventoryView(ReadOnlyModelViewSet): - queryset = Inventory.objects.all() + queryset = Inventory.objects.all().select_related("product") serializer_class = InventorySerializer permission_classes = [IsAdminUser] diff --git a/apps/orders/views.py b/apps/orders/views.py index befcc0e..677d3f0 100644 --- a/apps/orders/views.py +++ b/apps/orders/views.py @@ -36,7 +36,7 @@ def checkout(self, request): return Response( {"detail": "Cart is empty"}, status=status.HTTP_400_BAD_REQUEST ) - cart_items = CartItem.objects.filter(cart=cart.id).prefetch_related("product") + cart_items = CartItem.objects.filter(cart=cart.id).select_related("product") total_amount = sum(item.product.price * item.quantity for item in cart_items) delivery_cost = 10 @@ -151,7 +151,7 @@ def payment_verify(self, request): class OrderItemViewset(ModelViewSet): - queryset = OrderItem.objects.all() + queryset = OrderItem.objects.all().select_related("order", "product") serializer_class = OrderItemSerializer http_method_names = [ 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 deleted file mode 100644 index 5780c9a..0000000 --- a/apps/users/migrations/0007_user_otp_secret_created_at.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.2 on 2024-11-16 07:31 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0006_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 386ab76..f60bc3b 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -58,7 +58,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) + otp_secret_created = 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 d425a7c..a0ad1ab 100644 --- a/apps/users/serializers.py +++ b/apps/users/serializers.py @@ -170,8 +170,8 @@ def create(self, validated_data: dict): 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"]) + user.otp_secret_created = timezone.now() + user.save(update_fields=["otp_secret", "otp_secret_created"]) send_email_template( email, "d-45557d1b684442b6aef71ae69d50c495", {email: {"code": code}} ) @@ -208,7 +208,7 @@ def create(self, validated_data): 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(): + if user.otp_secret_created + timezone.timedelta(minutes=5) < timezone.now(): raise serializers.ValidationError("otp code has expired") validate_password(password, user) @@ -216,12 +216,12 @@ def create(self, validated_data): # If we get here, verification was successful user.set_password(raw_password=password) user.otp_scret = None - user.otp_secret_created_at = None + user.otp_secret_created = None user.is_verified = True user.save(update_fields=[ "password", "otp_scret", - "otp_secret_created_at", + "otp_secret_created", "is_verified" ]) @@ -230,15 +230,15 @@ def create(self, validated_data): 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"]) + user.otp_secret_created = None + user.save(update_fields=["otp_scret", "otp_secret_created"]) raise except Exception as e: 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"]) + user.otp_secret_created = None + user.save(update_fields=["otp_secret", "otp_secret_created"]) raise serializers.ValidationError(f"Password reset failed: {str(e)}") diff --git a/apps/users/views.py b/apps/users/views.py index 76a33d3..b9c931a 100644 --- a/apps/users/views.py +++ b/apps/users/views.py @@ -54,7 +54,7 @@ class UserView(RetrieveModelMixin, UpdateModelMixin, ListModelMixin, GenericView filterset_fields = ["is_active", "deleted"] search_fields = [ "email", - "name", + "username", ] def get_queryset(self): @@ -151,7 +151,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) From a3c1cfed2892593e1b1761cb5d911e4eea266571 Mon Sep 17 00:00:00 2001 From: miky-rola Date: Sat, 16 Nov 2024 07:49:05 +0000 Subject: [PATCH 05/14] feat: forgot password and reset password --- apps/users/models.py | 2 +- apps/users/serializers.py | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/apps/users/models.py b/apps/users/models.py index f60bc3b..386ab76 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -58,7 +58,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 = models.DateTimeField(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 a0ad1ab..966de17 100644 --- a/apps/users/serializers.py +++ b/apps/users/serializers.py @@ -170,8 +170,8 @@ def create(self, validated_data: dict): if user := User.objects.filter(email=email).first(): code, token = OTPUtils.generate_otp(user) user.otp_secret = code - user.otp_secret_created = timezone.now() - user.save(update_fields=["otp_secret", "otp_secret_created"]) + 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}} ) @@ -180,6 +180,7 @@ def create(self, validated_data: dict): except Exception as e: raise serializers.ValidationError(f"Error sending email: {e}") + return {"token": token} @@ -208,7 +209,7 @@ def create(self, validated_data): if not constant_time_compare(user.otp_scret, code): raise serializers.ValidationError("Invalid code") - if user.otp_secret_created + timezone.timedelta(minutes=5) < timezone.now(): + if user.otp_secret_created_at + timezone.timedelta(minutes=5) < timezone.now(): raise serializers.ValidationError("otp code has expired") validate_password(password, user) @@ -216,12 +217,12 @@ def create(self, validated_data): # If we get here, verification was successful user.set_password(raw_password=password) user.otp_scret = None - user.otp_secret_created = None + user.otp_secret_created_at = None user.is_verified = True user.save(update_fields=[ "password", "otp_scret", - "otp_secret_created", + "otp_secret_created_at", "is_verified" ]) @@ -230,15 +231,15 @@ def create(self, validated_data): except serializers.ValidationError: if "user" in locals() and user is not None: user.otp_secret = None - user.otp_secret_created = None - user.save(update_fields=["otp_scret", "otp_secret_created"]) + user.otp_secret_created_at = None + user.save(update_fields=["otp_scret", "otp_secret_created_at"]) raise except Exception as e: if "user" in locals() and user is not None: user.otp_secret = None - user.otp_secret_created = None - user.save(update_fields=["otp_secret", "otp_secret_created"]) + 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)}") From 61ce9ef6ced2b983853394a66d245ed521e405a0 Mon Sep 17 00:00:00 2001 From: miky-rola Date: Sat, 16 Nov 2024 07:49:41 +0000 Subject: [PATCH 06/14] feat: migration for otp secret created at --- .../0007_user_otp_secret_created_at.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 apps/users/migrations/0007_user_otp_secret_created_at.py 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..793c454 --- /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_user_member_type'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='otp_secret_created_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] From 820d19d4e548cd196832571c702c77d6aba85e1b Mon Sep 17 00:00:00 2001 From: miky-rola Date: Sat, 16 Nov 2024 22:30:28 +0000 Subject: [PATCH 07/14] feat: fixed migration conflict --- apps/users/migrations/0007_user_otp_secret_created_at.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/users/migrations/0007_user_otp_secret_created_at.py b/apps/users/migrations/0007_user_otp_secret_created_at.py index 793c454..842a2d5 100644 --- a/apps/users/migrations/0007_user_otp_secret_created_at.py +++ b/apps/users/migrations/0007_user_otp_secret_created_at.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ('users', '0006_alter_user_member_type'), + ('users', '0006_alter_profile_profile_image_alter_user_member_type'), ] operations = [ From 2895c66a706e5aad9d3cec2bf934b53b21b13df4 Mon Sep 17 00:00:00 2001 From: meeklife Date: Tue, 15 Jul 2025 11:03:11 +0100 Subject: [PATCH 08/14] fix: merge conflict, main into dev --- apps/users/serializers.py | 45 --------------------------------------- 1 file changed, 45 deletions(-) diff --git a/apps/users/serializers.py b/apps/users/serializers.py index d11005b..eb75a6f 100644 --- a/apps/users/serializers.py +++ b/apps/users/serializers.py @@ -2,13 +2,10 @@ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from rest_framework_simplejwt.tokens import RefreshToken -<<<<<<< HEAD from django.utils import timezone from django.utils.crypto import constant_time_compare from django.contrib.auth.password_validation import validate_password -======= import logging ->>>>>>> main from apps.common.email import send_email_template from apps.common.utils import OTPUtils @@ -173,7 +170,6 @@ def create(self, validated_data: dict): """ token = "" email = validated_data.get("email") -<<<<<<< HEAD try: if user := User.objects.filter(email=email).first(): code, token = OTPUtils.generate_otp(user) @@ -188,18 +184,6 @@ def create(self, validated_data: dict): except Exception as e: raise serializers.ValidationError(f"Error sending email: {e}") -======= - if user := User.objects.filter(email=email).first(): - code, token = OTPUtils.generate_otp(user) - - try: - send_email_template( - email, "d-45557d1b684442b6aef71ae69d50c495", {email: {"code": code}} - ) - - except Exception as e: - logging.error(f"Error sending OTP email: {e}") ->>>>>>> main return {"token": token} @@ -218,7 +202,6 @@ def create(self, validated_data): code = validated_data.get("code") password = validated_data.get("password") -<<<<<<< HEAD try: data = OTPUtils.decode_token(token) if not data or not isinstance(data, dict): @@ -262,34 +245,6 @@ def create(self, validated_data): 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)}") -======= - 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}}) - - except Exception as e: - logging.error(f"Error sending password reset confirmation email: {e}") - - return { - "email": user.email, - } ->>>>>>> main class ChangePasswordSerializer(serializers.Serializer): From f42b3a542086cb4d3d24cfd5292612181de6b4cf Mon Sep 17 00:00:00 2001 From: meeklife Date: Tue, 15 Jul 2025 13:41:29 +0100 Subject: [PATCH 09/14] fix(cart): invalid field while using select_related --- .gitignore | 1 + .pre-commit-config.yaml | 10 +++++----- apps/cart/views.py | 2 +- requirements/local.txt | 2 +- requirements/prod.txt | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) 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 474d77a..5da25d5 100644 --- a/apps/cart/views.py +++ b/apps/cart/views.py @@ -21,7 +21,7 @@ class ShoppingCartViewSet(ModelViewSet): def get_queryset(self): user = self.request.user if user.is_authenticated: - return ShoppingCart.objects.filter(user=self.request.user).select_related( + return ShoppingCart.objects.filter(user=self.request.user).prefetch_related( "items__product", "items__product__brand", "items__product__category" ) return ShoppingCart.objects.none() 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 From e28f6f9f413dea835e6d91c45d441da976fa6ca1 Mon Sep 17 00:00:00 2001 From: meeklife Date: Tue, 15 Jul 2025 13:47:04 +0100 Subject: [PATCH 10/14] refactor(inventory): allow read and write for modelviewset --- apps/inventory/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/inventory/views.py b/apps/inventory/views.py index d09cfce..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): +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"]] From 08cd36a98438354694204cd532b6b430be64cd83 Mon Sep 17 00:00:00 2001 From: meeklife Date: Tue, 15 Jul 2025 15:01:06 +0100 Subject: [PATCH 11/14] feat(cart&order): check inventory before completing order --- apps/cart/views.py | 14 ++++++++++++++ apps/orders/views.py | 27 +++++++++++++++++++++------ 2 files changed, 35 insertions(+), 6 deletions(-) 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/orders/views.py b/apps/orders/views.py index 036323a..8f55333 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,13 @@ 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.inventory.models import Inventory from apps.orders.models import Order, OrderItem from apps.orders.serializers import OrderItemSerializer, OrderSerializer from apps.users.models import Address, Profile -from apps.common.email import send_email_template class OrderViewSet(ModelViewSet): @@ -72,6 +72,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) @@ -141,10 +150,16 @@ def payment_verify(self, request): pass 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, + } + }, + ) return Response( {"detail": "Payment successful"}, status=status.HTTP_200_OK From f5959037edc07bf93c94b4c136f5a3ffc413991a Mon Sep 17 00:00:00 2001 From: meeklife Date: Tue, 15 Jul 2025 22:41:08 +0100 Subject: [PATCH 12/14] chore(inventory): override Product save method --- apps/inventory/models.py | 3 +-- apps/products/models.py | 8 ++++++++ 2 files changed, 9 insertions(+), 2 deletions(-) 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/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 From 90ac63ef47d8704c0af926e5cec5a5a299f7cba9 Mon Sep 17 00:00:00 2001 From: meeklife Date: Thu, 4 Sep 2025 11:49:23 +0100 Subject: [PATCH 13/14] chore(order): add delivery --- apps/orders/migrations/0005_delivery.py | 34 +++++++++++++++++++++++++ apps/orders/models.py | 23 +++++++++++++++++ apps/orders/serializers.py | 15 ++++++++++- apps/orders/urls.py | 4 ++- apps/orders/views.py | 32 +++++++++++++++++++++-- 5 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 apps/orders/migrations/0005_delivery.py 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 8f55333..d0a6732 100644 --- a/apps/orders/views.py +++ b/apps/orders/views.py @@ -11,8 +11,12 @@ from apps.finance.models import Transaction from apps.finance.paystack import PaystackUtils from apps.inventory.models import Inventory -from apps.orders.models import Order, OrderItem -from apps.orders.serializers import OrderItemSerializer, OrderSerializer +from apps.orders.models import Delivery, Order, OrderItem +from apps.orders.serializers import ( + DeliverySerializer, + OrderItemSerializer, + OrderSerializer, +) from apps.users.models import Address, Profile @@ -149,6 +153,10 @@ 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, @@ -157,6 +165,7 @@ def payment_verify(self, request): email: { "order_id": order.id, "address": order.delivery_address, + "tracking_id": delivery_instance.tracking_id, } }, ) @@ -194,3 +203,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) From 5e0ff641bcb3f21f28d6bcff5c1dc7763b55bbaa Mon Sep 17 00:00:00 2001 From: meeklife Date: Thu, 4 Sep 2025 14:26:48 +0100 Subject: [PATCH 14/14] chore(order): user can cancel an order --- apps/orders/views.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/orders/views.py b/apps/orders/views.py index d0a6732..7cebc80 100644 --- a/apps/orders/views.py +++ b/apps/orders/views.py @@ -185,6 +185,25 @@ 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().select_related("order", "product")