Spaces:
Running
Running
| 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") | |
| 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() | |
| def validate_business_name(cls, v): | |
| if v and not BUSINESS_NAME_PATTERN.match(v): | |
| raise ValueError('Business name contains invalid characters') | |
| return v | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| } | |