From 8f85d4c93f9ebf1c613e0d8135e23a3ca025d6a2 Mon Sep 17 00:00:00 2001 From: PTHARRISH Date: Thu, 11 Dec 2025 23:02:01 +0530 Subject: [PATCH 1/4] Day 11: Admin Dashboard --- users/admin.py | 173 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 150 insertions(+), 23 deletions(-) diff --git a/users/admin.py b/users/admin.py index 4e854cf..3b63253 100644 --- a/users/admin.py +++ b/users/admin.py @@ -1,12 +1,29 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin -from .models import Booking, Profile, Review, Tag, User +from .models import ( + Booking, + Brand, + Cart, + CartItem, + Category, + Notification, + Order, + OrderItem, + Product, + ProductImage, + ProductReview, + ProductReviewImage, + Profile, + Tag, + TechnicianReview, + User, +) -# --------------------------- -# Custom User Admin -# --------------------------- +# ---------------------------------------------------- +# USER ADMIN +# ---------------------------------------------------- @admin.register(User) class CustomUserAdmin(UserAdmin): list_display = ("username", "fullname", "role", "mobile", "is_active") @@ -28,40 +45,86 @@ class CustomUserAdmin(UserAdmin): ) -# --------------------------- -# Profile Admin -# --------------------------- +# ---------------------------------------------------- +# PROFILE ADMIN +# ---------------------------------------------------- @admin.register(Profile) class ProfileAdmin(admin.ModelAdmin): - list_display = ("user", "profile_status", "years_experience", "price_hour") + list_display = ("user", "profile_status", "years_experience", "price_day") list_filter = ("profile_status", "tags") search_fields = ("user__username", "user__fullname") + filter_horizontal = ("tags",) - filter_horizontal = ("tags",) # for ManyToManyField - -# --------------------------- -# Tag Admin -# --------------------------- +# ---------------------------------------------------- +# TAG ADMIN +# ---------------------------------------------------- @admin.register(Tag) class TagAdmin(admin.ModelAdmin): list_display = ("name",) search_fields = ("name",) -# --------------------------- -# Review Admin -# --------------------------- -@admin.register(Review) -class ReviewAdmin(admin.ModelAdmin): - list_display = ("technician", "reviewer", "rating", "created_at") +# ---------------------------------------------------- +# CATEGORY ADMIN +# ---------------------------------------------------- +@admin.register(Category) +class CategoryAdmin(admin.ModelAdmin): + list_display = ("name", "created_at") + search_fields = ("name",) + + +# ---------------------------------------------------- +# BRAND ADMIN +# ---------------------------------------------------- +@admin.register(Brand) +class BrandAdmin(admin.ModelAdmin): + list_display = ("name", "category", "created_at") + search_fields = ("name", "category__name") + list_filter = ("category",) + + +# ---------------------------------------------------- +# PRODUCT IMAGES INLINE +# ---------------------------------------------------- +class ProductImageInline(admin.TabularInline): + model = ProductImage + extra = 1 + + +# ---------------------------------------------------- +# PRODUCT ADMIN +# ---------------------------------------------------- +@admin.register(Product) +class ProductAdmin(admin.ModelAdmin): + list_display = ("product_name", "brand", "price", "status") + search_fields = ("product_name", "brand__name") + list_filter = ("status", "brand") + inlines = [ProductImageInline] + + +# ---------------------------------------------------- +# PRODUCT REVIEW IMAGES INLINE +# ---------------------------------------------------- +class ProductReviewImageInline(admin.TabularInline): + model = ProductReviewImage + extra = 1 + + +# ---------------------------------------------------- +# PRODUCT REVIEW ADMIN +# ---------------------------------------------------- +@admin.register(ProductReview) +class ProductReviewAdmin(admin.ModelAdmin): + list_display = ("product", "user", "rating", "created_at") list_filter = ("rating",) - search_fields = ("technician__user__fullname", "reviewer__fullname") + search_fields = ("product__product_name", "user__username") + inlines = [ProductReviewImageInline] -# --------------------------- -# Booking Admin -# --------------------------- +# ---------------------------------------------------- +# BOOKING ADMIN +# ---------------------------------------------------- @admin.register(Booking) class BookingAdmin(admin.ModelAdmin): list_display = ( @@ -72,5 +135,69 @@ class BookingAdmin(admin.ModelAdmin): "payment_done", "service_status", ) + search_fields = ("technician__user__fullname", "user__fullname") list_filter = ("service_status", "payment_done") + + +# ---------------------------------------------------- +# CART ITEM INLINE +# ---------------------------------------------------- +class CartItemInline(admin.TabularInline): + model = CartItem + extra = 1 + + +# ---------------------------------------------------- +# CART ADMIN +# ---------------------------------------------------- +@admin.register(Cart) +class CartAdmin(admin.ModelAdmin): + list_display = ("id", "user", "is_active", "created_at") + search_fields = ("user__username",) + inlines = [CartItemInline] + + +# ---------------------------------------------------- +# ORDER ITEM INLINE +# ---------------------------------------------------- +class OrderItemInline(admin.TabularInline): + model = OrderItem + extra = 1 + + +# ---------------------------------------------------- +# ORDER ADMIN +# ---------------------------------------------------- +@admin.register(Order) +class OrderAdmin(admin.ModelAdmin): + list_display = ( + "id", + "user", + "total", + "payment_method", + "payment_done", + "created_at", + ) + list_filter = ("payment_method", "payment_done") + search_fields = ("user__username", "user__fullname") + inlines = [OrderItemInline] + + +# ---------------------------------------------------- +# NOTIFICATION ADMIN +# ---------------------------------------------------- +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin): + list_display = ("recipient", "title", "is_read", "created_at") + list_filter = ("is_read",) + search_fields = ("title", "recipient__user__fullname") + + +# ---------------------------------------------------- +# TECHNICIAN REVIEW ADMIN +# ---------------------------------------------------- +@admin.register(TechnicianReview) +class TechnicianReviewAdmin(admin.ModelAdmin): + list_display = ("technician", "user", "booking", "rating", "created_at") + list_filter = ("rating",) search_fields = ("technician__user__fullname", "user__fullname") From 47b0d5173f8992104268b30cc8757a6269da305f Mon Sep 17 00:00:00 2001 From: PTHARRISH Date: Mon, 29 Dec 2025 11:18:47 +0530 Subject: [PATCH 2/4] Day 12: Based On swagger documentation Modified Models and serializer --- users/serializers.py | 67 ++++++++- users/views.py | 342 ++++++++++++++++++++++++------------------- 2 files changed, 251 insertions(+), 158 deletions(-) diff --git a/users/serializers.py b/users/serializers.py index aed283f..803dc0d 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -1,6 +1,7 @@ import re from django.contrib.auth import get_user_model +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from users.models import ( @@ -9,6 +10,7 @@ Brand, Cart, CartItem, + Notification, Product, ProductImage, ProductReview, @@ -169,6 +171,16 @@ class AdminProfileSerializer(UserProfileSerializer): pass +class EmptyProfileSerializer(serializers.Serializer): + pass + + +class EmptyResponseSerializer(serializers.Serializer): + """Used only for schema generation of 204 responses.""" + + pass + + class ProductImageSerializer(serializers.ModelSerializer): class Meta: model = ProductImage @@ -230,6 +242,7 @@ class Meta: "url", ] + @extend_schema_field(serializers.URLField(allow_null=True)) def get_url(self, obj): return f"/products/{obj.id}/" @@ -260,6 +273,7 @@ class Meta: "earnings", ] + @extend_schema_field(serializers.URLField(allow_null=True)) def get_avatar(self, obj): return obj.avatar.url if obj.avatar else None @@ -285,6 +299,29 @@ def get_user(self, obj): return {"id": u.id, "username": u.username, "fullname": u.fullname} +class BookingUserSerializer(serializers.Serializer): + id = serializers.IntegerField() + username = serializers.CharField() + fullname = serializers.CharField() + + +class TechnicianBookingSerializer(serializers.ModelSerializer): + user = BookingUserSerializer(source="user.username") + + class Meta: + model = Booking + fields = [ + "id", + "user", + "date_time_start", + "date_time_end", + "address", + "price", + "service_status", + "payment_done", + ] + + class AdminUserDetailSerializer(serializers.ModelSerializer): avatar = serializers.SerializerMethodField() total_bookings = serializers.IntegerField() @@ -307,6 +344,7 @@ class Meta: "avg_rating", ] + @extend_schema_field(serializers.URLField(allow_null=True)) def get_avatar(self, obj): profile = getattr(obj, "profile", None) return profile.avatar.url if profile and profile.avatar else None @@ -343,7 +381,7 @@ def get_technician(self, obj): class CartItemSerializer(serializers.ModelSerializer): product_name = serializers.CharField(source="product.product_name", read_only=True) total_price = serializers.DecimalField( - source="total_price", max_digits=12, decimal_places=2, read_only=True + max_digits=12, decimal_places=2, read_only=True ) class Meta: @@ -360,9 +398,7 @@ class Meta: class CartSerializer(serializers.ModelSerializer): items = CartItemSerializer(many=True) - total = serializers.DecimalField( - source="total", max_digits=12, decimal_places=2, read_only=True - ) + total = serializers.DecimalField(max_digits=12, decimal_places=2, read_only=True) class Meta: model = Cart @@ -386,6 +422,10 @@ class UpdateCartItemSerializer(serializers.Serializer): quantity = serializers.IntegerField(min_value=1) +class DeleteAccountResponseSerializer(serializers.Serializer): + message = serializers.CharField() + + class CheckoutSerializer(serializers.Serializer): address_index = serializers.IntegerField(required=False) address = serializers.JSONField(required=False) @@ -415,7 +455,26 @@ def validate(self, attrs): return attrs +class NotificationSerializer(serializers.ModelSerializer): + class Meta: + model = Notification + fields = [ + "id", + "title", + "message", + "created_at", + "is_read", + ] + + class TechnicianReviewSerializer(serializers.ModelSerializer): class Meta: model = TechnicianReview fields = ["id", "rating", "comment", "created_at"] + + +class CheckoutResponseSerializer(serializers.Serializer): + order_id = serializers.IntegerField() + total = serializers.DecimalField(max_digits=10, decimal_places=2) + payment_done = serializers.BooleanField() + booking_id = serializers.IntegerField(allow_null=True) diff --git a/users/views.py b/users/views.py index ab75f95..4e99526 100644 --- a/users/views.py +++ b/users/views.py @@ -5,19 +5,13 @@ from django.core.signing import BadSignature, SignatureExpired, TimestampSigner from django.db import transaction from django.db.models import Avg, Count, Max, Sum -from django.http import JsonResponse from django.shortcuts import get_object_or_404 from django_filters.rest_framework import DjangoFilterBackend from rest_framework import status from rest_framework.filters import OrderingFilter, SearchFilter -from rest_framework.generics import ListAPIView +from rest_framework.generics import GenericAPIView, ListAPIView from rest_framework.parsers import FormParser, MultiPartParser -from rest_framework.permissions import ( - AllowAny, - IsAuthenticated, - IsUserRole, - TechnicianUser, -) +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView @@ -36,7 +30,7 @@ Profile, TechnicianReview, ) -from users.permissions import AdminUser +from users.permissions import AdminUser, IsUserRole, TechnicianUser from users.serializers import ( AddToCartSerializer, AdminProfileSerializer, @@ -44,11 +38,17 @@ BookingDetailSerializer, CartItemSerializer, CartSerializer, + CheckoutResponseSerializer, CheckoutSerializer, + DeleteAccountResponseSerializer, + EmptyProfileSerializer, + EmptyResponseSerializer, LoginSerializer, + NotificationSerializer, ProductReviewSerializer, ProductSerializer, RegisterSerializer, + TechnicianBookingSerializer, TechnicianProfileSerializer, TechnicianReviewSerializer, TechnicianSummarySerializer, @@ -202,58 +202,87 @@ def post(self, request): ) -class ProfileView(APIView): +class ProfileView(GenericAPIView): permission_classes = [IsAuthenticated] + queryset = Profile.objects.select_related("user") - def get_profile(self, username): - try: - user = User.objects.get(username=username) - profile = Profile.objects.filter(user=user).first() - if not profile: - return None, None - return user, profile - except User.DoesNotExist: - return None, None + # ------------------------------------------------ + # MUST ALWAYS RETURN A SERIALIZER CLASS + # ------------------------------------------------ + def get_serializer_class(self): + request = getattr(self, "request", None) + kwargs = getattr(self, "kwargs", {}) + + # Schema generation / no request + if not request or "username" not in kwargs: + return EmptyProfileSerializer + + request_user = request.user + username = kwargs.get("username") + + user = User.objects.filter(username=username).first() + if not user: + return EmptyProfileSerializer - def get_serializer_class(self, request_user, profile_user): if request_user.role == "admin": return AdminProfileSerializer - if request_user.role == "technician" and request_user == profile_user: + if request_user.role == "technician" and request_user == user: return TechnicianProfileSerializer - if request_user.role == "user" and request_user == profile_user: + if request_user.role == "user" and request_user == user: return UserProfileSerializer - return None + # ❗ NEVER return None + return EmptyProfileSerializer + # ------------------------------------------------ + # GET + # ------------------------------------------------ def get(self, request, username): - user, profile = self.get_profile(username) - if not user: - return Response({"error": "User not found"}, status=404) + user = get_object_or_404(User, username=username) + profile = get_object_or_404(Profile, user=user) - serializer_class = self.get_serializer_class(request.user, user) - if not serializer_class: - return Response({"error": "Permission denied"}, status=403) + serializer_class = self.get_serializer_class() + + # Permission enforcement HERE (not in get_serializer_class) + if serializer_class is EmptyProfileSerializer: + return Response( + {"error": "Permission denied"}, + status=status.HTTP_403_FORBIDDEN, + ) serializer = serializer_class(profile) return Response(serializer.data) + # ------------------------------------------------ + # PUT + # ------------------------------------------------ def put(self, request, username): - user, profile = self.get_profile(username) - if not user: - return Response({"error": "User not found"}, status=404) + user = get_object_or_404(User, username=username) + profile = get_object_or_404(Profile, user=user) - serializer_class = self.get_serializer_class(request.user, user) - if not serializer_class: - return Response({"error": "Permission denied"}, status=403) + serializer_class = self.get_serializer_class() - serializer = serializer_class(profile, data=request.data, partial=True) + if serializer_class is EmptyProfileSerializer: + return Response( + {"error": "Permission denied"}, + status=status.HTTP_403_FORBIDDEN, + ) - if serializer.is_valid(): - serializer.save() - return Response({"message": "Profile updated successfully"}) - return Response(serializer.errors, status=400) + serializer = serializer_class( + profile, + data=request.data, + partial=True, + ) + + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response( + {"message": "Profile updated successfully"}, + status=status.HTTP_200_OK, + ) class ProductListView(ListAPIView): @@ -275,20 +304,21 @@ class ProductListView(ListAPIView): ordering = ["price"] -class ProductReviewCreateUpdateView(APIView): +class ProductReviewCreateUpdateView(GenericAPIView): parser_classes = [MultiPartParser, FormParser] permission_classes = [IsAuthenticated] + serializer_class = ProductReviewSerializer def post(self, request, product_id): user = request.user product = get_object_or_404(Product, id=product_id) - # user must have purchased this product + # Ensure the user purchased this product purchased = OrderItem.objects.filter(order__user=user, product=product).exists() if not purchased: return Response( {"error": "You can review this product only after purchasing it."}, - status=400, + status=status.HTTP_400_BAD_REQUEST, ) review, created = ProductReview.objects.get_or_create( @@ -310,7 +340,7 @@ def post(self, request, product_id): for img in images: ProductReviewImage.objects.create(review=review, image=img) - serializer = ProductReviewSerializer(review) + serializer = self.get_serializer(review) return Response(serializer.data) @@ -438,172 +468,177 @@ def get(self, request, user_id): ) -class DeleteAccountView(APIView): +class DeleteAccountView(GenericAPIView): permission_classes = [AllowAny] + serializer_class = DeleteAccountResponseSerializer def get(self, request): token = request.GET.get("token") if not token: - return JsonResponse({"error": "Token required"}, status=400) + return Response( + {"message": "Token required"}, status=status.HTTP_400_BAD_REQUEST + ) try: - # Verify and check expiration (max_age in seconds = 24h) - unsigned = signer.unsign(token, max_age=86400) + unsigned = signer.unsign(token, max_age=86400) # 24h user = get_user_model().objects.get(pk=unsigned) except SignatureExpired: - return JsonResponse({"error": "Link expired"}, status=400) + return Response( + {"message": "Link expired"}, status=status.HTTP_400_BAD_REQUEST + ) except (BadSignature, get_user_model().DoesNotExist): - return JsonResponse({"error": "Invalid link"}, status=400) + return Response( + {"message": "Invalid link"}, status=status.HTTP_400_BAD_REQUEST + ) - # Delete user and profile user.delete() - return JsonResponse({"message": "Account deleted successfully."}, status=200) + return Response( + {"message": "Account deleted successfully."}, status=status.HTTP_200_OK + ) -class TechnicianBookingsView(APIView): +class TechnicianBookingsView(GenericAPIView): permission_classes = [IsAuthenticated, TechnicianUser] + serializer_class = TechnicianBookingSerializer def get(self, request): - # technician is Profile linked to user try: profile = request.user.profile except Profile.DoesNotExist: - return Response({"detail": "Profile not found."}, status=404) - bookings = profile.bookings.all().order_by("-date_time_start") - data = [] - for b in bookings: - data.append( - { - "id": b.pk, - "user": { - "id": b.user.id, - "username": b.user.username, - "fullname": b.user.fullname, - }, - "date_time_start": b.date_time_start, - "date_time_end": b.date_time_end, - "address": b.address, - "price": b.price, - "service_status": b.service_status, - "payment_done": b.payment_done, - } + return Response( + {"detail": "Profile not found."}, + status=status.HTTP_404_NOT_FOUND, ) - return Response(data) + + bookings = profile.bookings.order_by("-date_time_start") + serializer = self.get_serializer(bookings, many=True) + return Response(serializer.data) -class TechnicianNotificationsView(APIView): +class TechnicianNotificationsView(GenericAPIView): permission_classes = [IsAuthenticated, TechnicianUser] + serializer_class = NotificationSerializer def get(self, request): profile = request.user.profile qs = profile.notifications.order_by("-created_at") - data = [ - { - "id": n.pk, - "title": n.title, - "message": n.message, - "created_at": n.created_at, - "is_read": n.is_read, - } - for n in qs - ] - return Response(data) + serializer = self.get_serializer(qs, many=True) + return Response(serializer.data) -class AddToCartView(APIView): +class AddToCartView(GenericAPIView): permission_classes = [IsAuthenticated, IsUserRole] + serializer_class = AddToCartSerializer def post(self, request): - serializer = AddToCartSerializer(data=request.data) + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) + product = get_object_or_404(Product, pk=serializer.validated_data["product_id"]) qty = serializer.validated_data.get("quantity", 1) cart, _ = Cart.objects.get_or_create(user=request.user, is_active=True) - # get or create cart item; increment quantity if exists cart_item, created = CartItem.objects.get_or_create( cart=cart, product=product, defaults={"quantity": qty, "unit_price": product.price}, ) + if not created: - cart_item.quantity = cart_item.quantity + qty + cart_item.quantity += qty cart_item.save() return Response(CartSerializer(cart).data, status=status.HTTP_200_OK) -class CartDetailView(APIView): +class CartDetailView(GenericAPIView): permission_classes = [IsAuthenticated, IsUserRole] + serializer_class = CartSerializer def get(self, request): cart, _ = Cart.objects.get_or_create(user=request.user, is_active=True) - return Response(CartSerializer(cart).data) + serializer = self.get_serializer(cart) + return Response(serializer.data) -class UpdateCartItemView(APIView): +# ------------------------------- +# Update Cart Item +# ------------------------------- +class UpdateCartItemView(GenericAPIView): permission_classes = [IsAuthenticated, IsUserRole] + serializer_class = UpdateCartItemSerializer def patch(self, request, item_id): cart, _ = Cart.objects.get_or_create(user=request.user, is_active=True) cart_item = get_object_or_404(CartItem, pk=item_id, cart=cart) - serializer = UpdateCartItemSerializer(data=request.data) + + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) + cart_item.quantity = serializer.validated_data["quantity"] cart_item.save() + return Response(CartItemSerializer(cart_item).data) -class RemoveCartItemView(APIView): +# ------------------------------- +# Remove Cart Item +# ------------------------------- +class RemoveCartItemView(GenericAPIView): permission_classes = [IsAuthenticated, IsUserRole] + serializer_class = EmptyResponseSerializer def delete(self, request, item_id): cart, _ = Cart.objects.get_or_create(user=request.user, is_active=True) cart_item = get_object_or_404(CartItem, pk=item_id, cart=cart) mode = request.query_params.get("mode", "all") - # default to removing entire item + if mode == "one": - # remove just one quantity if cart_item.quantity > 1: cart_item.quantity -= 1 cart_item.save() else: - # if quantity becomes 0 → delete item cart_item.delete() else: - # remove entire item cart_item.delete() + # Return 204 No Content return Response(status=status.HTTP_204_NO_CONTENT) -class CheckoutView(APIView): +class CheckoutView(GenericAPIView): permission_classes = [IsAuthenticated, IsUserRole] + serializer_class = CheckoutSerializer # request serializer @transaction.atomic def post(self, request): + # -------- CART ---------- cart = get_object_or_404(Cart, user=request.user, is_active=True) + if not cart.items.exists(): return Response( - {"detail": "Cart is empty."}, status=status.HTTP_400_BAD_REQUEST + {"detail": "Cart is empty."}, + status=status.HTTP_400_BAD_REQUEST, ) - serializer = CheckoutSerializer(data=request.data) + # -------- VALIDATE INPUT ---------- + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) data = serializer.validated_data - # Resolve address + # -------- ADDRESS RESOLUTION ---------- chosen_address = None if "address_index" in data: idx = data["address_index"] - # expect user.address to be a list of address objects OR a single object ua = getattr(request.user, "address", None) + if ua is None: return Response( {"detail": "No saved addresses found."}, status=status.HTTP_400_BAD_REQUEST, ) + if isinstance(ua, list): try: chosen_address = ua[idx] @@ -622,39 +657,36 @@ def post(self, request): else: chosen_address = data.get("address") - # calculate items total - items_total = Decimal(0) - for it in cart.items.all(): - items_total += Decimal(it.unit_price) * it.quantity + # -------- ITEMS TOTAL ---------- + items_total = Decimal("0.00") + for item in cart.items.all(): + items_total += Decimal(item.unit_price) * item.quantity - # technician handling + # -------- TECHNICIAN HANDLING ---------- technician = None - technician_fee = Decimal(0) + technician_fee = Decimal("0.00") booking_obj = None + if data.get("technician_id"): - tech = get_object_or_404(Profile, pk=data["technician_id"]) - technician = tech - # calculate duration; prefer price_day; fallback to price_hour + technician = get_object_or_404(Profile, pk=data["technician_id"]) + start = data["date_time_start"] end = data["date_time_end"] delta = end - start - # days as integer (count partial as full) - days = int(ceil(delta.total_seconds() / (24 * 3600))) - if tech.price_day: - technician_fee = (Decimal(tech.price_day) * Decimal(days)).quantize( - Decimal("0.01") - ) - elif tech.price_hour: + + if technician.price_day: + days = int(ceil(delta.total_seconds() / (24 * 3600))) + technician_fee = Decimal(technician.price_day) * days + elif technician.price_hour: hours = int(ceil(delta.total_seconds() / 3600)) - technician_fee = (Decimal(tech.price_hour) * Decimal(hours)).quantize( - Decimal("0.01") - ) - else: - technician_fee = Decimal(0) + technician_fee = Decimal(technician.price_hour) * hours + + technician_fee = technician_fee.quantize(Decimal("0.01")) + # -------- TOTAL ---------- total = (items_total + technician_fee).quantize(Decimal("0.01")) - # create Order + # -------- CREATE ORDER ---------- order = Order.objects.create( user=request.user, cart=cart, @@ -667,7 +699,7 @@ def post(self, request): notes=data.get("notes", ""), ) - # create order items + # -------- ORDER ITEMS ---------- for item in cart.items.all(): OrderItem.objects.create( order=order, @@ -679,9 +711,8 @@ def post(self, request): ), ) - # If technician selected, create Booking and attach to order. + # -------- BOOKING ---------- if technician: - booking_price = technician_fee booking_obj = Booking.objects.create( technician=technician, user=request.user, @@ -689,17 +720,17 @@ def post(self, request): date_time_end=data["date_time_end"], address=chosen_address, payment_done=order.payment_done, - price=booking_price, + price=technician_fee, service_status="pending", ) + order.booking = booking_obj order.save() - # Notify technician Notification.objects.create( recipient=technician, title=f"New booking request (Order {order.pk})", - message=f"You have a new booking from {request.user.username}.", + message=(f"You have a new booking from {request.user.username}."), metadata={ "order_id": order.pk, "booking_id": booking_obj.pk, @@ -707,50 +738,53 @@ def post(self, request): }, ) - # mark cart inactive (checkout) + # -------- FINALIZE CART ---------- cart.is_active = False cart.save() - # Optionally create a fresh cart for user Cart.objects.create(user=request.user, is_active=True) + # -------- RESPONSE ---------- + response_data = { + "order_id": order.pk, + "total": order.total, + "payment_done": order.payment_done, + "booking_id": booking_obj.pk if booking_obj else None, + } + return Response( - { - "order_id": order.pk, - "total": order.total, - "payment_done": order.payment_done, - "booking_id": booking_obj.pk if booking_obj else None, - }, + CheckoutResponseSerializer(response_data).data, status=status.HTTP_201_CREATED, ) -class TechnicianReviewView(APIView): +class TechnicianReviewView(GenericAPIView): permission_classes = [IsAuthenticated, IsUserRole] + serializer_class = TechnicianReviewSerializer def post(self, request, booking_id): user = request.user booking = get_object_or_404(Booking, id=booking_id, user=user) - # Only if booking is completed if booking.service_status != "completed": return Response( - {"error": "You can review only completed services."}, status=400 + {"error": "You can review only completed services."}, + status=status.HTTP_400_BAD_REQUEST, ) - # Only 1 review per booking if TechnicianReview.objects.filter(booking=booking).exists(): - return Response({"error": "You already reviewed this service."}, status=400) + return Response( + {"error": "You already reviewed this service."}, + status=status.HTTP_400_BAD_REQUEST, + ) - technician = booking.technician # profile + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) - review = TechnicianReview.objects.create( - technician=technician, - user=user, - booking=booking, - rating=request.data.get("rating"), - comment=request.data.get("comment", ""), + review = serializer.save( + technician=booking.technician, user=user, booking=booking ) - serializer = TechnicianReviewSerializer(review) - return Response(serializer.data, status=201) + return Response( + self.get_serializer(review).data, status=status.HTTP_201_CREATED + ) From bea0d137daa17c47aed93d004976a5369688f0c5 Mon Sep 17 00:00:00 2001 From: PTHARRISH Date: Mon, 29 Dec 2025 11:19:10 +0530 Subject: [PATCH 3/4] Day 12: swagger documentation Modified --- schema.yml | 437 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 428 insertions(+), 9 deletions(-) diff --git a/schema.yml b/schema.yml index 7f59f0f..f84dcf9 100644 --- a/schema.yml +++ b/schema.yml @@ -40,6 +40,119 @@ paths: schema: $ref: '#/components/schemas/AdminUserDetail' description: '' + /api/cart/: + get: + operationId: cart_retrieve + tags: + - cart + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Cart' + description: '' + /api/cart/add/: + post: + operationId: cart_add_create + tags: + - cart + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AddToCart' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/AddToCart' + multipart/form-data: + schema: + $ref: '#/components/schemas/AddToCart' + required: true + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/AddToCart' + description: '' + /api/cart/checkout/: + post: + operationId: cart_checkout_create + tags: + - cart + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Checkout' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Checkout' + multipart/form-data: + schema: + $ref: '#/components/schemas/Checkout' + required: true + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Checkout' + description: '' + /api/cart/item/{item_id}/: + patch: + operationId: cart_item_partial_update + parameters: + - in: path + name: item_id + schema: + type: integer + required: true + tags: + - cart + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedUpdateCartItem' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedUpdateCartItem' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedUpdateCartItem' + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateCartItem' + description: '' + /api/cart/item/{item_id}/remove/: + delete: + operationId: cart_item_remove_destroy + parameters: + - in: path + name: item_id + schema: + type: integer + required: true + tags: + - cart + security: + - jwtAuth: [] + responses: + '204': + description: No response body /api/delete-account/: get: operationId: delete_account_retrieve @@ -50,7 +163,11 @@ paths: - {} responses: '200': - description: No response body + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteAccountResponse' + description: '' /api/login/: post: operationId: login_create @@ -162,15 +279,12 @@ paths: - products requestBody: content: - application/json: + multipart/form-data: schema: $ref: '#/components/schemas/ProductReview' application/x-www-form-urlencoded: schema: $ref: '#/components/schemas/ProductReview' - multipart/form-data: - schema: - $ref: '#/components/schemas/ProductReview' required: true security: - jwtAuth: [] @@ -293,6 +407,66 @@ paths: schema: $ref: '#/components/schemas/Register' description: '' + /api/technician/bookings/: + get: + operationId: technician_bookings_retrieve + tags: + - technician + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TechnicianBooking' + description: '' + /api/technician/notifications/: + get: + operationId: technician_notifications_retrieve + tags: + - technician + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Notification' + description: '' + /api/technician/review/{booking_id}/: + post: + operationId: technician_review_create + parameters: + - in: path + name: booking_id + schema: + type: integer + required: true + tags: + - technician + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TechnicianReview' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TechnicianReview' + multipart/form-data: + schema: + $ref: '#/components/schemas/TechnicianReview' + required: true + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TechnicianReview' + description: '' /api/token/: post: operationId: token_create @@ -349,6 +523,17 @@ paths: description: '' components: schemas: + AddToCart: + type: object + properties: + product_id: + type: integer + quantity: + type: integer + minimum: 1 + default: 1 + required: + - product_id AdminUserDetail: type: object properties: @@ -371,6 +556,8 @@ components: maxLength: 254 avatar: type: string + format: uri + nullable: true readOnly: true date_joined: type: string @@ -395,6 +582,19 @@ components: - total_bookings - total_spent - username + BookingUser: + type: object + properties: + id: + type: integer + username: + type: string + fullname: + type: string + required: + - fullname + - id + - username Brand: type: object properties: @@ -411,6 +611,96 @@ components: - category - id - name + Cart: + type: object + properties: + id: + type: integer + readOnly: true + user: + type: integer + readOnly: true + items: + type: array + items: + $ref: '#/components/schemas/CartItem' + total: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,2})?$ + readOnly: true + is_active: + type: boolean + readOnly: true + required: + - id + - is_active + - items + - total + - user + CartItem: + type: object + properties: + id: + type: integer + readOnly: true + product: + type: integer + product_name: + type: string + readOnly: true + quantity: + type: integer + maximum: 2147483647 + minimum: 0 + unit_price: + type: string + format: decimal + pattern: ^-?\d{0,8}(?:\.\d{0,2})?$ + total_price: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,2})?$ + readOnly: true + required: + - id + - product + - product_name + - total_price + - unit_price + Checkout: + type: object + properties: + address_index: + type: integer + address: {} + payment_method: + $ref: '#/components/schemas/PaymentMethodEnum' + technician_id: + type: integer + nullable: true + date_time_start: + type: string + format: date-time + nullable: true + date_time_end: + type: string + format: date-time + nullable: true + payment_done: + type: boolean + default: false + notes: + type: string + required: + - payment_method + DeleteAccountResponse: + type: object + properties: + message: + type: string + required: + - message Login: type: object properties: @@ -422,6 +712,28 @@ components: required: - identifier - password + Notification: + type: object + properties: + id: + type: integer + readOnly: true + title: + type: string + maxLength: 255 + message: + type: string + created_at: + type: string + format: date-time + readOnly: true + is_read: + type: boolean + required: + - created_at + - id + - message + - title PaginatedProductList: type: object required: @@ -445,6 +757,22 @@ components: type: array items: $ref: '#/components/schemas/Product' + PatchedUpdateCartItem: + type: object + properties: + quantity: + type: integer + minimum: 1 + PaymentMethodEnum: + enum: + - COD + - UPI + - CARD + type: string + description: |- + * `COD` - Cash on Delivery + * `UPI` - UPI + * `CARD` - Card Product: type: object properties: @@ -485,6 +813,8 @@ components: readOnly: true url: type: string + format: uri + nullable: true readOnly: true required: - average_rating @@ -524,10 +854,11 @@ components: minimum: 1 review_text: type: string - image: - type: string - format: uri - nullable: true + images: + type: array + items: + $ref: '#/components/schemas/ProductReviewImage' + readOnly: true created_at: type: string format: date-time @@ -535,8 +866,26 @@ components: required: - created_at - id + - images - rating - username + ProductReviewImage: + type: object + properties: + id: + type: integer + readOnly: true + image: + type: string + format: uri + uploaded_at: + type: string + format: date-time + readOnly: true + required: + - id + - image + - uploaded_at ProfileStatusEnum: enum: - inactive @@ -592,6 +941,16 @@ components: * `user` - User * `admin` - Admin * `technician` - Technician + ServiceStatusEnum: + enum: + - pending + - completed + - cancelled + type: string + description: |- + * `pending` - Pending + * `completed` - Completed + * `cancelled` - Cancelled StatusEnum: enum: - stock @@ -612,6 +971,56 @@ components: required: - id - name + TechnicianBooking: + type: object + properties: + id: + type: integer + readOnly: true + user: + $ref: '#/components/schemas/BookingUser' + date_time_start: + type: string + format: date-time + date_time_end: + type: string + format: date-time + address: + nullable: true + price: + type: string + format: decimal + pattern: ^-?\d{0,8}(?:\.\d{0,2})?$ + service_status: + $ref: '#/components/schemas/ServiceStatusEnum' + payment_done: + type: boolean + required: + - date_time_end + - date_time_start + - id + - price + - user + TechnicianReview: + type: object + properties: + id: + type: integer + readOnly: true + rating: + type: integer + maximum: 2147483647 + minimum: -2147483648 + comment: + type: string + created_at: + type: string + format: date-time + readOnly: true + required: + - created_at + - id + - rating TechnicianSummary: type: object properties: @@ -624,6 +1033,8 @@ components: type: string avatar: type: string + format: uri + nullable: true readOnly: true profile_status: $ref: '#/components/schemas/ProfileStatusEnum' @@ -689,6 +1100,14 @@ components: required: - access - refresh + UpdateCartItem: + type: object + properties: + quantity: + type: integer + minimum: 1 + required: + - quantity securitySchemes: jwtAuth: type: http From 1be9c043d95c368f8f5d7cf5d3e4cfec16923add Mon Sep 17 00:00:00 2001 From: PTHARRISH Date: Mon, 29 Dec 2025 11:39:16 +0530 Subject: [PATCH 4/4] Day 12: Added Home View code and swagger documentation Modified --- api/urls.py | 2 + schema.yml | 88 ++++++++++++++++++++++++++++++++++++++++++++ users/serializers.py | 44 ++++++++++++++++++++++ users/views.py | 38 +++++++++++++++++++ 4 files changed, 172 insertions(+) diff --git a/api/urls.py b/api/urls.py index 4374737..73d94f6 100644 --- a/api/urls.py +++ b/api/urls.py @@ -8,6 +8,7 @@ CartDetailView, CheckoutView, DeleteAccountView, + HomeView, LoginView, ProductDetailView, ProductListView, @@ -31,6 +32,7 @@ ), path("register/admin/", AdminRegisterView.as_view(), name="admin-register"), path("login/", LoginView.as_view(), name="login"), + path("home/", HomeView.as_view(), name="home-view"), path("profile//", ProfileView.as_view(), name="profile_detail"), path("products/", ProductListView.as_view(), name="product-list"), path( diff --git a/schema.yml b/schema.yml index f84dcf9..a17de0f 100644 --- a/schema.yml +++ b/schema.yml @@ -168,6 +168,21 @@ paths: schema: $ref: '#/components/schemas/DeleteAccountResponse' description: '' + /api/home/: + get: + operationId: home_retrieve + tags: + - home + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Home' + description: '' /api/login/: post: operationId: login_create @@ -701,6 +716,25 @@ components: type: string required: - message + Home: + type: object + properties: + top_products: + type: array + items: + $ref: '#/components/schemas/ProductLanding' + top_technicians: + type: array + items: + $ref: '#/components/schemas/TechnicianLanding' + suggested_product: + allOf: + - $ref: '#/components/schemas/ProductLanding' + nullable: true + required: + - suggested_product + - top_products + - top_technicians Login: type: object properties: @@ -839,6 +873,36 @@ components: required: - id - image + ProductLanding: + type: object + properties: + id: + type: integer + readOnly: true + product_name: + type: string + maxLength: 255 + price: + type: string + format: decimal + pattern: ^-?\d{0,8}(?:\.\d{0,2})?$ + average_rating: + type: number + format: double + readOnly: true + images: + type: array + items: + type: object + additionalProperties: + type: string + readOnly: true + required: + - average_rating + - id + - images + - price + - product_name ProductReview: type: object properties: @@ -1001,6 +1065,30 @@ components: - id - price - user + TechnicianLanding: + type: object + properties: + id: + type: integer + readOnly: true + fullname: + type: string + avatar: + type: string + format: uri + nullable: true + readOnly: true + years_of_experience: + type: string + readOnly: true + total_bookings: + type: integer + required: + - avatar + - fullname + - id + - total_bookings + - years_of_experience TechnicianReview: type: object properties: diff --git a/users/serializers.py b/users/serializers.py index 803dc0d..020db65 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -109,6 +109,50 @@ def update(self, instance, validated_data): return instance # Login does not update objects +class ProductLandingSerializer(serializers.ModelSerializer): + images = serializers.SerializerMethodField() + average_rating = serializers.FloatField(read_only=True) + + class Meta: + model = Product + fields = ["id", "product_name", "price", "average_rating", "images"] + + @extend_schema_field( + serializers.ListField( + child=serializers.DictField(child=serializers.CharField()) + ) + ) + def get_images(self, obj): + return [{"id": img.id, "url": img.image.url} for img in obj.images.all()] + + +class TechnicianLandingSerializer(serializers.ModelSerializer): + avatar = serializers.SerializerMethodField() + fullname = serializers.CharField(source="user.fullname") + total_bookings = serializers.IntegerField() + years_of_experience = serializers.SerializerMethodField() + + class Meta: + model = Profile + fields = ["id", "fullname", "avatar", "years_of_experience", "total_bookings"] + + @extend_schema_field(serializers.URLField(allow_null=True)) + def get_avatar(self, obj): + return obj.avatar.url if obj.avatar else None + + @extend_schema_field(serializers.CharField()) + def get_years_of_experience(self, obj): + y = obj.years_experience or 0 + m = obj.months_experience or 0 + return f"{y}y {m}m" + + +class HomeSerializer(serializers.Serializer): + top_products = ProductLandingSerializer(many=True) + top_technicians = TechnicianLandingSerializer(many=True) + suggested_product = ProductLandingSerializer(allow_null=True) + + class UserProfileSerializer(serializers.ModelSerializer): fullname = serializers.CharField(source="user.fullname", required=False) diff --git a/users/views.py b/users/views.py index 4e99526..c34edbb 100644 --- a/users/views.py +++ b/users/views.py @@ -43,12 +43,15 @@ DeleteAccountResponseSerializer, EmptyProfileSerializer, EmptyResponseSerializer, + HomeSerializer, LoginSerializer, NotificationSerializer, + ProductLandingSerializer, ProductReviewSerializer, ProductSerializer, RegisterSerializer, TechnicianBookingSerializer, + TechnicianLandingSerializer, TechnicianProfileSerializer, TechnicianReviewSerializer, TechnicianSummarySerializer, @@ -202,6 +205,41 @@ def post(self, request): ) +class HomeView(GenericAPIView): + permission_classes = [AllowAny] + serializer_class = HomeSerializer # for Swagger/OpenAPI + + def get(self, request): + # Top 10 most popular products (using reviews as proxy) + top_products = Product.objects.annotate( + total_sold=Count("reviews__id") + ).order_by("-total_sold")[:10] + + # Top 10 technicians by bookings + top_technicians = ( + Profile.objects.annotate(total_bookings=Count("bookings")) + .filter(profile_status="active") + .order_by("-total_bookings")[:10] + ) + + # Suggested product: RO Purifier + ro_purifier = Product.objects.filter( + product_name__icontains="RO Purifier" + ).first() + + data = { + "top_products": ProductLandingSerializer(top_products, many=True).data, + "top_technicians": TechnicianLandingSerializer( + top_technicians, many=True + ).data, + "suggested_product": ( + ProductLandingSerializer(ro_purifier).data if ro_purifier else None + ), + } + + return Response(data) + + class ProfileView(GenericAPIView): permission_classes = [IsAuthenticated] queryset = Profile.objects.select_related("user")