feat: add GI tag, ODOP, and sustainability fields to product model and API
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 46s

This commit is contained in:
vickytechkey 2026-11-08 15:46:55 +05:30
parent b0f98e5702
commit df3bccda14
5 changed files with 402 additions and 40 deletions

View file

@ -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),
),
]

View file

@ -341,6 +341,11 @@ class Product(models.Model):
# New heritage fields from UI designs # New heritage fields from UI designs
description = models.TextField(blank=True, null=True) description = models.TextField(blank=True, null=True)
is_gi_tagged = models.BooleanField(default=False) 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) additional_images = models.JSONField(default=list, blank=True, null=True)
video_url = models.URLField(blank=True, null=True) video_url = models.URLField(blank=True, null=True)
view_360_url = models.URLField(blank=True, null=True) view_360_url = models.URLField(blank=True, null=True)

View file

@ -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

View file

@ -1298,37 +1298,25 @@ class BulkUploadView(APIView):
rows_skipped = 0 rows_skipped = 0
for idx, row in enumerate(products_data, start=1): for idx, row in enumerate(products_data, start=1):
title = ( norm_map = {str(k).strip().lower().replace(' ', '_').replace('-', '_'): v for k, v in row.items() if k is not None}
row.get('Title') or row.get('title') or row.get('Product Title') or def get_fld(*keys, default=''):
row.get('Product Name') or row.get('Item Name') or row.get('Name') or for k in keys:
row.get('name') or row.get('Product') or row.get('product') if k in row and row[k] is not None:
) return row[k]
sku = ( k_norm = str(k).strip().lower().replace(' ', '_').replace('-', '_')
row.get('SKU') or row.get('sku') or row.get('Item Code') or if k_norm in norm_map and norm_map[k_norm] is not None:
row.get('Product Code') or row.get('Code') or row.get('code') or return norm_map[k_norm]
row.get('SKU Code') or row.get('sku_code') return default
)
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 ''
)
raw_price = ( title = get_fld('Title', 'title', 'Product Title', 'Product Name', 'Item Name', 'Name', 'Product', 'product')
row.get('Price') or row.get('Price (INR)') or row.get('price') or sku = get_fld('SKU', 'sku', 'Item Code', 'Product Code', 'Code', 'code', 'SKU Code', 'sku_code')
row.get('Selling Price') or row.get('Unit Price') or row.get('unit_price') or category = get_fld('Category', 'category', 'Product Category', 'category_name', default="Handicrafts & Art")
row.get('Rate') or row.get('rate') 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) price_val = clean_num_val(raw_price, default_val=0.0)
raw_stock = ( raw_stock = get_fld('Stock', 'stock', 'Quantity', 'qty', 'Qty', 'Inventory', 'inventory', 'Available 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')
)
stock_num = clean_num_val(raw_stock, default_val=0.0) stock_num = clean_num_val(raw_stock, default_val=0.0)
stock_val = int(stock_num) if stock_num >= 0 else -1 stock_val = int(stock_num) if stock_num >= 0 else -1
@ -1338,7 +1326,7 @@ class BulkUploadView(APIView):
row_errors.append("Missing Product Title") row_errors.append("Missing Product Title")
if not sku or not str(sku).strip(): if not sku or not str(sku).strip():
from .sku_generator import generate_product_sku from .sku_generator import generate_product_sku
sku = generate_product_sku(user, category_val) sku = generate_product_sku(user, category)
else: else:
sku = str(sku).strip().upper() sku = str(sku).strip().upper()
if price_val <= 0: if price_val <= 0:
@ -1363,27 +1351,66 @@ class BulkUploadView(APIView):
error_rows.append(err_dict) error_rows.append(err_dict)
continue continue
raw_mrp = ( raw_mrp = get_fld('MRP', 'mrp', 'Compare At Price', 'compare_at_price', 'Compare_At_Price')
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')
)
mrp_parsed = clean_num_val(raw_mrp, default_val=0.0) 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) 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_parsed = clean_num_val(raw_weight, default_val=1.0)
weight_val = weight_parsed if weight_parsed > 0 else 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() def parse_bool(v):
is_gi_tagged = gi_str in ('true', 'yes', '1', 'y') 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 # Determine image URL
image_url = ( image_url = (
images_map.get(sku) or images_map.get(sku.upper()) or 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 get_fld('Image URL', 'Image_URL', 'image_url', 'image', 'Image', 'Image Link', 'image_link')
row.get('image') or row.get('Image') or row.get('Image Link') or row.get('image_link')
) )
if not image_url: if not image_url:
image_url = 'https://images.unsplash.com/photo-1544022613-e87ca75a784a?auto=format&fit=crop&q=80&w=600' 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.image = image_url
existing_product.description = desc existing_product.description = desc
existing_product.is_gi_tagged = is_gi_tagged 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.package_weight = weight_val
existing_product.shipping_profile = shipping_profile existing_product.shipping_profile = shipping_profile
existing_product.upload_source = 'bulk' existing_product.upload_source = 'bulk'
@ -1422,6 +1459,14 @@ class BulkUploadView(APIView):
'image': image_url, 'image': image_url,
'description': desc, 'description': desc,
'is_gi_tagged': is_gi_tagged, '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, 'package_weight': weight_val,
'shipping_profile': shipping_profile, 'shipping_profile': shipping_profile,
'upload_source': 'bulk', 'upload_source': 'bulk',
@ -1446,7 +1491,12 @@ class BulkUploadView(APIView):
# 4. Generate Error CSV if any errors occurred # 4. Generate Error CSV if any errors occurred
if error_rows: if error_rows:
out_io = io.StringIO() 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 = csv.DictWriter(out_io, fieldnames=fnames, extrasaction='ignore')
writer.writeheader() writer.writeheader()
for er in error_rows: for er in error_rows:

View file

@ -1,3 +1,5 @@
[pytest] [pytest]
DJANGO_SETTINGS_MODULE = config.settings DJANGO_SETTINGS_MODULE = config.settings
python_files = tests.py test_*.py *_tests.py python_files = tests.py test_*.py *_tests.py
testpaths = api
addopts = -p no:cacheprovider