bookmyservice-mhs / app /models /merchant.py
MukeshKapoor25's picture
feat(security): Implement comprehensive input and log sanitization
a7c2198
from pydantic import BaseModel, Field, validator
from typing import Optional, List, Dict, Any, Tuple
from datetime import datetime
import re
CURRENT_DATE = datetime.now()
# Validation patterns
LOCATION_ID_PATTERN = re.compile(r'^[A-Z]{2}-[A-Z0-9]+$')
MERCHANT_ID_PATTERN = re.compile(r'^[a-zA-Z0-9_-]+$')
BUSINESS_NAME_PATTERN = re.compile(r'^[a-zA-Z0-9\s\-\.\&\']+$')
class RecommendedSearchQuery(BaseModel):
location_id: str = Field(..., description="Location ID (e.g., 'IN-SOUTH')")
city: Optional[str] = Field(None, description="City name for location")
radius: Optional[int] = Field(0, description="Search radius in meters")
latitude: Optional[float] = Field(
None, description="Latitude for geospatial search")
longitude: Optional[float] = Field(
None, description="Longitude for geospatial search")
merchant_category: Optional[str] = Field(
None, description="Category of the merchant")
average_rating: Optional[float] = Field(
None, description="Minimum average rating")
services: Optional[List[str]] = Field(
None, description="List of services offered by the merchant")
business_name: Optional[str] = Field(
None, description="Business name (case-insensitive search)")
free_text: Optional[str] = Field(
None, description="free text search (case-insensitive search)")
top_rated: Optional[bool] = Field(
False, description="Filter by top-rated merchants based on average rating")
popular: Optional[bool] = Field(
False, description="Filter by popular merchants based on total bookings")
trending: Optional[bool] = Field(
False, description="Filter by trending merchants")
offset: int = Field(0, description="Pagination offset")
limit: int = Field(10, description="Pagination limit")
class SearchQuery(BaseModel):
"""
Enhanced search filters for merchant discovery with input validation.
"""
location_id: str = Field(..., min_length=2, max_length=10, example="IN-SOUTH",
description="Location ID (e.g., 'IN-SOUTH')")
city: Optional[str] = Field(
None, min_length=2, max_length=50, description="City name for location")
latitude: Optional[float] = Field(
None, ge=-90, le=90, description="Latitude for geospatial search")
longitude: Optional[float] = Field(
None, ge=-180, le=180, description="Longitude for geospatial search")
radius: Optional[int] = Field(
5000, ge=100, le=50000, description="Search radius in meters (100m to 50km)")
free_text: Optional[str] = Field(
None, min_length=2, max_length=100, description="Free text search (case-insensitive)")
merchant_category: Optional[str] = Field(
None, min_length=2, max_length=50, description="Category of the merchant")
average_rating: Optional[float] = Field(
None, ge=0, le=5, description="Average rating filter (0-5)")
services: Optional[List[str]] = Field(
None, max_items=10, description="List of services offered by the merchant")
business_name: Optional[str] = Field(
None, min_length=2, max_length=100, description="Business name")
top_rated: bool = Field(False, description="Filter top-rated merchants")
popular: bool = Field(False, description="Filter popular merchants")
trending: bool = Field(False, description="Filter trending merchants")
recommended: bool = Field(
False, description="Filter recommended merchants")
price_low_to_high: Optional[bool] = Field(
False, description="Sort merchants by price (low to high)")
rating_highest_first: Optional[bool] = Field(
False, description="Sort merchants by highest rating first")
distance_nearest_first: Optional[bool] = Field(
False, description="Sort merchants by nearest location")
popularity: Optional[bool] = Field(
False, description="Sort merchants by popularity")
offset: int = Field(0, ge=0, description="Pagination offset")
limit: int = Field(
10, ge=1, le=100, description="Items per page (max 100)")
sort_by: Optional[str] = Field(
None, description="Sort by: rating|price|distance|recommended|popularity")
sort_order: Optional[str] = Field(
"desc", description="Sort order: asc|desc")
@validator('location_id')
def validate_location_id(cls, v):
if not LOCATION_ID_PATTERN.match(v.upper()):
raise ValueError(
'Invalid location ID format. Expected format: XX-XXXXX')
return v.upper()
@validator('business_name')
def validate_business_name(cls, v):
if v and not BUSINESS_NAME_PATTERN.match(v):
raise ValueError('Business name contains invalid characters')
return v
@validator('services')
def validate_services(cls, v):
if v:
for service in v:
if not isinstance(service, str) or len(service.strip()) == 0:
raise ValueError('All services must be non-empty strings')
if len(service) > 50:
raise ValueError(
'Service name too long (max 50 characters)')
return v
@validator('free_text')
def validate_free_text(cls, v):
if v:
# Check for potentially dangerous patterns
dangerous_patterns = ['<script',
'javascript:', 'eval(', 'function(']
for pattern in dangerous_patterns:
if pattern.lower() in v.lower():
raise ValueError(
'Free text contains potentially dangerous content')
return v
@validator('sort_by', pre=True, always=True)
def validate_sort_by(cls, v):
if v is None:
return None
valid_options = ['rating', 'price',
'distance', 'recommended', 'popularity']
if v not in valid_options:
raise ValueError(
f"Invalid sort_by value. Choose from {valid_options}")
return v
@validator('sort_order', pre=True, always=True)
def validate_sort_order(cls, v):
if v is None:
return "desc" # Default value
if not isinstance(v, str): # Ensure it's a string
v = str(v)
v = v.lower()
if v not in ['asc', 'desc']:
raise ValueError(
"Invalid sort_order value. Choose 'asc' or 'desc'.")
return v
@validator('latitude')
def validate_latitude(cls, v, values):
if values.get('sort_by') == 'distance':
if v is not None and values.get('longitude') is None:
raise ValueError(
"Longitude must be provided when latitude is set for recommended sort")
return v
@validator('longitude')
def validate_longitude(cls, v, values):
if values.get('sort_by') == 'distance':
if v is not None and values.get('latitude') is None:
raise ValueError(
"Latitude must be provided when longitude is set for recommended sort")
return v
class Config:
json_schema_extra = {
"example": {
"location_id": "IN-SOUTH",
"latitude": 13.0827,
"longitude": 80.2707,
"radius": 5000,
"free_text": "hair styling",
"merchant_category": "salon",
"average_rating": 4.5,
"services": ["haircut", "coloring", "styling"],
"business_name": "Style Studio",
"top_rated": True,
"popular": False,
"trending": True,
"sort_by": "rating",
"sort_order": "desc",
"offset": 0,
"limit": 10
}
}
class GeoLocation(BaseModel):
latitude: Optional[float] = Field(None, ge=-90, le=90, example=13.0827)
longitude: Optional[float] = Field(None, ge=-180, le=180, example=80.2707)
radius: Optional[int] = Field(None, ge=100, le=50000, example=5000)
class NewSearchQuery(BaseModel):
location_id: str = Field(..., min_length=2,
max_length=10, example="IN-SOUTH")
city: str = Field(..., min_length=2, max_length=50, example="Chennai")
merchant_category: Optional[str] = Field(
None, min_length=2, max_length=50, example="salon")
merchant_subcategory: Optional[str] = Field(
None, min_length=2, max_length=50, example="Womens Only")
geo: Optional[GeoLocation] = None
free_text: Optional[str] = Field(
None, min_length=2, max_length=100, example="hair styling")
business_name: Optional[str] = Field(
None, min_length=2, max_length=100, example="Style Studio")
top_rated: bool = Field(False, description="Filter top-rated merchants")
popular: bool = Field(False, description="Filter popular merchants")
trending: bool = Field(False, description="Filter trending merchants")
recommended: bool = Field(
False, description="Filter recommended merchants")
average_rating: Optional[float] = Field(None, ge=2, le=5, example=4.5)
price_range: Optional[Tuple[int, int]] = Field(None, example=[100, 500])
availability: Optional[List[str]] = Field(
default=[], example=["now", "early"])
amenities: Optional[List[str]] = Field(
default=[], example=["Wi-Fi", "Parking"])
# sort_by: Optional[str] = Field(None, example="price")
sort_by: Optional[str] = Field(
None, description="Sort by: rating|price|distance|recommended|popularity|recent")
sort_order: Optional[str] = Field("asc", example="asc")
offset: int = Field(0, ge=0)
limit: int = Field(10, ge=1, le=100)
class Config:
json_schema_extra = {
"example": {
"location_id": "IN-SOUTH",
"city": "Chennai",
"merchant_category": "salon",
"merchant_subcategory": "Womens Only",
"geo": {
"latitude": 13.0827,
"longitude": 80.2707,
"radius": 5000
},
"free_text": "Top salon near me",
"business_name": "Style Studio",
"top_rated": True,
"popular": False,
"trending": False,
"recommended": True,
"average_rating": 4.5,
"price_range": [100, 500],
"availability": ["now", "early", "late"],
"amenities": ["Wi-Fi", "Parking"],
"sort_by": "price",
"sort_order": "asc",
"offset": 1,
"limit": 10
}
}
class PaginationMeta(BaseModel):
total: int # Total number of merchants matching the search criteria
offset: int # Current pagination offset
limit: int # Number of merchants returned in the current response
has_more: bool # Indicates if there are more merchants to load
class CategorizedService(BaseModel):
# Unique category ID (e.g., 'top_rated', 'popular', 'trending', 'default')
id: str
title: str # Human-readable title of the category
# List of merchants and their details in the category
services: List[Dict[str, Any]]
class CategorizedMerchantResponse(BaseModel):
pagination: PaginationMeta
data: List[CategorizedService]
class MerchantCatalogueResponse(BaseModel):
merchant_id: str
business_name: str
location_id: str
catalogue: List[Dict[str, Any]]
class AssociateRequest(BaseModel):
merchant_id: str
location_id: str
class AssociateResponse(BaseModel):
merchant_id: str
business_name: str
location_id: str
associate: List[Dict[str, Any]]
COMMON_FIELDS = {
"_id": 0,
"merchant_id": 1,
"business_name": 1,
"business_url": 1,
"description": 1,
"display_picture": 1,
"profile_picture": {"$arrayElemAt": ["$display_picture", 0]},
"average_rating": 1,
"city": 1,
"country": 1,
"merchant_category": 1,
"merchant_subcategory": 1,
"address": 1,
"business_hour": 1,
"promotions": 1,
"trending": 1,
"amenities": 1,
"cancellation_policy": 1,
"share_description": 1,
"years_in_business": {
"$switch": {
"branches": [
{
"case": {
"$gte": [
{"$subtract": [CURRENT_DATE.year, {
"$year": "$available_from"}]}, 1
]
},
"then": {
"$concat": [
{"$toString": {"$subtract": [
CURRENT_DATE.year, {"$year": "$available_from"}]}},
" years in business"
]
}
},
{
"case": {
"$gte": [
{"$subtract": [CURRENT_DATE.month, {
"$month": "$available_from"}]}, 1
]
},
"then": {
"$concat": [
{"$toString": {"$subtract": [
CURRENT_DATE.month, {"$month": "$available_from"}]}},
" months in business"
]
}
}
],
"default": {
"$concat": [
{"$toString": {"$subtract": [CURRENT_DATE.day, {
"$dayOfMonth": "$available_from"}]}},
" days in business"
]
}
}
}
}
RECOMMENDED_FIELDS = {
"_id": 0,
"merchant_id": 1,
"address": 1,
"location_id": 1,
"business_name": 1,
"business_url": 1,
"description": 1,
"business_hour": 1,
"promotions": 1,
"cancellation_policy": 1,
"amenities": 1,
"display_picture": {"$arrayElemAt": ["$display_picture", 0]},
"profile_picture": {"$arrayElemAt": ["$display_picture", 0]},
"average_rating": "$average_rating.value",
"share_description": 1,
"city": 1,
"country": 1,
"merchant_category": 1,
"merchant_subcategory": 1,
"payment_modes": 1
}
MERCHANT_SCHEMA = {
"associate_projection": {
"input": "$associate",
"as": "s",
"in": {
"associate_id": "$$s.associate_id",
"name": "$$s.name",
"role": "$$s.role",
"experience": "$$s.experience",
"rating": "$$s.rating",
"photo": "$$s.photo",
},
},
"catalogue_projection": {
"input": {"$objectToArray": "$catalogue"},
"as": "cat",
"in": {
"category": "$$cat.k",
"services": {
"$map": {
"input": "$$cat.v",
"as": "service",
"in": {
"service_id": "$$service.service_id",
"service_name": "$$service.service_name",
"description": "$$service.description",
"price": {
# Rounds price to 2 decimal places
"$round": ["$$service.price", 2]
},
"currency": "$$service.currency",
"duration": "$$service.duration",
},
}
},
},
},
}
# Generalized time zone mapping
LOCATION_TIMEZONE_MAPPING = {
"IN": "Asia/Kolkata", # Single time zone for all Indian cities
"US": { # Varying time zones for US cities
"NYC": "America/New_York", # New York
"LAX": "America/Los_Angeles", # Los Angeles
"CHI": "America/Chicago", # Chicago
},
"UK": "Europe/London", # Single time zone for the UK
"SG": "Asia/Singapore", # Singapore
"MY": "Asia/Kuala_Lumpur", # Malaysia
"UAE": "Asia/Dubai", # United Arab Emirates
}