MukeshKapoor25 commited on
Commit
a24843c
Β·
1 Parent(s): 815556f

feat(catalogue): Add merchant-level catalogue ID generation and date field validators

Browse files

- Implement generate_merchant_catalogue_id method with sequential numbering format (MERCHANT_PREFIX-SEQUENTIAL_NUMBER)
- Add merchant_id-based catalogue ID generation with UUID fallback for robustness
- Add field validators to handle empty string to None conversion in gift_card_schema for updated_at field
- Add field validators to handle empty string to None conversion in promotion_schema for start_date and end_date fields
- Add field validators to handle empty string to None conversion in supplier_schema for date-related fields
- Add field validators to handle empty string to None conversion in taxonomy_schema for timestamp fields
- Add comprehensive test suite (test_catalogue_id_generation.py) to validate catalogue ID generation logic
- Improves data consistency by ensuring empty strings are properly converted to None across all schemas

app/models/catalogue_models.py CHANGED
@@ -27,10 +27,48 @@ UNCATEGORIZED_DEFAULT = "Uncategorized"
27
 
28
  class CatalogueModel:
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  @staticmethod
31
  async def create_catalogue_item(data: Dict[str, Any]) -> str:
32
  try:
33
- catalogue_id = str(uuid.uuid4())
 
 
 
 
 
 
34
  data['catalogue_id'] = catalogue_id
35
  data = convert_dates(data)
36
  result = await db['catalogues'].insert_one(data)
 
27
 
28
  class CatalogueModel:
29
 
30
+ @staticmethod
31
+ async def generate_merchant_catalogue_id(merchant_id: str) -> str:
32
+ """
33
+ Generate a simple, merchant-level catalogue ID.
34
+ Format: MERCHANT_PREFIX-SEQUENTIAL_NUMBER
35
+
36
+ Args:
37
+ merchant_id: The merchant identifier
38
+
39
+ Returns:
40
+ str: Generated catalogue ID (e.g., "MER123-00001")
41
+ """
42
+ try:
43
+ # Get the count of existing catalogues for this merchant
44
+ count = await db['catalogues'].count_documents({"merchant_id": merchant_id})
45
+
46
+ # Generate sequential ID with merchant prefix
47
+ merchant_prefix = merchant_id[:6].upper() if len(merchant_id) >= 6 else merchant_id.upper()
48
+ sequential_number = str(count + 1).zfill(5)
49
+ catalogue_id = f"{merchant_prefix}-{sequential_number}"
50
+
51
+ logger.info("Generated merchant catalogue ID", extra={
52
+ "merchant_id": merchant_id,
53
+ "catalogue_id": catalogue_id
54
+ })
55
+
56
+ return catalogue_id
57
+ except Exception as e:
58
+ logger.error("Error generating merchant catalogue ID", exc_info=e)
59
+ # Fallback to UUID if generation fails
60
+ return str(uuid.uuid4())
61
+
62
  @staticmethod
63
  async def create_catalogue_item(data: Dict[str, Any]) -> str:
64
  try:
65
+ merchant_id = data.get("merchant_id")
66
+ if merchant_id:
67
+ catalogue_id = await CatalogueModel.generate_merchant_catalogue_id(merchant_id)
68
+ else:
69
+ # Fallback to UUID if no merchant_id
70
+ catalogue_id = str(uuid.uuid4())
71
+
72
  data['catalogue_id'] = catalogue_id
73
  data = convert_dates(data)
74
  result = await db['catalogues'].insert_one(data)
app/schemas/gift_card_schema.py CHANGED
@@ -146,6 +146,13 @@ class GiftCardTemplateResponse(GiftCardTemplateBase):
146
  created_at: datetime = Field(..., description="Created timestamp")
147
  updated_at: Optional[datetime] = Field(None, description="Last modified timestamp")
148
 
 
 
 
 
 
 
 
149
  class Config:
150
  populate_by_name = True
