searchengine/api/typesense_client.py
2026-10-15 20:08:21 +05:30

176 lines
6.4 KiB
Python

import typesense
from django.conf import settings
import logging
logger = logging.getLogger(__name__)
PRODUCTS_SCHEMA = {
'name': 'products',
'fields': [
{'name': 'id', 'type': 'string'},
{'name': 'title', 'type': 'string'},
{'name': 'description', 'type': 'string', 'optional': True},
{'name': 'category', 'type': 'string', 'facet': True},
{'name': 'supplier_id', 'type': 'int32', 'facet': True},
{'name': 'supplier_name', 'type': 'string', 'optional': True},
{'name': 'price', 'type': 'float', 'facet': True},
{'name': 'compare_at_price', 'type': 'float', 'optional': True},
{'name': 'stock', 'type': 'int32'},
{'name': 'sku', 'type': 'string'},
{'name': 'image', 'type': 'string', 'optional': True},
{'name': 'is_gi_tagged', 'type': 'bool', 'facet': True},
{'name': 'is_active', 'type': 'bool', 'facet': True},
{'name': 'status', 'type': 'string', 'facet': True},
{'name': 'created_at', 'type': 'int64', 'optional': True},
],
'default_sorting_field': 'created_at'
}
def safe_str(val, default=""):
if val is None:
return default
return str(val)
def safe_int(val, default=0):
if val is None or val == "":
return default
try:
return int(val)
except (ValueError, TypeError):
return default
def safe_float(val, default=0.0):
if val is None or val == "":
return default
try:
return float(val)
except (ValueError, TypeError):
return default
def safe_bool(val, default=False):
if val is None:
return default
if isinstance(val, bool):
return val
if isinstance(val, (int, float)):
return bool(val)
if isinstance(val, str):
return val.lower() in ("true", "1", "yes")
return default
class TypesenseService:
_client = None
_schema_initialized = False
@classmethod
def get_client(cls):
if cls._client is None:
cfg = getattr(settings, 'TYPESENSE_CONFIG', {})
cls._client = typesense.Client({
'nodes': [{
'host': cfg.get('HOST', '127.0.0.1'),
'port': str(cfg.get('PORT', '8108')),
'protocol': cfg.get('PROTOCOL', 'http')
}],
'api_key': cfg.get('API_KEY', 'vignesh_typesense_master_key_2026'),
'connection_timeout_seconds': cfg.get('TIMEOUT', 5.0)
})
return cls._client
@classmethod
def ensure_schema(cls, force=False):
if cls._schema_initialized and not force:
return
client = cls.get_client()
try:
client.collections['products'].retrieve()
logger.info("Typesense 'products' collection already exists.")
cls._schema_initialized = True
except Exception:
logger.info("Creating Typesense 'products' collection schema...")
client.collections.create(PRODUCTS_SCHEMA)
cls._schema_initialized = True
@classmethod
def prepare_product_doc(cls, product_dict):
doc_id = product_dict.get('id')
if doc_id is None:
doc_id = product_dict.get('sku')
return {
'id': safe_str(doc_id),
'title': safe_str(product_dict.get('title')),
'description': safe_str(product_dict.get('description')),
'category': safe_str(product_dict.get('category'), 'Uncategorized'),
'supplier_id': safe_int(product_dict.get('supplier_id'), 0),
'supplier_name': safe_str(product_dict.get('supplier_name')),
'price': safe_float(product_dict.get('price'), 0.0),
'compare_at_price': safe_float(product_dict.get('compare_at_price'), 0.0),
'stock': safe_int(product_dict.get('stock'), 0),
'sku': safe_str(product_dict.get('sku')),
'image': safe_str(product_dict.get('image')),
'is_gi_tagged': safe_bool(product_dict.get('is_gi_tagged'), False),
'is_active': safe_bool(product_dict.get('is_active'), True),
'status': safe_str(product_dict.get('status'), 'active'),
'created_at': safe_int(product_dict.get('created_at'), 0)
}
@classmethod
def index_product(cls, product_dict):
cls.ensure_schema()
client = cls.get_client()
doc = cls.prepare_product_doc(product_dict)
return client.collections['products'].documents.upsert(doc)
@classmethod
def index_products_batch(cls, products_list):
if not products_list:
return []
cls.ensure_schema()
client = cls.get_client()
docs = [cls.prepare_product_doc(p) for p in products_list]
return client.collections['products'].documents.import_(docs, {'action': 'upsert'})
@classmethod
def delete_product(cls, product_id):
client = cls.get_client()
pid_str = str(product_id).strip()
try:
return client.collections['products'].documents[pid_str].delete()
except Exception as e:
logger.info(f"Direct delete by ID {pid_str} failed ({e}), attempting delete by SKU filter...")
try:
return client.collections['products'].documents.delete({'filter_by': f'sku:="{pid_str}"'})
except Exception as e_filter:
logger.warning(f"Could not delete document with identifier {pid_str} from Typesense: {e_filter}")
return None
@classmethod
def search_products(cls, query="*", filter_by="", sort_by="", page=1, per_page=20):
cls.ensure_schema()
client = cls.get_client()
q_clean = query.strip() if query else "*"
search_parameters = {
'q': q_clean if q_clean else '*',
'query_by': 'title,description,category,sku',
'facet_by': 'category,is_gi_tagged,supplier_id',
'page': page,
'per_page': per_page,
'num_typos': 2,
'prefix': True
}
if filter_by:
search_parameters['filter_by'] = filter_by
if sort_by:
search_parameters['sort_by'] = sort_by
else:
if q_clean and q_clean != '*':
search_parameters['sort_by'] = '_text_match:desc,created_at:desc'
else:
search_parameters['sort_by'] = 'created_at:desc'
return client.collections['products'].documents.search(search_parameters)