diff --git a/api/migrations/0020_product_gi_tag_name_product_is_odop_and_more.py b/api/migrations/0020_product_gi_tag_name_product_is_odop_and_more.py new file mode 100644 index 0000000..4dec2fe --- /dev/null +++ b/api/migrations/0020_product_gi_tag_name_product_is_odop_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 6.1 on 2026-11-08 09:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0019_supplieronboardingdraft_merchant_id_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='product', + name='gi_tag_name', + field=models.CharField(blank=True, max_length=200, null=True), + ), + migrations.AddField( + model_name='product', + name='is_odop', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='product', + name='is_sustainable', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='product', + name='odop_district', + field=models.CharField(blank=True, max_length=200, null=True), + ), + migrations.AddField( + model_name='product', + name='sustainable_type', + field=models.CharField(blank=True, max_length=200, null=True), + ), + ] diff --git a/api/models.py b/api/models.py index 6d835b3..1fbb992 100644 --- a/api/models.py +++ b/api/models.py @@ -341,6 +341,11 @@ class Product(models.Model): # New heritage fields from UI designs 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) additional_images = models.JSONField(default=list, blank=True, null=True) video_url = models.URLField(blank=True, null=True) view_360_url = models.URLField(blank=True, null=True) diff --git a/api/test_initiatives_upload.py b/api/test_initiatives_upload.py new file mode 100644 index 0000000..7cbb20d --- /dev/null +++ b/api/test_initiatives_upload.py @@ -0,0 +1,267 @@ +import io +import csv +from unittest.mock import patch +import pytest +from django.urls import reverse +from django.contrib.auth.models import User +from django.core.files.uploadedfile import SimpleUploadedFile +from rest_framework import status +from rest_framework.test import APIClient +from api.models import Product, SupplierProfile + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def seller_user(db): + user = User.objects.create_user( + username='artisan_initiatives_test', + email='artisan_test@tradhox.com', + password='TestPassword123!' + ) + SupplierProfile.objects.create( + user=user, + phone='9876543210', + pincode='560001', + is_profile_complete=True + ) + return user + + +@pytest.mark.django_db +class TestInitiativesUpload: + + def test_individual_upload_initiatives_creation(self, api_client, seller_user): + """Test individual product upload captures ODOP, GI, and Sustainable fields.""" + api_client.force_authenticate(user=seller_user) + url = reverse('product-list') + + payload = { + 'title': 'Kanchipuram Pure Mulberry Silk Saree', + 'sku': 'TRDX-KSS-INIT-001', + 'category': 'Clothing & Textiles', + 'price': 6500.00, + 'compare_at_price': 8500.00, + 'stock': 12, + 'description': 'Handloom silk saree with gold zari border.', + 'is_odop': True, + 'odop_district': 'Kanchipuram, Tamil Nadu', + 'is_gi_tagged': True, + 'gi_tag_name': 'Kancheepuram Silk (GI-12)', + 'is_sustainable': True, + 'sustainable_type': '100% Biodegradable & Natural Fibers', + 'package_weight': 0.8, + 'shipping_profile': 'Standard Fragile' + } + + with patch('api.views.shipping_client.is_pincode_pickup_serviceable', return_value=(True, 'OK')): + response = api_client.post(url, payload, format='json') + + assert response.status_code == status.HTTP_201_CREATED + assert response.data['is_odop'] is True + assert response.data['odop_district'] == 'Kanchipuram, Tamil Nadu' + assert response.data['is_gi_tagged'] is True + assert response.data['gi_tag_name'] == 'Kancheepuram Silk (GI-12)' + assert response.data['is_sustainable'] is True + assert response.data['sustainable_type'] == '100% Biodegradable & Natural Fibers' + + # Verify DB persistence + product = Product.objects.get(sku='TRDX-KSS-INIT-001') + assert product.is_odop is True + assert product.odop_district == 'Kanchipuram, Tamil Nadu' + assert product.is_gi_tagged is True + assert product.gi_tag_name == 'Kancheepuram Silk (GI-12)' + assert product.is_sustainable is True + assert product.sustainable_type == '100% Biodegradable & Natural Fibers' + + def test_individual_upload_initiatives_update(self, api_client, seller_user): + """Test updating ODOP, GI, and Sustainable attributes via PATCH.""" + api_client.force_authenticate(user=seller_user) + + product = Product.objects.create( + supplier=seller_user, + sku='TRDX-JBP-INIT-002', + title='Jaipur Ceramic Vase', + category='Handicrafts & Art', + price=1200.00, + stock=15, + is_odop=False, + is_gi_tagged=False, + is_sustainable=False + ) + + url = reverse('product-detail', kwargs={'pk': product.id}) + update_payload = { + 'is_odop': True, + 'odop_district': 'Jaipur, Rajasthan', + 'is_gi_tagged': True, + 'gi_tag_name': 'Jaipur Blue Pottery', + 'is_sustainable': True, + 'sustainable_type': 'Zero-Lead Eco Glaze' + } + + response = api_client.patch(url, update_payload, format='json') + assert response.status_code == status.HTTP_200_OK + + product.refresh_from_db() + assert product.is_odop is True + assert product.odop_district == 'Jaipur, Rajasthan' + assert product.is_gi_tagged is True + assert product.gi_tag_name == 'Jaipur Blue Pottery' + assert product.is_sustainable is True + assert product.sustainable_type == 'Zero-Lead Eco Glaze' + + def test_bulk_upload_initiatives_csv(self, api_client, seller_user): + """Test bulk CSV upload parses and persists ODOP, GI, and Sustainable fields.""" + api_client.force_authenticate(user=seller_user) + url = reverse('bulk-upload') + + csv_rows = [ + [ + 'Title', 'SKU', 'Category', 'Price', 'MRP', 'Stock', 'Description', + 'ODOP_Product', 'ODOP_District', 'GI_Tagged', 'GI_Tag_Name', + 'Sustainable_Product', 'Sustainable_Type', 'Package_Weight_Kg', + 'Shipping_Profile', 'Image_URL', 'Additional_Images', 'Video_URL' + ], + [ + 'Jaipur Blue Pottery Bowl', 'BULK-JBP-001', 'Handicrafts & Art', + '899', '1199', '25', 'Authentic handmade glazed bowl.', + 'Yes', 'Jaipur (Rajasthan)', 'Yes', 'Jaipur Blue Pottery', + 'Yes', 'Zero-Waste Upcycled Glaze', '1.1', + 'Fragile Handcrafted', 'https://example.com/cover.jpg', + 'https://example.com/side.jpg;https://example.com/top.jpg', + 'https://example.com/pottery_demo.mp4' + ], + [ + 'Saharanpur Wood Carved Coasters', 'BULK-SHM-002', 'Home & Living', + '450', '600', '40', 'Solid wood carving coasters.', + 'Yes', 'Saharanpur (Uttar Pradesh)', 'No', '', + 'Yes', 'Sustainably Harvested Wood', '0.5', + 'Standard Fragile', 'https://example.com/coasters.jpg', + '', '' + ] + ] + + out_io = io.StringIO() + writer = csv.writer(out_io) + for r in csv_rows: + writer.writerow(r) + + csv_file = SimpleUploadedFile( + 'initiatives_catalog.csv', + out_io.getvalue().encode('utf-8'), + content_type='text/csv' + ) + + with patch('api.views.shipping_client.is_pincode_pickup_serviceable', return_value=(True, 'OK')): + response = api_client.post(url, {'csv_file': csv_file}, format='multipart') + + assert response.status_code == status.HTTP_200_OK + assert response.data['summary']['created'] == 2 + assert response.data['summary']['skipped'] == 0 + + # Verify Item 1 (ODOP + GI + Sustainable + Media) + prod1 = Product.objects.get(sku='BULK-JBP-001') + assert prod1.is_odop is True + assert prod1.odop_district == 'Jaipur (Rajasthan)' + assert prod1.is_gi_tagged is True + assert prod1.gi_tag_name == 'Jaipur Blue Pottery' + assert prod1.is_sustainable is True + assert prod1.sustainable_type == 'Zero-Waste Upcycled Glaze' + assert prod1.additional_images == ['https://example.com/side.jpg', 'https://example.com/top.jpg'] + assert prod1.video_url == 'https://example.com/pottery_demo.mp4' + assert prod1.upload_source == 'bulk' + + # Verify Item 2 (ODOP + Sustainable, GI=False) + prod2 = Product.objects.get(sku='BULK-SHM-002') + assert prod2.is_odop is True + assert prod2.odop_district == 'Saharanpur (Uttar Pradesh)' + assert prod2.is_gi_tagged is False + assert prod2.gi_tag_name == '' + assert prod2.is_sustainable is True + assert prod2.sustainable_type == 'Sustainably Harvested Wood' + + def test_bulk_upload_column_aliases_and_case_insensitivity(self, api_client, seller_user): + """Test bulk upload gracefully accepts alternative header aliases and booleans.""" + api_client.force_authenticate(user=seller_user) + url = reverse('bulk-upload') + + # Using lowercase/alternate header keys + csv_rows = [ + ['title', 'sku', 'category', 'price', 'stock', 'odop', 'district', 'gi', 'gi_name', 'sustainable', 'sustainability_type'], + ['Channapatna Wooden Toy', 'BULK-CPT-003', 'Toys & Games', '350', '20', 'true', 'Ramanagara, Karnataka', '1', 'Channapatna Toys', 'yes', 'Non-Toxic Vegetable Dye'] + ] + + out_io = io.StringIO() + writer = csv.writer(out_io) + for r in csv_rows: + writer.writerow(r) + + csv_file = SimpleUploadedFile( + 'alias_catalog.csv', + out_io.getvalue().encode('utf-8'), + content_type='text/csv' + ) + + with patch('api.views.shipping_client.is_pincode_pickup_serviceable', return_value=(True, 'OK')): + response = api_client.post(url, {'csv_file': csv_file}, format='multipart') + + assert response.status_code == status.HTTP_200_OK + prod = Product.objects.get(sku='BULK-CPT-003') + assert prod.is_odop is True + assert prod.odop_district == 'Ramanagara, Karnataka' + assert prod.is_gi_tagged is True + assert prod.gi_tag_name == 'Channapatna Toys' + assert prod.is_sustainable is True + assert prod.sustainable_type == 'Non-Toxic Vegetable Dye' + + def test_bulk_upload_error_reporting_includes_initiatives(self, api_client, seller_user): + """Test when bulk row fails, the error CSV keeps initiative fields intact for review.""" + api_client.force_authenticate(user=seller_user) + url = reverse('bulk-upload') + + csv_rows = [ + [ + 'Title', 'SKU', 'Category', 'Price', 'MRP', 'Stock', 'Description', + 'ODOP_Product', 'ODOP_District', 'GI_Tagged', 'GI_Tag_Name', + 'Sustainable_Product', 'Sustainable_Type' + ], + [ + 'Invalid Price Item', 'BULK-ERR-001', 'Handicrafts & Art', + '-50', '100', '5', 'Negative price item.', + 'Yes', 'Moradabad', 'Yes', 'Moradabad Metal Craft', + 'Yes', 'Recycled Brass' + ] + ] + + out_io = io.StringIO() + writer = csv.writer(out_io) + for r in csv_rows: + writer.writerow(r) + + csv_file = SimpleUploadedFile( + 'error_catalog.csv', + out_io.getvalue().encode('utf-8'), + content_type='text/csv' + ) + + with patch('api.views.shipping_client.is_pincode_pickup_serviceable', return_value=(True, 'OK')): + response = api_client.post(url, {'csv_file': csv_file}, format='multipart') + + # When all rows fail validation, BulkUploadView returns HTTP 400 with error summary and error CSV + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data['summary']['skipped'] == 1 + assert response.data['error_csv'] is not None + + # Verify error CSV contains initiative columns + err_csv_text = response.data['error_csv'] + assert 'ODOP_Product' in err_csv_text + assert 'ODOP_District' in err_csv_text + assert 'GI_Tagged' in err_csv_text + assert 'GI_Tag_Name' in err_csv_text + assert 'Sustainable_Product' in err_csv_text + assert 'Sustainable_Type' in err_csv_text + assert 'Moradabad Metal Craft' in err_csv_text diff --git a/api/views.py b/api/views.py index 25cd6e2..8348e55 100644 --- a/api/views.py +++ b/api/views.py @@ -1298,37 +1298,25 @@ class BulkUploadView(APIView): rows_skipped = 0 for idx, row in enumerate(products_data, start=1): - title = ( - row.get('Title') or row.get('title') or row.get('Product Title') or - row.get('Product Name') or row.get('Item Name') or row.get('Name') or - row.get('name') or row.get('Product') or row.get('product') - ) - sku = ( - row.get('SKU') or row.get('sku') or row.get('Item Code') or - row.get('Product Code') or row.get('Code') or row.get('code') or - row.get('SKU Code') or row.get('sku_code') - ) - category = ( - row.get('Category') or row.get('category') or row.get('Product Category') or - row.get('category_name') or "Handicrafts & Art" - ) - desc = ( - row.get('Description') or row.get('description') or row.get('Story') or - row.get('desc') or row.get('Product Description') or '' - ) + norm_map = {str(k).strip().lower().replace(' ', '_').replace('-', '_'): v for k, v in row.items() if k is not None} + def get_fld(*keys, default=''): + for k in keys: + if k in row and row[k] is not None: + return row[k] + k_norm = str(k).strip().lower().replace(' ', '_').replace('-', '_') + if k_norm in norm_map and norm_map[k_norm] is not None: + return norm_map[k_norm] + return default - raw_price = ( - row.get('Price') or row.get('Price (INR)') or row.get('price') or - row.get('Selling Price') or row.get('Unit Price') or row.get('unit_price') or - row.get('Rate') or row.get('rate') - ) + title = get_fld('Title', 'title', 'Product Title', 'Product Name', 'Item Name', 'Name', 'Product', 'product') + sku = get_fld('SKU', 'sku', 'Item Code', 'Product Code', 'Code', 'code', 'SKU Code', 'sku_code') + category = get_fld('Category', 'category', 'Product Category', 'category_name', default="Handicrafts & Art") + desc = get_fld('Description', 'description', 'Story', 'desc', 'Product Description') + + raw_price = get_fld('Price', 'Price (INR)', 'price', 'Selling Price', 'Unit Price', 'unit_price', 'Rate', 'rate') price_val = clean_num_val(raw_price, default_val=0.0) - raw_stock = ( - row.get('Stock') or row.get('stock') or row.get('Quantity') or - row.get('qty') or row.get('Qty') or row.get('Inventory') or - row.get('inventory') or row.get('Available Stock') - ) + raw_stock = get_fld('Stock', 'stock', 'Quantity', 'qty', 'Qty', 'Inventory', 'inventory', 'Available Stock') stock_num = clean_num_val(raw_stock, default_val=0.0) stock_val = int(stock_num) if stock_num >= 0 else -1 @@ -1338,7 +1326,7 @@ class BulkUploadView(APIView): row_errors.append("Missing Product Title") if not sku or not str(sku).strip(): from .sku_generator import generate_product_sku - sku = generate_product_sku(user, category_val) + sku = generate_product_sku(user, category) else: sku = str(sku).strip().upper() if price_val <= 0: @@ -1363,27 +1351,66 @@ class BulkUploadView(APIView): error_rows.append(err_dict) continue - raw_mrp = ( - row.get('MRP') or row.get('mrp') or row.get('Compare At Price') or - row.get('compare_at_price') or row.get('Compare_At_Price') - ) + raw_mrp = get_fld('MRP', 'mrp', 'Compare At Price', 'compare_at_price', 'Compare_At_Price') mrp_parsed = clean_num_val(raw_mrp, default_val=0.0) compare_at_val = mrp_parsed if mrp_parsed > 0 else (price_val * 1.25) - raw_weight = row.get('Package_Weight_Kg') or row.get('package_weight') or row.get('Weight') or row.get('weight') + raw_weight = get_fld('Package_Weight_Kg', 'package_weight', 'Weight', 'weight', default='1.0') weight_parsed = clean_num_val(raw_weight, default_val=1.0) weight_val = weight_parsed if weight_parsed > 0 else 1.0 - gi_str = str(row.get('GI_Tagged') or row.get('is_gi_tagged') or row.get('GI Tagged') or '').strip().lower() - is_gi_tagged = gi_str in ('true', 'yes', '1', 'y') + def parse_bool(v): + if v is None: + return False + s = str(v).strip().lower() + return s in ('true', 'yes', '1', 'y', 't') - shipping_profile = row.get('Shipping_Profile') or row.get('shipping_profile') or 'Standard Fragile' + gi_val = get_fld('GI_Tagged', 'is_gi_tagged', 'GI Tagged', 'GI_Tag', 'GI Tag', 'gi_tagged', 'gi', 'GI') + is_gi_tagged = parse_bool(gi_val) + gi_tag_name = str( + get_fld('GI_Tag_Name', 'gi_tag_name', 'GI Name', 'GI_Name', 'gi_name', 'GI Certificate', 'gi_ref', 'GI_Ref') or '' + ).strip() + + odop_val = get_fld('ODOP_Product', 'is_odop', 'ODOP', 'odop', 'ODOP Product', 'isOdop', 'One District One Product') + is_odop = parse_bool(odop_val) + odop_district = str( + get_fld('ODOP_District', 'odop_district', 'ODOP District', 'District', 'district', 'Cluster', 'cluster') or '' + ).strip() + + sust_val = get_fld('Sustainable_Product', 'is_sustainable', 'Sustainable', 'sustainable', 'Sustainable Product', 'isSustainable', 'Eco Friendly') + is_sustainable = parse_bool(sust_val) + sustainable_type = str( + get_fld('Sustainable_Type', 'sustainable_type', 'Sustainability_Type', 'sustainability_type', 'Sustainable Type', 'Eco_Type') or '' + ).strip() + + additional_images_raw = get_fld('Additional_Images', 'additional_images', 'Gallery_Images', 'gallery_images', 'Supporting_Images') + additional_images = [] + if isinstance(additional_images_raw, list): + additional_images = additional_images_raw + elif isinstance(additional_images_raw, str) and additional_images_raw.strip(): + for delim in [';', '|', ',']: + if delim in additional_images_raw: + additional_images = [img.strip() for img in additional_images_raw.split(delim) if img.strip()] + break + if not additional_images and additional_images_raw.strip(): + additional_images = [additional_images_raw.strip()] + + video_url = str(get_fld('Video_URL', 'video_url', 'Video', 'video') or '').strip() or None + + raw_processing_days = get_fld('Processing_Days', 'processing_days', default=3) + try: + processing_days = int(clean_num_val(raw_processing_days, default_val=3)) + if processing_days <= 0: + processing_days = 3 + except: + processing_days = 3 + + shipping_profile = get_fld('Shipping_Profile', 'shipping_profile', default='Standard Fragile') # Determine image URL image_url = ( images_map.get(sku) or images_map.get(sku.upper()) or - row.get('Image URL') or row.get('Image_URL') or row.get('image_url') or - row.get('image') or row.get('Image') or row.get('Image Link') or row.get('image_link') + get_fld('Image URL', 'Image_URL', 'image_url', 'image', 'Image', 'Image Link', 'image_link') ) if not image_url: image_url = 'https://images.unsplash.com/photo-1544022613-e87ca75a784a?auto=format&fit=crop&q=80&w=600' @@ -1401,6 +1428,16 @@ class BulkUploadView(APIView): existing_product.image = image_url existing_product.description = desc existing_product.is_gi_tagged = is_gi_tagged + existing_product.gi_tag_name = gi_tag_name if is_gi_tagged else '' + existing_product.is_odop = is_odop + existing_product.odop_district = odop_district if is_odop else '' + existing_product.is_sustainable = is_sustainable + existing_product.sustainable_type = sustainable_type if is_sustainable else '' + if additional_images: + existing_product.additional_images = additional_images + if video_url: + existing_product.video_url = video_url + existing_product.processing_days = processing_days existing_product.package_weight = weight_val existing_product.shipping_profile = shipping_profile existing_product.upload_source = 'bulk' @@ -1422,6 +1459,14 @@ class BulkUploadView(APIView): 'image': image_url, 'description': desc, 'is_gi_tagged': is_gi_tagged, + 'gi_tag_name': gi_tag_name if is_gi_tagged else '', + 'is_odop': is_odop, + 'odop_district': odop_district if is_odop else '', + 'is_sustainable': is_sustainable, + 'sustainable_type': sustainable_type if is_sustainable else '', + 'additional_images': additional_images, + 'video_url': video_url, + 'processing_days': processing_days, 'package_weight': weight_val, 'shipping_profile': shipping_profile, 'upload_source': 'bulk', @@ -1446,7 +1491,12 @@ class BulkUploadView(APIView): # 4. Generate Error CSV if any errors occurred if error_rows: out_io = io.StringIO() - fnames = ['Row_Number', 'Title', 'SKU', 'Category', 'Price', 'MRP', 'Stock', 'Description', 'GI_Tagged', 'Package_Weight_Kg', 'Shipping_Profile', 'Error_Reason'] + fnames = [ + 'Row_Number', 'Title', 'SKU', 'Category', 'Price', 'MRP', 'Stock', + 'Description', 'ODOP_Product', 'ODOP_District', 'GI_Tagged', 'GI_Tag_Name', + 'Sustainable_Product', 'Sustainable_Type', 'Package_Weight_Kg', + 'Shipping_Profile', 'Image_URL', 'Additional_Images', 'Video_URL', 'Error_Reason' + ] writer = csv.DictWriter(out_io, fieldnames=fnames, extrasaction='ignore') writer.writeheader() for er in error_rows: diff --git a/pytest.ini b/pytest.ini index 7a4fb9b..0a5c919 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,5 @@ [pytest] DJANGO_SETTINGS_MODULE = config.settings python_files = tests.py test_*.py *_tests.py +testpaths = api +addopts = -p no:cacheprovider