151
  json_encoders = {
 
146
  created_at: datetime = Field(..., description="Created timestamp")
147
  updated_at: Optional[datetime] = Field(None, description="Last modified timestamp")
148
 
149
+ @field_validator('updated_at', mode='before')
150
+ @classmethod
151
+ def empty_str_to_none(cls, v):
152
+ if v == '' or v is None:
153
+ return None
154
+ return v
155
+
156
  class Config:
157
  populate_by_name = True
158
  json_encoders = {
app/schemas/promotion_schema.py CHANGED
@@ -29,6 +29,13 @@ class PromotionMetaData(BaseModel):
29
  max_discount: Optional[float]= Field(None, description="Cap on discount amount")
30
  start_date: datetime = Field(..., description="Start date of validity period (ISO 8601 format, e.g. YYYY-MM-DD)")
31
  end_date: datetime = Field(..., description="End date of validity period (ISO 8601 format, e.g. YYYY-MM-DD)")
 
 
 
 
 
 
 
32
  usage_limit: Optional[int] = Field(None, description="Total uses allowed")
33
  per_user_limit: Optional[int] = Field(0, description="Max uses per user")
34
  used_count: int = Field(0, description="Total times the coupon has been used")
@@ -44,7 +51,7 @@ class PromotionMetaData(BaseModel):
44
 
45
 
46
  class PromotionUpdate(BaseModel):
47
-
48
  title: Optional[str] = None
49
  description: Optional[str] = None
50
 
@@ -64,6 +71,13 @@ class PromotionUpdate(BaseModel):
64
  auto_apply: Optional[bool] = None
65
  stackable: Optional[bool] = None
66
 
 
 
 
 
 
 
 
67
  @field_validator("end_date")
68
  def check_end_date(cls, v, info):
69
  start_date = info.data.get("start_date")
 
29
  max_discount: Optional[float]= Field(None, description="Cap on discount amount")
30
  start_date: datetime = Field(..., description="Start date of validity period (ISO 8601 format, e.g. YYYY-MM-DD)")
31
  end_date: datetime = Field(..., description="End date of validity period (ISO 8601 format, e.g. YYYY-MM-DD)")
32
+
33
+ @field_validator('start_date', 'end_date', mode='before')
34
+ @classmethod
35
+ def empty_str_to_none_dates(cls, v):
36
+ if v == '' or v is None:
37
+ return None
38
+ return v
39
  usage_limit: Optional[int] = Field(None, description="Total uses allowed")
40
  per_user_limit: Optional[int] = Field(0, description="Max uses per user")
41
  used_count: int = Field(0, description="Total times the coupon has been used")
 
51
 
52
 
53
  class PromotionUpdate(BaseModel):
54
+
55
  title: Optional[str] = None
56
  description: Optional[str] = None
57
 
 
71
  auto_apply: Optional[bool] = None
72
  stackable: Optional[bool] = None
73
 
74
+ @field_validator('start_date', 'end_date', mode='before')
75
+ @classmethod
76
+ def empty_str_to_none(cls, v):
77
+ if v == '' or v is None:
78
+ return None
79
+ return v
80
+
81
  @field_validator("end_date")
82
  def check_end_date(cls, v, info):
83
  start_date = info.data.get("start_date")
app/schemas/supplier_schema.py CHANGED
@@ -1,7 +1,7 @@
1
  from datetime import date, datetime
2
  from typing import Any, Dict, List, Optional
3
  from fastapi import Query
4
- from pydantic import BaseModel, Field
5
 
6
 
7
  class Contact(BaseModel):
@@ -68,6 +68,12 @@ class Supplier(BaseModel):
68
  created_at: Optional[datetime] = Field(None, description="Creation timestamp")
69
  updated_at: Optional[datetime] = Field(None, description="Last update timestamp")
70
 
 
 
 
 
 
 
71
 
72
  class SupplierListFilter(BaseModel):
73
  filters: Optional[Dict[str, Any]] = None
 
1
  from datetime import date, datetime
2
  from typing import Any, Dict, List, Optional
3
  from fastapi import Query
4
+ from pydantic import BaseModel, Field, validator
5
 
6
 
7
  class Contact(BaseModel):
 
68
  created_at: Optional[datetime] = Field(None, description="Creation timestamp")
69
  updated_at: Optional[datetime] = Field(None, description="Last update timestamp")
70
 
71
+ @validator('associated_since', 'last_supplied_date', 'created_at', 'updated_at', pre=True)
72
+ def empty_str_to_none(cls, v):
73
+ if v == '' or v is None:
74
+ return None
75
+ return v
76
+
77
 
78
  class SupplierListFilter(BaseModel):
79
  filters: Optional[Dict[str, Any]] = None
app/schemas/taxonomy_schema.py CHANGED
@@ -1,6 +1,6 @@
1
  from datetime import datetime
2
  from typing import Dict, List, Literal, Optional
3
- from pydantic import BaseModel, Field
4
 
5
  class UOMConversionDetail(BaseModel):
6
  alt_uom: str = Field(..., description="Alternative unit of measurement")
@@ -34,6 +34,11 @@ class TaxonomyInfo(BaseModel):
34
  created_by: Optional[str] = Field(None, description="User who created this record")
35
  created_at: Optional[datetime] = Field(default_factory=datetime.utcnow, description="Creation timestamp")
36
  updated_at: Optional[datetime] = Field(default_factory=datetime.utcnow, description="Last update timestamp")
 
 
 
 
 
 
37
 
38
 
39
-
 
1
  from datetime import datetime
2
  from typing import Dict, List, Literal, Optional
3
+ from pydantic import BaseModel, Field, validator
4
 
5
  class UOMConversionDetail(BaseModel):
6
  alt_uom: str = Field(..., description="Alternative unit of measurement")
 
34
  created_by: Optional[str] = Field(None, description="User who created this record")
35
  created_at: Optional[datetime] = Field(default_factory=datetime.utcnow, description="Creation timestamp")
36
  updated_at: Optional[datetime] = Field(default_factory=datetime.utcnow, description="Last update timestamp")
37
+
38
+ @validator('created_at', 'updated_at', pre=True)
39
+ def empty_str_to_none(cls, v):
40
+ if v == '' or v is None:
41
+ return None
42
+ return v
43
 
44
 
 
test_catalogue_id_generation.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test script for the new merchant-level catalogue ID generation.
3
+ This tests the generate_merchant_catalogue_id method.
4
+ """
5
+ import asyncio
6
+ import sys
7
+ import os
8
+ from unittest.mock import AsyncMock, MagicMock, patch, Mock
9
+
10
+ # Add the parent directory to path
11
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
12
+
13
+ async def test_catalogue_id_generation():
14
+ """Test the new catalogue ID generation logic"""
15
+
16
+ # Mock all the dependencies before importing
17
+ mock_modules = {
18
+ 'insightfy_utils': Mock(),
19
+ 'insightfy_utils.logging': Mock(),
20
+ 'app.constants': Mock(),
21
+ 'app.constants.collections': Mock(),
22
+ 'app.repositories': Mock(),
23
+ 'app.repositories.db': Mock(),
24
+ 'app.repositories.inventory_repository': Mock(),
25
+ 'app.schemas': Mock(),
26
+ 'app.schemas.catalogue_schema': Mock(),
27
+ 'app.schemas.inventory_schema': Mock(),
28
+ 'app.utils': Mock(),
29
+ 'app.utils.catalogue_utils': Mock(),
30
+ 'app.utils.info_widget_utils': Mock(),
31
+ 'app.sql': Mock(),
32
+ }
33
+
34
+ for name, mock_module in mock_modules.items():
35
+ sys.modules[name] = mock_module
36
+
37
+ # Mock get_logger
38
+ sys.modules['insightfy_utils.logging'].get_logger = Mock(return_value=Mock())
39
+
40
+ # Import after mocking
41
+ from app.models.catalogue_models import CatalogueModel
42
+
43
+ print("=" * 60)
44
+ print("Testing Merchant-Level Catalogue ID Generation")
45
+ print("=" * 60)
46
+
47
+ # Test Case 1: Normal merchant ID with existing catalogues
48
+ print("\n[Test 1] Merchant with 5 existing catalogues")
49
+ with patch('app.models.catalogue_models.db') as mock_db:
50
+ mock_db.__getitem__.return_value.count_documents = AsyncMock(return_value=5)
51
+
52
+ catalogue_id = await CatalogueModel.generate_merchant_catalogue_id("merchant123")
53
+ print(f" Merchant ID: merchant123")
54
+ print(f" Generated ID: {catalogue_id}")
55
+ print(f" Expected format: MERCHA-00006")
56
+ assert catalogue_id == "MERCHA-00006", f"Expected MERCHA-00006, got {catalogue_id}"
57
+ print(" βœ“ PASSED")
58
+
59
+ # Test Case 2: New merchant with no catalogues
60
+ print("\n[Test 2] New merchant with 0 catalogues")
61
+ with patch('app.models.catalogue_models.db') as mock_db:
62
+ mock_db.__getitem__.return_value.count_documents = AsyncMock(return_value=0)
63
+
64
+ catalogue_id = await CatalogueModel.generate_merchant_catalogue_id("newmerchant")
65
+ print(f" Merchant ID: newmerchant")
66
+ print(f" Generated ID: {catalogue_id}")
67
+ print(f" Expected format: NEWMER-00001")
68
+ assert catalogue_id == "NEWMER-00001", f"Expected NEWMER-00001, got {catalogue_id}"
69
+ print(" βœ“ PASSED")
70
+
71
+ # Test Case 3: Short merchant ID
72
+ print("\n[Test 3] Short merchant ID (less than 6 chars)")
73
+ with patch('app.models.catalogue_models.db') as mock_db:
74
+ mock_db.__getitem__.return_value.count_documents = AsyncMock(return_value=10)
75
+
76
+ catalogue_id = await CatalogueModel.generate_merchant_catalogue_id("abc")
77
+ print(f" Merchant ID: abc")
78
+ print(f" Generated ID: {catalogue_id}")
79
+ print(f" Expected format: ABC-00011")
80
+ assert catalogue_id == "ABC-00011", f"Expected ABC-00011, got {catalogue_id}"
81
+ print(" βœ“ PASSED")
82
+
83
+ # Test Case 4: Large number of catalogues (sequential numbering)
84
+ print("\n[Test 4] Merchant with 999 catalogues")
85
+ with patch('app.models.catalogue_models.db') as mock_db:
86
+ mock_db.__getitem__.return_value.count_documents = AsyncMock(return_value=999)
87
+
88
+ catalogue_id = await CatalogueModel.generate_merchant_catalogue_id("store456")
89
+ print(f" Merchant ID: store456")
90
+ print(f" Generated ID: {catalogue_id}")
91
+ print(f" Expected format: STORE4-01000")
92
+ assert catalogue_id == "STORE4-01000", f"Expected STORE4-01000, got {catalogue_id}"
93
+ print(" βœ“ PASSED")
94
+
95
+ # Test Case 5: Error handling - fallback to UUID
96
+ print("\n[Test 5] Error handling - should fallback to UUID")
97
+ with patch('app.models.catalogue_models.db') as mock_db:
98
+ mock_db.__getitem__.return_value.count_documents = AsyncMock(side_effect=Exception("DB Error"))
99
+
100
+ catalogue_id = await CatalogueModel.generate_merchant_catalogue_id("errormerchant")
101
+ print(f" Merchant ID: errormerchant")
102
+ print(f" Generated ID: {catalogue_id}")
103
+ print(f" Expected: UUID format (36 chars with dashes)")
104
+ assert len(catalogue_id) == 36 and catalogue_id.count('-') == 4, "Should be UUID format"
105
+ print(" βœ“ PASSED - Fallback to UUID works")
106
+
107
+ # Test Case 6: Integration with create_catalogue_item
108
+ print("\n[Test 6] Integration with create_catalogue_item")
109
+ with patch('app.models.catalogue_models.db') as mock_db, \
110
+ patch('app.models.catalogue_models.convert_dates') as mock_convert:
111
+
112
+ mock_db.__getitem__.return_value.count_documents = AsyncMock(return_value=42)
113
+ mock_convert.side_effect = lambda x: x
114
+
115
+ # Mock insert_one to return a result with inserted_id
116
+ mock_result = MagicMock()
117
+ mock_result.inserted_id = "mongo_object_id_123"
118
+ mock_db.__getitem__.return_value.insert_one = AsyncMock(return_value=mock_result)
119
+
120
+ test_data = {
121
+ "merchant_id": "testmerch",
122
+ "catalogue_name": "Test Product",
123
+ "catalogue_type": "Product",
124
+ "retail_price": 100.0
125
+ }
126
+
127
+ result = await CatalogueModel.create_catalogue_item(test_data)
128
+ print(f" Merchant ID: testmerch")
129
+ print(f" Catalogue ID in data: {test_data.get('catalogue_id')}")
130
+ print(f" Expected format: TESTME-00043")
131
+ assert test_data['catalogue_id'] == "TESTME-00043", f"Expected TESTME-00043, got {test_data['catalogue_id']}"
132
+ print(" βœ“ PASSED - create_catalogue_item uses new ID generation")
133
+
134
+ print("\n" + "=" * 60)
135
+ print("All Tests PASSED! βœ“")
136
+ print("=" * 60)
137
+ print("\nSummary:")
138
+ print(" β€’ Sequential numbering works correctly")
139
+ print(" β€’ Merchant prefix extraction works")
140
+ print(" β€’ Error handling with UUID fallback works")
141
+ print(" β€’ Integration with create_catalogue_item works")
142
+ print("\nThe new merchant-level catalogue ID generation is working as expected!")
143
+
144
+ if __name__ == "__main__":
145
+ asyncio.run(test_catalogue_id_generation())