From a4e46dc22c23635ce508d11db45dac472f558bd5 Mon Sep 17 00:00:00 2001 From: vickytechkey Date: Sun, 8 Nov 2026 17:09:07 +0530 Subject: [PATCH] feat: add sellers app, database router, serializers, views, and frontend pages --- backend/crm/db_router.py | 8 +- backend/sellers/models.py | 11 + backend/sellers/serializers.py | 19 +- backend/sellers/views.py | 76 ++++- backend/test_crm_seller_products.py | 307 ++++++++++++++++++ .../routes/(app)/sellers/[id]/+page.server.js | 2 +- .../routes/(app)/sellers/[id]/+page.svelte | 33 +- 7 files changed, 422 insertions(+), 34 deletions(-) create mode 100644 backend/test_crm_seller_products.py diff --git a/backend/crm/db_router.py b/backend/crm/db_router.py index 677f3c6c..07f3a10a 100644 --- a/backend/crm/db_router.py +++ b/backend/crm/db_router.py @@ -10,9 +10,9 @@ class CRMDatabaseRouter: app = model._meta.app_label name = model._meta.model_name if app in ["sellers", "seller_models"]: - if name == "product": + if name in ["product", "bulkuploadlog"]: return "product_db" - if name in ["wallet", "wallettransaction", "order", "returnrequest"]: + if name in ["wallet", "wallettransaction", "order", "sellerorder", "returnrequest"]: return "customer_db" return "seller_db" if app in ["customers", "customer_models"]: @@ -23,9 +23,9 @@ class CRMDatabaseRouter: app = model._meta.app_label name = model._meta.model_name if app in ["sellers", "seller_models"]: - if name == "product": + if name in ["product", "bulkuploadlog"]: return "product_db" - if name in ["wallet", "wallettransaction", "order", "returnrequest"]: + if name in ["wallet", "wallettransaction", "order", "sellerorder", "returnrequest"]: return "customer_db" return "seller_db" if app in ["customers", "customer_models"]: diff --git a/backend/sellers/models.py b/backend/sellers/models.py index 32102c7c..65ac8042 100644 --- a/backend/sellers/models.py +++ b/backend/sellers/models.py @@ -106,7 +106,18 @@ class Product(models.Model): compare_at_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) description = models.TextField(blank=True, null=True) is_gi_tagged = models.BooleanField(default=False) + gi_tag_name = models.CharField(max_length=200, blank=True, null=True) + is_odop = models.BooleanField(default=False) + odop_district = models.CharField(max_length=200, blank=True, null=True) + is_sustainable = models.BooleanField(default=False) + sustainable_type = models.CharField(max_length=200, blank=True, null=True) + geographical_origin = models.CharField(max_length=255, blank=True, null=True) + statutory_certifications = models.TextField(blank=True, null=True) + provenance_story = models.TextField(blank=True, null=True) + coordinates = models.CharField(max_length=100, blank=True, null=True) + track_inventory = models.BooleanField(default=True) shipping_profile = models.CharField(max_length=100, default='Standard Fragile') + processing_days = models.IntegerField(default=3) package_weight = models.FloatField(default=1.0) upload_source = models.CharField(max_length=50, default='individual') rejection_reason = models.TextField(blank=True, null=True) diff --git a/backend/sellers/serializers.py b/backend/sellers/serializers.py index d41f9389..6793c8cc 100644 --- a/backend/sellers/serializers.py +++ b/backend/sellers/serializers.py @@ -53,7 +53,7 @@ class SupplierProfileListSerializer(serializers.ModelSerializer): def get_products_count(self, obj): try: - return Product.objects.filter(supplier_id=obj.id).count() + return Product.objects.filter(Q(supplier_id=obj.id) | Q(supplier_id=obj.user_id)).count() except Exception: return 0 @@ -165,13 +165,15 @@ class SupplierProfileDetailSerializer(serializers.ModelSerializer): def get_metrics(self, obj): try: + supplier_q = Q(supplier_id=obj.id) | Q(supplier_id=obj.user_id) + # 1. Total Sales from delivered or all accepted orders - orders_qs = SellerOrder.objects.filter(supplier_id=obj.id) + orders_qs = SellerOrder.objects.filter(supplier_q) sales_agg = orders_qs.exclude(status__in=['Cancelled', 'Rejected']).aggregate(total=Sum('total')) total_sales = float(sales_agg['total'] or 0.0) # 2. Total items in stock - products_qs = Product.objects.filter(supplier_id=obj.id) + products_qs = Product.objects.filter(supplier_q) stock_agg = products_qs.aggregate(total_stock=Sum('stock')) total_stock = int(stock_agg['total_stock'] or 0) @@ -183,7 +185,7 @@ class SupplierProfileDetailSerializer(serializers.ModelSerializer): ).count() # 4. Total amount we need to pay to seller (outstanding wallet balance) - wallet = Wallet.objects.filter(supplier_id=obj.id).first() + wallet = Wallet.objects.filter(supplier_q).first() payable_amt = float(wallet.outstanding) if wallet else 0.0 return { @@ -214,22 +216,21 @@ class SupplierProfileDetailSerializer(serializers.ModelSerializer): def get_products(self, obj): try: - items = Product.objects.filter(supplier_id=obj.id).order_by("-id")[:100] + items = Product.objects.filter(Q(supplier_id=obj.id) | Q(supplier_id=obj.user_id)).order_by("-id")[:100] return ProductSerializer(items, many=True).data except Exception: return [] def get_bulk_upload_logs(self, obj): try: - # Query by user_id or supplier_id - logs = BulkUploadLog.objects.filter(supplier_id=obj.user_id).order_by("-id")[:20] + logs = BulkUploadLog.objects.filter(Q(supplier_id=obj.id) | Q(supplier_id=obj.user_id)).order_by("-id")[:20] return BulkUploadLogSerializer(logs, many=True).data except Exception: return [] def get_wallet(self, obj): try: - wallet = Wallet.objects.filter(supplier_id=obj.id).first() + wallet = Wallet.objects.filter(Q(supplier_id=obj.id) | Q(supplier_id=obj.user_id)).first() if wallet: return WalletSerializer(wallet).data except Exception: @@ -238,7 +239,7 @@ class SupplierProfileDetailSerializer(serializers.ModelSerializer): def get_orders(self, obj): try: - orders = SellerOrder.objects.filter(supplier_id=obj.id).order_by("-id")[:50] + orders = SellerOrder.objects.filter(Q(supplier_id=obj.id) | Q(supplier_id=obj.user_id)).order_by("-id")[:50] return SellerOrderSerializer(orders, many=True).data except Exception: return [] diff --git a/backend/sellers/views.py b/backend/sellers/views.py index ce4bf761..22c00fdd 100644 --- a/backend/sellers/views.py +++ b/backend/sellers/views.py @@ -295,9 +295,15 @@ class SellerProductApproveView(APIView): permission_classes = [IsAuthenticated] def post(self, request, pk, product_id): - try: - product = Product.objects.get(pk=product_id, supplier_id=pk) - except Product.DoesNotExist: + seller = SupplierProfile.objects.filter(pk=pk).first() + if not seller: + return Response({"detail": "Seller not found."}, status=status.HTTP_404_NOT_FOUND) + + product = Product.objects.filter( + models.Q(supplier_id=seller.id) | models.Q(supplier_id=seller.user_id), + pk=product_id + ).first() + if not product: return Response({"detail": "Product not found."}, status=status.HTTP_404_NOT_FOUND) product.status = "approved" @@ -316,9 +322,15 @@ class SellerProductRejectView(APIView): def post(self, request, pk, product_id): reason = request.data.get("reason", "Product does not comply with catalogue quality or listing policies.") - try: - product = Product.objects.get(pk=product_id, supplier_id=pk) - except Product.DoesNotExist: + seller = SupplierProfile.objects.filter(pk=pk).first() + if not seller: + return Response({"detail": "Seller not found."}, status=status.HTTP_404_NOT_FOUND) + + product = Product.objects.filter( + models.Q(supplier_id=seller.id) | models.Q(supplier_id=seller.user_id), + pk=product_id + ).first() + if not product: return Response({"detail": "Product not found."}, status=status.HTTP_404_NOT_FOUND) product.status = "rejected" @@ -337,16 +349,28 @@ class SellerProductDetailView(APIView): permission_classes = [IsAuthenticated] def get(self, request, pk, product_id): - try: - product = Product.objects.get(pk=product_id, supplier_id=pk) - except Product.DoesNotExist: + seller = SupplierProfile.objects.filter(pk=pk).first() + if not seller: + return Response({"detail": "Seller not found."}, status=status.HTTP_404_NOT_FOUND) + + product = Product.objects.filter( + models.Q(supplier_id=seller.id) | models.Q(supplier_id=seller.user_id), + pk=product_id + ).first() + if not product: return Response({"detail": "Product not found."}, status=status.HTTP_404_NOT_FOUND) return Response(ProductSerializer(product).data) def patch(self, request, pk, product_id): - try: - product = Product.objects.get(pk=product_id, supplier_id=pk) - except Product.DoesNotExist: + seller = SupplierProfile.objects.filter(pk=pk).first() + if not seller: + return Response({"detail": "Seller not found."}, status=status.HTTP_404_NOT_FOUND) + + product = Product.objects.filter( + models.Q(supplier_id=seller.id) | models.Q(supplier_id=seller.user_id), + pk=product_id + ).first() + if not product: return Response({"detail": "Product not found."}, status=status.HTTP_404_NOT_FOUND) updatable_fields = [ @@ -358,6 +382,15 @@ class SellerProductDetailView(APIView): "sku", "description", "is_gi_tagged", + "gi_tag_name", + "is_odop", + "odop_district", + "is_sustainable", + "sustainable_type", + "geographical_origin", + "statutory_certifications", + "provenance_story", + "coordinates", "is_active", "status", "rejection_reason", @@ -381,9 +414,15 @@ class SellerProductToggleView(APIView): permission_classes = [IsAuthenticated] def post(self, request, pk, product_id): - try: - product = Product.objects.get(pk=product_id, supplier_id=pk) - except Product.DoesNotExist: + seller = SupplierProfile.objects.filter(pk=pk).first() + if not seller: + return Response({"detail": "Seller not found."}, status=status.HTTP_404_NOT_FOUND) + + product = Product.objects.filter( + models.Q(supplier_id=seller.id) | models.Q(supplier_id=seller.user_id), + pk=product_id + ).first() + if not product: return Response({"detail": "Product not found."}, status=status.HTTP_404_NOT_FOUND) if "is_active" in request.data: @@ -403,8 +442,13 @@ class SellerOrderTrackView(APIView): permission_classes = [IsAuthenticated] def get(self, request, pk, order_id): + seller = SupplierProfile.objects.filter(pk=pk).first() + supplier_id_q = models.Q(supplier_id=pk) + if seller: + supplier_id_q = models.Q(supplier_id=seller.id) | models.Q(supplier_id=seller.user_id) + try: - order = SellerOrder.objects.filter(models.Q(order_id=order_id) | models.Q(id=order_id), supplier_id=pk).first() + order = SellerOrder.objects.filter(models.Q(order_id=order_id) | models.Q(id=order_id)).filter(supplier_id_q).first() if not order: # Also fallback to general order search order = SellerOrder.objects.filter(models.Q(order_id=order_id) | models.Q(id=order_id)).first() diff --git a/backend/test_crm_seller_products.py b/backend/test_crm_seller_products.py new file mode 100644 index 00000000..accac882 --- /dev/null +++ b/backend/test_crm_seller_products.py @@ -0,0 +1,307 @@ +import os +import sys +from unittest.mock import MagicMock + +# Mock celery before importing settings +sys.modules['celery'] = MagicMock() +sys.modules['celery.schedules'] = MagicMock() +sys.modules['django_celery_beat'] = MagicMock() +sys.modules['drf_spectacular'] = MagicMock() +sys.modules['drf_spectacular.extensions'] = MagicMock() + +sys.path.insert(0, 'D:/Tradhox_workspace/Django-CRM/backend') +os.environ['DJANGO_SETTINGS_MODULE'] = 'crm.settings' + +import django +from django.conf import settings + +# Override settings for standalone unit test +settings.DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + }, + 'seller_db': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + }, + 'product_db': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + }, + 'customer_db': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + }, +} +settings.REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [], + 'DEFAULT_PERMISSION_CLASSES': [], +} +settings.DATABASE_ROUTERS = ['crm.db_router.CRMDatabaseRouter'] +settings.AUTH_USER_MODEL = 'auth.User' +settings.INSTALLED_APPS = [ + 'django.contrib.contenttypes', + 'django.contrib.auth', + 'rest_framework', + 'sellers', +] + +django.setup() + +from sellers.models import SupplierProfile, Product, BulkUploadLog, SellerOrder, Wallet +from sellers.serializers import ( + SupplierProfileListSerializer, + SupplierProfileDetailSerializer, + ProductSerializer +) +from crm.db_router import CRMDatabaseRouter + +# 1. Test Router mappings +router = CRMDatabaseRouter() +assert router.db_for_read(Product) == 'product_db', f"Product should route to product_db, got {router.db_for_read(Product)}" +assert router.db_for_read(BulkUploadLog) == 'product_db', f"BulkUploadLog should route to product_db, got {router.db_for_read(BulkUploadLog)}" +assert router.db_for_read(SellerOrder) == 'customer_db', f"SellerOrder should route to customer_db, got {router.db_for_read(SellerOrder)}" +assert router.db_for_read(Wallet) == 'customer_db', f"Wallet should route to customer_db, got {router.db_for_read(Wallet)}" +assert router.db_for_read(SupplierProfile) == 'seller_db', f"SupplierProfile should route to seller_db, got {router.db_for_read(SupplierProfile)}" +print("CRMDatabaseRouter checks passed!") + +# 2. Create tables in test DBs +from django.db import connections + +def create_table_product(): + with connections['product_db'].cursor() as c: + c.execute(""" + CREATE TABLE api_product ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + supplier_id INTEGER, + title VARCHAR(255), + category VARCHAR(100), + price DECIMAL(10, 2), + stock INTEGER, + sku VARCHAR(100), + image TEXT, + is_active BOOLEAN, + status VARCHAR(50), + compare_at_price DECIMAL(10, 2), + description TEXT, + is_gi_tagged BOOLEAN, + gi_tag_name VARCHAR(200), + is_odop BOOLEAN, + odop_district VARCHAR(200), + is_sustainable BOOLEAN, + sustainable_type VARCHAR(200), + geographical_origin VARCHAR(255), + statutory_certifications TEXT, + provenance_story TEXT, + coordinates VARCHAR(100), + track_inventory BOOLEAN, + shipping_profile VARCHAR(100), + processing_days INTEGER, + package_weight REAL, + upload_source VARCHAR(50), + rejection_reason TEXT, + additional_images TEXT, + video_url VARCHAR(200), + view_360_url VARCHAR(200) + ) + """) + +def create_table_seller(): + with connections['seller_db'].cursor() as c: + c.execute(""" + CREATE TABLE api_supplierprofile ( + id INTEGER PRIMARY KEY, + user_id INTEGER UNIQUE, + phone VARCHAR(20), + phone_verified BOOLEAN, + email_verified BOOLEAN, + gstin VARCHAR(15), + is_gstin_verified BOOLEAN, + pan_number VARCHAR(20), + is_pan_verified BOOLEAN, + verified_pan_name VARCHAR(255), + verified_gst_name VARCHAR(255), + is_verified BOOLEAN, + business_type VARCHAR(50), + store_slug VARCHAR(255), + aadhar_file VARCHAR(255), + pan_file VARCHAR(255), + aadhar_s3_key VARCHAR(255), + pan_s3_key VARCHAR(255), + store_name VARCHAR(255), + store_logo TEXT, + logo_s3_key VARCHAR(255), + business_bio TEXT, + street VARCHAR(255), + city VARCHAR(100), + state VARCHAR(100), + pincode VARCHAR(20), + latitude REAL, + longitude REAL, + status VARCHAR(50), + submitted_at DATETIME, + approved_at DATETIME, + approved_by VARCHAR(150), + rejected_reason TEXT, + is_profile_complete BOOLEAN, + pickup_code VARCHAR(100), + account_holder_name VARCHAR(255), + account_number VARCHAR(50), + ifsc_code VARCHAR(20), + bank_name VARCHAR(255), + is_bank_verified BOOLEAN, + tax_mode VARCHAR(20) + ) + """) + +def create_table_customer(): + with connections['customer_db'].cursor() as c: + c.execute(""" + CREATE TABLE api_order ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id VARCHAR(100), + customer VARCHAR(255), + item VARCHAR(255), + quantity INTEGER, + total DECIMAL(12, 2), + status VARCHAR(50), + carrier VARCHAR(100), + tracking VARCHAR(100), + date DATE, + supplier_id INTEGER, + payment_mode VARCHAR(20), + eta VARCHAR(50), + expected_delivery_date VARCHAR(100), + pickup_id VARCHAR(100), + pickup_payload TEXT + ) + """) + c.execute(""" + CREATE TABLE api_wallet ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + supplier_id INTEGER UNIQUE, + outstanding DECIMAL(12, 2), + withdrawn DECIMAL(12, 2) + ) + """) + +create_table_product() +create_table_seller() +create_table_customer() + +# Create SupplierProfile with id=46, user_id=28 +seller = SupplierProfile.objects.create( + id=46, + user_id=28, + store_name="vignesh store", + phone="9600494039", + status="approved" +) + +# Create Product with supplier_id=28 (which is seller.user_id) +product = Product.objects.create( + id=20, + supplier_id=28, + title="Pure Kanchipuram Mulberry Silk Saree - Crimson Maroon & Gold Zari", + category="Clothing & Textiles", + price=14500.00, + stock=8, + sku="TRDX-KSS-960049", + is_gi_tagged=True, + gi_tag_name="Kancheepuram Silk (GI Application #26)", + is_odop=True, + odop_district="Kanchipuram, Tamil Nadu", + is_sustainable=True, + sustainable_type="Handloom Organic Mulberry Silk & Eco-dyes", + geographical_origin="Kanchipuram, Tamil Nadu, India", + statutory_certifications="GI Tag #26 Registered, Silk Mark Certified, Handloom Mark India", + status="pending", + is_active=True +) + +# Test SupplierProfileListSerializer +list_data = SupplierProfileListSerializer(seller).data +print(f"SupplierProfileListSerializer products_count: {list_data['products_count']}") +assert list_data['products_count'] == 1, f"Expected products_count 1, got {list_data['products_count']}" + +# Test SupplierProfileDetailSerializer +detail_data = SupplierProfileDetailSerializer(seller).data +print(f"Detail metrics: {detail_data['metrics']}") +assert detail_data['metrics']['total_products'] == 1, f"Expected 1 total_products, got {detail_data['metrics']['total_products']}" +assert detail_data['metrics']['total_items_in_stock'] == 8, f"Expected 8 items in stock, got {detail_data['metrics']['total_items_in_stock']}" +assert len(detail_data['products']) == 1, f"Expected 1 product in products list, got {len(detail_data['products'])}" + +prod_data = detail_data['products'][0] +print(f"Product in detail: ID={prod_data['id']}, title='{prod_data['title']}', is_gi_tagged={prod_data['is_gi_tagged']}, is_odop={prod_data['is_odop']}, is_sustainable={prod_data['is_sustainable']}") +assert prod_data['id'] == 20 +assert prod_data['is_gi_tagged'] is True +assert prod_data['is_odop'] is True +assert prod_data['is_sustainable'] is True + +print("ALL SERIALIZER AND MODEL TESTS PASSED SUCCESSFULLY!") + +# 3. Test Views logic (Approve, Reject, Toggle, Edit Content) +from sellers.views import ( + SellerProductApproveView, + SellerProductRejectView, + SellerProductToggleView, + SellerProductDetailView +) +from django.core.management import call_command +call_command('migrate', database='default', run_syncdb=True, verbosity=0) +from rest_framework.test import APIRequestFactory, force_authenticate +from django.contrib.auth.models import User + +factory = APIRequestFactory() +admin_user = User.objects.create_user(username='admin', email='admin@tradhox.com') + +# Test Approve +req = factory.post(f"/api/sellers/46/products/20/approve/") +force_authenticate(req, user=admin_user) +resp = SellerProductApproveView.as_view()(req, pk=46, product_id=20) +assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.data}" +p = Product.objects.using('product_db').get(pk=20) +assert p.status == 'approved' +assert p.is_active is True +print("SellerProductApproveView passed!") + +# Test Reject +req = factory.post(f"/api/sellers/46/products/20/reject/", {"reason": "Quality check required"}, format="json") +force_authenticate(req, user=admin_user) +resp = SellerProductRejectView.as_view()(req, pk=46, product_id=20) +assert resp.status_code == 200 +p = Product.objects.using('product_db').get(pk=20) +assert p.status == 'rejected' +assert p.is_active is False +assert p.rejection_reason == "Quality check required" +print("SellerProductRejectView passed!") + +# Test Toggle +req = factory.post(f"/api/sellers/46/products/20/toggle/", {"is_active": True, "status": "approved"}, format="json") +force_authenticate(req, user=admin_user) +resp = SellerProductToggleView.as_view()(req, pk=46, product_id=20) +assert resp.status_code == 200 +p = Product.objects.using('product_db').get(pk=20) +assert p.is_active is True +assert p.status == 'approved' +print("SellerProductToggleView passed!") + +# Test Detail PATCH +req = factory.patch(f"/api/sellers/46/products/20/", { + "price": 15200.00, + "stock": 12, + "is_odop": True, + "odop_district": "Kanchipuram District", + "geographical_origin": "Kanchipuram, Tamil Nadu" +}, format="json") +force_authenticate(req, user=admin_user) +resp = SellerProductDetailView.as_view()(req, pk=46, product_id=20) +assert resp.status_code == 200 +p = Product.objects.using('product_db').get(pk=20) +assert float(p.price) == 15200.00 +assert p.stock == 12 +assert p.odop_district == "Kanchipuram District" +print("SellerProductDetailView PATCH passed!") + +print("ALL VIEWS AND API ENDPOINT TESTS PASSED SUCCESSFULLY!") diff --git a/frontend/src/routes/(app)/sellers/[id]/+page.server.js b/frontend/src/routes/(app)/sellers/[id]/+page.server.js index cf424454..f6882064 100644 --- a/frontend/src/routes/(app)/sellers/[id]/+page.server.js +++ b/frontend/src/routes/(app)/sellers/[id]/+page.server.js @@ -165,7 +165,7 @@ export const actions = { const payload = {}; for (const [key, value] of data.entries()) { if (key !== 'product_id' && key !== 'action') { - if (key === 'is_gi_tagged' || key === 'is_active') { + if (key === 'is_gi_tagged' || key === 'is_active' || key === 'is_odop' || key === 'is_sustainable') { payload[key] = value === 'true'; } else if (key === 'price' || key === 'compare_at_price' || key === 'stock' || key === 'package_weight') { if (value !== '') payload[key] = Number(value); diff --git a/frontend/src/routes/(app)/sellers/[id]/+page.svelte b/frontend/src/routes/(app)/sellers/[id]/+page.svelte index 8f37a24a..434a1cd7 100644 --- a/frontend/src/routes/(app)/sellers/[id]/+page.svelte +++ b/frontend/src/routes/(app)/sellers/[id]/+page.svelte @@ -624,11 +624,17 @@ {/if}
-
+
{p.title} {#if p.is_gi_tagged} GI TAG {/if} + {#if p.is_odop} + ODOP + {/if} + {#if p.is_sustainable} + SUSTAINABLE + {/if}
SKU: {p.sku || '—'} · ID: #{p.id}
{#if p.rejection_reason} @@ -917,14 +923,33 @@
-
+
+
+ + +
+
+ + +
+
+ +
+ +