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

175 lines
7.7 KiB
Python

from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from django.db import connection
from .typesense_client import TypesenseService
import logging
import time
logger = logging.getLogger(__name__)
class ProductSearchView(APIView):
def get(self, request):
try:
q = request.query_params.get('q', '*').strip()
category = request.query_params.get('category', '').strip()
min_price = request.query_params.get('min_price')
max_price = request.query_params.get('max_price')
is_gi_tagged = request.query_params.get('is_gi_tagged')
supplier_id = request.query_params.get('supplier_id')
sort_by_param = request.query_params.get('sort_by', '').strip()
try:
page = max(1, int(request.query_params.get('page', 1)))
per_page = min(250, max(1, int(request.query_params.get('per_page', 20))))
except (ValueError, TypeError):
return Response({'error': 'Invalid page or per_page parameter'}, status=status.HTTP_400_BAD_REQUEST)
filter_clauses = ['is_active:=true']
if category:
escaped_category = category.replace('"', '\\"')
filter_clauses.append(f'category:="{escaped_category}"')
if supplier_id:
try:
s_id = int(supplier_id)
filter_clauses.append(f'supplier_id:={s_id}')
except (ValueError, TypeError):
return Response({'error': 'supplier_id must be an integer'}, status=status.HTTP_400_BAD_REQUEST)
if is_gi_tagged is not None:
val = is_gi_tagged.strip().lower()
if val in ('true', '1'):
filter_clauses.append('is_gi_tagged:=true')
elif val in ('false', '0'):
filter_clauses.append('is_gi_tagged:=false')
if min_price is not None or max_price is not None:
try:
p_min = float(min_price) if min_price else 0.0
p_max = float(max_price) if max_price else 9999999.0
if p_min > p_max:
p_min, p_max = p_max, p_min
filter_clauses.append(f'price:[{p_min}..{p_max}]')
except (ValueError, TypeError):
return Response({'error': 'Price filters must be valid numbers'}, status=status.HTTP_400_BAD_REQUEST)
filter_by_str = ' && '.join(filter_clauses)
if sort_by_param == 'price_asc':
sort_by_str = 'price:asc'
elif sort_by_param == 'price_desc':
sort_by_str = 'price:desc'
elif sort_by_param == 'relevance':
sort_by_str = '_text_match:desc,created_at:desc'
elif sort_by_param == 'newest':
sort_by_str = 'created_at:desc'
else:
sort_by_str = '_text_match:desc,created_at:desc' if (q and q != '*') else 'created_at:desc'
res = TypesenseService.search_products(
query=q,
filter_by=filter_by_str,
sort_by=sort_by_str,
page=page,
per_page=per_page
)
hits = res.get('hits', [])
found = res.get('found', 0)
total_pages = (found + per_page - 1) // per_page if per_page > 0 else 1
results = [hit.get('document', {}) for hit in hits]
facet_counts = res.get('facet_counts', [])
return Response({
'count': found,
'page': page,
'per_page': per_page,
'total_pages': total_pages,
'facets': facet_counts,
'results': results
}, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Typesense Search Error: {e}", exc_info=True)
return Response({'error': str(e), 'results': [], 'count': 0}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class IndexProductView(APIView):
def post(self, request):
try:
data = request.data
if not data:
return Response({'error': 'Payload cannot be empty'}, status=status.HTTP_400_BAD_REQUEST)
if isinstance(data, list):
valid_items = []
for item in data:
if isinstance(item, dict) and ('id' in item or 'sku' in item):
valid_items.append(item)
if not valid_items:
return Response({'error': 'Each product in list must have an id or sku'}, status=status.HTTP_400_BAD_REQUEST)
res = TypesenseService.index_products_batch(valid_items)
return Response({'status': 'indexed', 'count': len(valid_items), 'result': res}, status=status.HTTP_200_OK)
elif isinstance(data, dict):
if 'id' not in data and 'sku' not in data:
return Response({'error': 'Product id or sku is required'}, status=status.HTTP_400_BAD_REQUEST)
res = TypesenseService.index_product(data)
return Response({'status': 'indexed', 'result': res}, status=status.HTTP_200_OK)
else:
return Response({'error': 'Invalid JSON format. Expected JSON object or array.'}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
logger.error(f"Product Index Error: {e}", exc_info=True)
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class DeleteProductView(APIView):
def delete(self, request, product_id):
try:
if not product_id or not str(product_id).strip():
return Response({'error': 'Product identifier is required'}, status=status.HTTP_400_BAD_REQUEST)
res = TypesenseService.delete_product(product_id)
return Response({'status': 'deleted', 'result': res}, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Product Delete Error: {e}", exc_info=True)
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class ReindexCatalogView(APIView):
def post(self, request):
try:
TypesenseService.ensure_schema(force=True)
indexed_count = 0
batch_size = 250
batch = []
with connection.cursor() as cursor:
cursor.execute("""
SELECT id, sku, title, description, category, supplier_id, price,
compare_at_price, stock, image, is_gi_tagged, is_active, status
FROM api_product
""")
columns = [col[0] for col in cursor.description]
rows = cursor.fetchall()
now_ts = int(time.time())
for idx, row in enumerate(rows):
item = dict(zip(columns, row))
# Retain descending ordering with timestamp offset
item['created_at'] = now_ts + idx
batch.append(item)
if len(batch) >= batch_size:
TypesenseService.index_products_batch(batch)
indexed_count += len(batch)
batch = []
if batch:
TypesenseService.index_products_batch(batch)
indexed_count += len(batch)
return Response({'status': 'completed', 'total_indexed': indexed_count}, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Full Reindex Error: {e}", exc_info=True)
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)