feat: add Haversine radius-based location filtering to EventListAPI
- Add _haversine_km() great-circle distance function (pure Python, no PostGIS) - EventListAPI now accepts optional latitude, longitude, radius_km params - Bounding-box SQL pre-filter narrows candidates, Haversine filters precisely - Progressive radius expansion: 10km → 25km → 50km → 100km if <6 results - Backward compatible: falls back to pincode filtering when no coords provided - Response includes radius_km field showing effective search radius used - Guard radius_km float conversion against malformed input - Use `is not None` checks for lat/lng (handles 0.0 edge case) - Expansion list filters to only try radii larger than requested
This commit is contained in:
@@ -11,9 +11,21 @@ from django.views.decorators.csrf import csrf_exempt
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import calendar
|
import calendar
|
||||||
|
import math
|
||||||
from mobile_api.utils import validate_token_and_get_user
|
from mobile_api.utils import validate_token_and_get_user
|
||||||
|
|
||||||
|
|
||||||
|
def _haversine_km(lat1, lon1, lat2, lon2):
|
||||||
|
"""Great-circle distance between two points in km."""
|
||||||
|
R = 6371.0
|
||||||
|
dlat = math.radians(lat2 - lat1)
|
||||||
|
dlon = math.radians(lon2 - lon1)
|
||||||
|
a = (math.sin(dlat / 2) ** 2 +
|
||||||
|
math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) *
|
||||||
|
math.sin(dlon / 2) ** 2)
|
||||||
|
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt, name='dispatch')
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
class EventTypeListAPIView(APIView):
|
class EventTypeListAPIView(APIView):
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
@@ -79,18 +91,83 @@ class EventListAPI(APIView):
|
|||||||
page_size = int(data.get("page_size", 50))
|
page_size = int(data.get("page_size", 50))
|
||||||
per_type = int(data.get("per_type", 0))
|
per_type = int(data.get("per_type", 0))
|
||||||
|
|
||||||
# Build base queryset (lazy - no DB hit yet)
|
# New optional geo params
|
||||||
|
user_lat = data.get("latitude")
|
||||||
|
user_lng = data.get("longitude")
|
||||||
|
try:
|
||||||
|
radius_km = float(data.get("radius_km", 10))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
radius_km = 10
|
||||||
|
|
||||||
|
# Build base queryset
|
||||||
MIN_EVENTS_THRESHOLD = 6
|
MIN_EVENTS_THRESHOLD = 6
|
||||||
qs = Event.objects.all()
|
qs = Event.objects.all()
|
||||||
if pincode and pincode != 'all':
|
used_radius = None
|
||||||
|
|
||||||
|
# Priority 1: Haversine radius filtering (if lat/lng provided)
|
||||||
|
if user_lat is not None and user_lng is not None:
|
||||||
|
try:
|
||||||
|
user_lat = float(user_lat)
|
||||||
|
user_lng = float(user_lng)
|
||||||
|
|
||||||
|
# Bounding box pre-filter (1 degree lat ≈ 111km)
|
||||||
|
lat_delta = radius_km / 111.0
|
||||||
|
lng_delta = radius_km / (111.0 * max(math.cos(math.radians(user_lat)), 0.01))
|
||||||
|
|
||||||
|
candidates = qs.filter(
|
||||||
|
latitude__gte=user_lat - lat_delta,
|
||||||
|
latitude__lte=user_lat + lat_delta,
|
||||||
|
longitude__gte=user_lng - lng_delta,
|
||||||
|
longitude__lte=user_lng + lng_delta,
|
||||||
|
latitude__isnull=False,
|
||||||
|
longitude__isnull=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Exact Haversine filter in Python
|
||||||
|
nearby_ids = []
|
||||||
|
for e in candidates:
|
||||||
|
if e.latitude is not None and e.longitude is not None:
|
||||||
|
dist = _haversine_km(user_lat, user_lng, float(e.latitude), float(e.longitude))
|
||||||
|
if dist <= radius_km:
|
||||||
|
nearby_ids.append(e.id)
|
||||||
|
|
||||||
|
# Progressive radius expansion if too few results
|
||||||
|
if len(nearby_ids) < MIN_EVENTS_THRESHOLD:
|
||||||
|
for expanded_r in [r for r in [25, 50, 100] if r > radius_km]:
|
||||||
|
lat_delta_ex = expanded_r / 111.0
|
||||||
|
lng_delta_ex = expanded_r / (111.0 * max(math.cos(math.radians(user_lat)), 0.01))
|
||||||
|
candidates_ex = qs.filter(
|
||||||
|
latitude__gte=user_lat - lat_delta_ex,
|
||||||
|
latitude__lte=user_lat + lat_delta_ex,
|
||||||
|
longitude__gte=user_lng - lng_delta_ex,
|
||||||
|
longitude__lte=user_lng + lng_delta_ex,
|
||||||
|
latitude__isnull=False,
|
||||||
|
longitude__isnull=False,
|
||||||
|
)
|
||||||
|
nearby_ids = []
|
||||||
|
for e in candidates_ex:
|
||||||
|
if e.latitude is not None and e.longitude is not None:
|
||||||
|
dist = _haversine_km(user_lat, user_lng, float(e.latitude), float(e.longitude))
|
||||||
|
if dist <= expanded_r:
|
||||||
|
nearby_ids.append(e.id)
|
||||||
|
if len(nearby_ids) >= MIN_EVENTS_THRESHOLD:
|
||||||
|
radius_km = expanded_r
|
||||||
|
break
|
||||||
|
|
||||||
|
if nearby_ids:
|
||||||
|
qs = qs.filter(id__in=nearby_ids)
|
||||||
|
used_radius = radius_km
|
||||||
|
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass # Invalid lat/lng — fall back to pincode
|
||||||
|
|
||||||
|
# Priority 2: Pincode filtering (backward compatible fallback)
|
||||||
|
if used_radius is None and pincode and pincode != 'all':
|
||||||
pincode_qs = qs.filter(pincode=pincode)
|
pincode_qs = qs.filter(pincode=pincode)
|
||||||
# Fallback to all events if pincode has too few
|
|
||||||
if pincode_qs.count() >= MIN_EVENTS_THRESHOLD:
|
if pincode_qs.count() >= MIN_EVENTS_THRESHOLD:
|
||||||
qs = pincode_qs
|
qs = pincode_qs
|
||||||
# else: keep qs as Event.objects.all()
|
|
||||||
|
|
||||||
if per_type > 0 and page == 1:
|
if per_type > 0 and page == 1:
|
||||||
# Diverse mode: one bounded query per event type
|
|
||||||
type_ids = list(qs.values_list('event_type_id', flat=True).distinct())
|
type_ids = list(qs.values_list('event_type_id', flat=True).distinct())
|
||||||
events_page = []
|
events_page = []
|
||||||
for tid in sorted(type_ids):
|
for tid in sorted(type_ids):
|
||||||
@@ -99,19 +176,16 @@ class EventListAPI(APIView):
|
|||||||
total_count = qs.count()
|
total_count = qs.count()
|
||||||
end = len(events_page)
|
end = len(events_page)
|
||||||
else:
|
else:
|
||||||
# Standard pagination at DB level
|
|
||||||
total_count = qs.count()
|
total_count = qs.count()
|
||||||
qs = qs.order_by('-created_date')
|
qs = qs.order_by('-created_date')
|
||||||
start = (page - 1) * page_size
|
start = (page - 1) * page_size
|
||||||
end = start + page_size
|
end = start + page_size
|
||||||
events_page = list(qs[start:end])
|
events_page = list(qs[start:end])
|
||||||
|
|
||||||
# Fetch images ONLY for the events we will return
|
|
||||||
page_ids = [e.id for e in events_page]
|
page_ids = [e.id for e in events_page]
|
||||||
primary_images = EventImages.objects.filter(event_id__in=page_ids, is_primary=True)
|
primary_images = EventImages.objects.filter(event_id__in=page_ids, is_primary=True)
|
||||||
thumb_map = {img.event_id: img for img in primary_images}
|
thumb_map = {img.event_id: img for img in primary_images}
|
||||||
|
|
||||||
# Serialize with direct attribute access (fast)
|
|
||||||
event_list = [self._serialize_event(e, thumb_map) for e in events_page]
|
event_list = [self._serialize_event(e, thumb_map) for e in events_page]
|
||||||
|
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
@@ -121,6 +195,7 @@ class EventListAPI(APIView):
|
|||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
"has_next": end < total_count,
|
"has_next": end < total_count,
|
||||||
|
"radius_km": used_radius,
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JsonResponse({"status": "error", "message": str(e)})
|
return JsonResponse({"status": "error", "message": str(e)})
|
||||||
|
|||||||
Reference in New Issue
Block a user