diff --git a/api/urls.py b/api/urls.py index 5fbe4e3..73d94f6 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,16 +1,25 @@ from django.urls import path from users.views import ( + AddToCartView, AdminDashboardView, AdminRegisterView, AdminUserDetailView, + CartDetailView, + CheckoutView, DeleteAccountView, + HomeView, LoginView, ProductDetailView, ProductListView, ProductReviewCreateUpdateView, ProfileView, + RemoveCartItemView, + TechnicianBookingsView, + TechnicianNotificationsView, TechnicianRegisterView, + TechnicianReviewView, + UpdateCartItemView, UserRegisterView, ) @@ -23,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( @@ -33,11 +43,35 @@ ProductReviewCreateUpdateView.as_view(), name="product-review", ), + path("technician/review//", TechnicianReviewView.as_view()), path("admin/dashboard/", AdminDashboardView.as_view(), name="admin-dashboard"), path( "admin/users//", AdminUserDetailView.as_view(), name="admin-user-detail", ), + path( + "technician/bookings/", + TechnicianBookingsView.as_view(), + name="technician-bookings", + ), + path( + "technician/notifications/", + TechnicianNotificationsView.as_view(), + name="technician-notifications", + ), + path("cart/add/", AddToCartView.as_view(), name="cart-add"), + path("cart/", CartDetailView.as_view(), name="cart-detail"), + path( + "cart/item//", + UpdateCartItemView.as_view(), + name="cart-item-update", + ), + path( + "cart/item//remove/", + RemoveCartItemView.as_view(), + name="cart-item-remove", + ), + path("cart/checkout/", CheckoutView.as_view(), name="cart-checkout"), path("delete-account/", DeleteAccountView.as_view(), name="delete-account"), ] diff --git a/schema.yml b/schema.yml index 7f59f0f..a17de0f 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,26 @@ paths: - {} responses: '200': - description: No response body + content: + application/json: + 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 @@ -162,15 +294,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 +422,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 +538,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 +571,8 @@ components: maxLength: 254 avatar: type: string + format: uri + nullable: true readOnly: true date_joined: type: string @@ -395,6 +597,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 +626,115 @@ 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 + 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: @@ -422,6 +746,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 +791,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 +847,8 @@ components: readOnly: true url: type: string + format: uri + nullable: true readOnly: true required: - average_rating @@ -509,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: @@ -524,10 +918,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 +930,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 +1005,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 +1035,80 @@ 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 + 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: + 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 +1121,8 @@ components: type: string avatar: type: string + format: uri + nullable: true readOnly: true profile_status: $ref: '#/components/schemas/ProfileStatusEnum' @@ -689,6 +1188,14 @@ components: required: - access - refresh + UpdateCartItem: + type: object + properties: + quantity: + type: integer + minimum: 1 + required: + - quantity securitySchemes: jwtAuth: type: http 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") diff --git a/users/models.py b/users/models.py index c82b959..c2c44e6 100644 --- a/users/models.py +++ b/users/models.py @@ -1,8 +1,16 @@ +from decimal import ROUND_HALF_UP, Decimal + from django.conf import settings from django.contrib.auth.models import AbstractUser from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +PAYMENT_CHOICES = [ + ("COD", "Cash on Delivery"), + ("UPI", "UPI"), + ("CARD", "Card"), +] + class TimestampedModel(models.Model): created_at = models.DateTimeField(auto_now_add=True) @@ -130,7 +138,7 @@ class ProductReview(TimestampedModel): validators=[MinValueValidator(1), MaxValueValidator(5)] ) review_text = models.TextField(blank=True) - image = models.ImageField(upload_to="reviews/", blank=True, null=True) + # image = models.ImageField(upload_to="reviews/", blank=True, null=True) class Meta: unique_together = ("user", "product") # One review per user @@ -139,6 +147,17 @@ def __str__(self): return f"{self.user.username} - {self.product.product_name}" +class ProductReviewImage(models.Model): + review = models.ForeignKey( + ProductReview, related_name="images", on_delete=models.CASCADE + ) + image = models.ImageField(upload_to="product_review_images/") + uploaded_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"ReviewImage {self.id} for Review {self.review.id}" + + class Review(TimestampedModel): technician = models.ForeignKey( Profile, on_delete=models.CASCADE, related_name="reviews" @@ -174,3 +193,103 @@ class Booking(models.Model): def __str__(self): return f"Booking {self.pk} - {self.technician} for {self.user}" + + +class Cart(TimestampedModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="carts" + ) + is_active = models.BooleanField(default=True) + + def __str__(self): + return f"Cart {self.pk} - {self.user.username}" + + @property + def total(self): + total = sum([item.total_price for item in self.items.all()]) + return Decimal(total).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + +class CartItem(models.Model): + cart = models.ForeignKey(Cart, on_delete=models.CASCADE, related_name="items") + product = models.ForeignKey("Product", on_delete=models.CASCADE) + quantity = models.PositiveIntegerField(default=1) + unit_price = models.DecimalField(max_digits=10, decimal_places=2) + added_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("cart", "product") + + def __str__(self): + return f"{self.product.product_name} x {self.quantity}" + + @property + def total_price(self): + return (Decimal(self.unit_price) * Decimal(self.quantity)).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + + +class Order(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="orders" + ) + cart = models.ForeignKey(Cart, on_delete=models.SET_NULL, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + total = models.DecimalField(max_digits=12, decimal_places=2) + address = models.JSONField(blank=True, null=True) + payment_method = models.CharField(max_length=10, choices=PAYMENT_CHOICES) + payment_done = models.BooleanField(default=False) + technician = models.ForeignKey( + "Profile", on_delete=models.SET_NULL, null=True, blank=True + ) + technician_fee = models.DecimalField(max_digits=10, decimal_places=2, default=0) + booking = models.OneToOneField( + "Booking", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="order", + ) + notes = models.TextField(blank=True, default="") + + def __str__(self): + return f"Order {self.pk} - {self.user.username}" + + +class OrderItem(models.Model): + order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items") + product = models.ForeignKey("Product", on_delete=models.SET_NULL, null=True) + unit_price = models.DecimalField(max_digits=10, decimal_places=2) + quantity = models.PositiveIntegerField() + line_total = models.DecimalField(max_digits=12, decimal_places=2) + + def __str__(self): + return f"OrderItem {self.pk} for Order {self.order.pk}" + + +class Notification(TimestampedModel): + recipient = models.ForeignKey( + "Profile", on_delete=models.CASCADE, related_name="notifications" + ) + title = models.CharField(max_length=255) + message = models.TextField() + metadata = models.JSONField(blank=True, null=True) + is_read = models.BooleanField(default=False) + + def __str__(self): + return f"Notification to {self.recipient} - {self.title}" + + +class TechnicianReview(models.Model): + technician = models.ForeignKey( + Profile, on_delete=models.CASCADE, related_name="tech_reviews" + ) + user = models.ForeignKey(User, on_delete=models.CASCADE) + booking = models.OneToOneField(Booking, on_delete=models.CASCADE) + rating = models.IntegerField() + comment = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.user} reviewed {self.technician}" diff --git a/users/permissions.py b/users/permissions.py index a9758f6..a7d1b8f 100644 --- a/users/permissions.py +++ b/users/permissions.py @@ -19,3 +19,13 @@ def has_permission(self, request, view): and request.user.role == "technician" and request.user.is_staff ) + + +class IsUserRole(BasePermission): + def has_permission(self, request, view): + return bool( + request.user + and request.user.is_authenticated + and request.user.role == "user" + and not request.user.is_staff + ) diff --git a/users/serializers.py b/users/serializers.py index c311c67..020db65 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -1,16 +1,23 @@ 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 ( + PAYMENT_CHOICES, Booking, Brand, + Cart, + CartItem, + Notification, Product, ProductImage, ProductReview, + ProductReviewImage, Profile, Tag, + TechnicianReview, ) User = get_user_model() @@ -102,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) @@ -164,6 +215,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 @@ -184,12 +245,19 @@ class Meta: fields = ["id", "name"] +class ProductReviewImageSerializer(serializers.ModelSerializer): + class Meta: + model = ProductReviewImage + fields = ["id", "image", "uploaded_at"] + + class ProductReviewSerializer(serializers.ModelSerializer): + images = ProductReviewImageSerializer(many=True, read_only=True) username = serializers.CharField(source="user.username", read_only=True) class Meta: model = ProductReview - fields = ["id", "username", "rating", "review_text", "image", "created_at"] + fields = ["id", "username", "rating", "review_text", "images", "created_at"] read_only_fields = ["username", "created_at"] @@ -218,6 +286,7 @@ class Meta: "url", ] + @extend_schema_field(serializers.URLField(allow_null=True)) def get_url(self, obj): return f"/products/{obj.id}/" @@ -248,6 +317,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 @@ -273,6 +343,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() @@ -295,6 +388,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 @@ -326,3 +420,105 @@ def get_technician(self, obj): "username": t.username, "fullname": t.fullname, } + + +class CartItemSerializer(serializers.ModelSerializer): + product_name = serializers.CharField(source="product.product_name", read_only=True) + total_price = serializers.DecimalField( + max_digits=12, decimal_places=2, read_only=True + ) + + class Meta: + model = CartItem + fields = [ + "id", + "product", + "product_name", + "quantity", + "unit_price", + "total_price", + ] + + +class CartSerializer(serializers.ModelSerializer): + items = CartItemSerializer(many=True) + total = serializers.DecimalField(max_digits=12, decimal_places=2, read_only=True) + + class Meta: + model = Cart + fields = ["id", "user", "items", "total", "is_active"] + read_only_fields = ["user", "total", "is_active"] + + +class AddToCartSerializer(serializers.Serializer): + product_id = serializers.IntegerField() + quantity = serializers.IntegerField(min_value=1, default=1) + + def validate_product_id(self, value): + try: + Product.objects.get(pk=value) + except Product.DoesNotExist: + raise serializers.ValidationError("Product not found.") from None + return value + + +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) + payment_method = serializers.ChoiceField(choices=PAYMENT_CHOICES) + technician_id = serializers.IntegerField(required=False, allow_null=True) + date_time_start = serializers.DateTimeField(required=False, allow_null=True) + date_time_end = serializers.DateTimeField(required=False, allow_null=True) + payment_done = serializers.BooleanField(default=False) + notes = serializers.CharField(required=False, allow_blank=True) + + def validate(self, attrs): + tech = attrs.get("technician_id") + start = attrs.get("date_time_start") + end = attrs.get("date_time_end") + if tech: + if not start or not end: + raise serializers.ValidationError( + "date_time_start and date_time_end " + "are required when technician is selected." + ) + if start >= end: + raise serializers.ValidationError( + "date_time_end must be after date_time_start." + ) + if "address_index" not in attrs and "address" not in attrs: + raise serializers.ValidationError("Provide address_index or address JSON.") + 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 3de1a2d..c34edbb 100644 --- a/users/views.py +++ b/users/views.py @@ -1,38 +1,68 @@ from decimal import Decimal, InvalidOperation +from math import ceil from django.contrib.auth import get_user_model 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 from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView from rest_framework_simplejwt.tokens import RefreshToken -from users.models import Booking, Product, ProductReview, Profile -from users.permissions import AdminUser +from users.models import ( + Booking, + Cart, + CartItem, + Notification, + Order, + OrderItem, + Product, + ProductReview, + ProductReviewImage, + Profile, + TechnicianReview, +) +from users.permissions import AdminUser, IsUserRole, TechnicianUser from users.serializers import ( + AddToCartSerializer, AdminProfileSerializer, AdminUserDetailSerializer, BookingDetailSerializer, + CartItemSerializer, + CartSerializer, + CheckoutResponseSerializer, + CheckoutSerializer, + DeleteAccountResponseSerializer, + EmptyProfileSerializer, + EmptyResponseSerializer, + HomeSerializer, LoginSerializer, + NotificationSerializer, + ProductLandingSerializer, ProductReviewSerializer, ProductSerializer, RegisterSerializer, + TechnicianBookingSerializer, + TechnicianLandingSerializer, TechnicianProfileSerializer, + TechnicianReviewSerializer, TechnicianSummarySerializer, + UpdateCartItemSerializer, UserBookingHistorySerializer, UserProfileSerializer, ) from users.utils import paginate, parse_date_range User = get_user_model() +signer = TimestampSigner() class UserRegisterView(APIView): @@ -175,58 +205,122 @@ def post(self, request): ) -class ProfileView(APIView): +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") - 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): @@ -248,26 +342,44 @@ 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) - try: - review = ProductReview.objects.get(product=product, user=request.user) - serializer = self.serializer_class(review, data=request.data, partial=True) - except ProductReview.DoesNotExist: - serializer = self.serializer_class(data=request.data) - if serializer.is_valid(): - serializer.save(product=product, user=request.user) + # Ensure the user purchased this product + purchased = OrderItem.objects.filter(order__user=user, product=product).exists() + if not purchased: return Response( - {"message": "Review submitted successfully", "data": serializer.data}, - status=status.HTTP_200_OK, + {"error": "You can review this product only after purchasing it."}, + status=status.HTTP_400_BAD_REQUEST, ) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + review, created = ProductReview.objects.get_or_create( + user=user, + product=product, + defaults={ + "rating": request.data.get("rating"), + "review_text": request.data.get("review_text", ""), + }, + ) + + if not created: + review.rating = request.data.get("rating") + review.review_text = request.data.get("review_text", "") + review.save() + + # Handle images + images = request.FILES.getlist("images") + for img in images: + ProductReviewImage.objects.create(review=review, image=img) + + serializer = self.get_serializer(review) + return Response(serializer.data) class ProductDetailView(APIView): @@ -280,9 +392,6 @@ def get(self, request, product_id): return Response(serializer.data, status=status.HTTP_200_OK) -# Admin Views all user information including roles and technician details. - - class AdminDashboardView(APIView): permission_classes = [AdminUser] serializer_class = TechnicianSummarySerializer @@ -397,26 +506,323 @@ def get(self, request, user_id): ) -signer = TimestampSigner() - - -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(GenericAPIView): + permission_classes = [IsAuthenticated, TechnicianUser] + serializer_class = TechnicianBookingSerializer + + def get(self, request): + try: + profile = request.user.profile + except Profile.DoesNotExist: + return Response( + {"detail": "Profile not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + bookings = profile.bookings.order_by("-date_time_start") + serializer = self.get_serializer(bookings, many=True) + return Response(serializer.data) + + +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") + serializer = self.get_serializer(qs, many=True) + return Response(serializer.data) + + +class AddToCartView(GenericAPIView): + permission_classes = [IsAuthenticated, IsUserRole] + serializer_class = AddToCartSerializer + + def post(self, request): + 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) + + 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 += qty + cart_item.save() + + return Response(CartSerializer(cart).data, status=status.HTTP_200_OK) + + +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) + serializer = self.get_serializer(cart) + return Response(serializer.data) + + +# ------------------------------- +# 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 = 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) + + +# ------------------------------- +# 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") + + if mode == "one": + if cart_item.quantity > 1: + cart_item.quantity -= 1 + cart_item.save() + else: + cart_item.delete() + else: + cart_item.delete() + + # Return 204 No Content + return Response(status=status.HTTP_204_NO_CONTENT) + + +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, + ) + + # -------- VALIDATE INPUT ---------- + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + # -------- ADDRESS RESOLUTION ---------- + chosen_address = None + if "address_index" in data: + idx = data["address_index"] + 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] + except Exception: + return Response( + {"detail": "Invalid address_index."}, + status=status.HTTP_400_BAD_REQUEST, + ) + elif isinstance(ua, dict) and idx == 0: + chosen_address = ua + else: + return Response( + {"detail": "Invalid address selection."}, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + chosen_address = data.get("address") + + # -------- ITEMS TOTAL ---------- + items_total = Decimal("0.00") + for item in cart.items.all(): + items_total += Decimal(item.unit_price) * item.quantity + + # -------- TECHNICIAN HANDLING ---------- + technician = None + technician_fee = Decimal("0.00") + booking_obj = None + + if data.get("technician_id"): + technician = get_object_or_404(Profile, pk=data["technician_id"]) + + start = data["date_time_start"] + end = data["date_time_end"] + delta = end - start + + 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(technician.price_hour) * hours + + technician_fee = technician_fee.quantize(Decimal("0.01")) + + # -------- TOTAL ---------- + total = (items_total + technician_fee).quantize(Decimal("0.01")) + + # -------- CREATE ORDER ---------- + order = Order.objects.create( + user=request.user, + cart=cart, + total=total, + address=chosen_address, + payment_method=data["payment_method"], + payment_done=data.get("payment_done", False), + technician=technician, + technician_fee=technician_fee, + notes=data.get("notes", ""), + ) + + # -------- ORDER ITEMS ---------- + for item in cart.items.all(): + OrderItem.objects.create( + order=order, + product=item.product, + unit_price=item.unit_price, + quantity=item.quantity, + line_total=(Decimal(item.unit_price) * Decimal(item.quantity)).quantize( + Decimal("0.01") + ), + ) + + # -------- BOOKING ---------- + if technician: + booking_obj = Booking.objects.create( + technician=technician, + user=request.user, + date_time_start=data["date_time_start"], + date_time_end=data["date_time_end"], + address=chosen_address, + payment_done=order.payment_done, + price=technician_fee, + service_status="pending", + ) + + order.booking = booking_obj + order.save() + + Notification.objects.create( + recipient=technician, + title=f"New booking request (Order {order.pk})", + message=(f"You have a new booking from {request.user.username}."), + metadata={ + "order_id": order.pk, + "booking_id": booking_obj.pk, + "user_id": request.user.pk, + }, + ) + + # -------- FINALIZE CART ---------- + cart.is_active = False + cart.save() + + 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( + CheckoutResponseSerializer(response_data).data, + status=status.HTTP_201_CREATED, + ) + + +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) + + if booking.service_status != "completed": + return Response( + {"error": "You can review only completed services."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if TechnicianReview.objects.filter(booking=booking).exists(): + return Response( + {"error": "You already reviewed this service."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + review = serializer.save( + technician=booking.technician, user=user, booking=booking + ) + + return Response( + self.get_serializer(review).data, status=status.HTTP_201_CREATED + )