""" Ad Control — Admin CRUD API views. All endpoints require JWT authentication (IsAuthenticated). Mounted at /api/v1/ad-control/ via admin_api/urls.py. """ import json import math from datetime import datetime from django.utils import timezone from django.db import models as db_models from django.db.models import Q, Count, Max from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from django.http import JsonResponse from .models import AdSurface, AdPlacement from events.models import Event, EventImages # --------------------------------------------------------------------------- # Serialisation helpers # --------------------------------------------------------------------------- def _serialize_surface(s): """Serialize an AdSurface to camelCase dict matching admin panel types.""" active = s.placements.filter(status__in=['ACTIVE', 'SCHEDULED']).count() return { 'id': str(s.id), 'key': s.key, 'name': s.name, 'description': s.description, 'maxSlots': s.max_slots, 'layoutType': s.layout_type, 'sortBehavior': s.sort_behavior, 'isActive': s.is_active, 'activeCount': active, 'createdAt': s.created_at.isoformat() if s.created_at else None, } def _serialize_picker_event(e): """Serialize an Event for the picker modal (lightweight).""" try: thumb = EventImages.objects.get(event=e.id, is_primary=True) cover = thumb.event_image.url except EventImages.DoesNotExist: cover = None return { 'id': str(e.id), 'title': e.title or e.name, 'city': e.district, 'state': e.state, 'country': 'IN', 'date': str(e.start_date) if e.start_date else '', 'endDate': str(e.end_date) if e.end_date else '', 'organizer': e.partner.name if e.partner else 'Eventify', 'organizerLogo': '', 'category': e.event_type.name if e.event_type else '', 'coverImage': cover, 'approvalStatus': 'APPROVED' if e.event_status == 'published' else ( 'REJECTED' if e.event_status == 'cancelled' else 'PENDING' ), 'ticketsSold': 0, 'capacity': 0, } def _serialize_placement(p, include_event=True): """Serialize an AdPlacement to camelCase dict matching admin panel types.""" result = { 'id': str(p.id), 'surfaceId': str(p.surface_id), 'itemType': 'EVENT', 'eventId': str(p.event_id), 'status': p.status, 'priority': p.priority, 'scope': p.scope, 'rank': p.rank, 'startAt': p.start_at.isoformat() if p.start_at else None, 'endAt': p.end_at.isoformat() if p.end_at else None, 'targeting': { 'cityIds': [], 'categoryIds': [], 'countryCodes': ['IN'], }, 'boostLabel': p.boost_label or None, 'notes': p.notes or None, 'createdBy': str(p.created_by_id) if p.created_by_id else 'system', 'updatedBy': str(p.updated_by_id) if p.updated_by_id else 'system', 'createdAt': p.created_at.isoformat(), 'updatedAt': p.updated_at.isoformat(), } if include_event: result['event'] = _serialize_picker_event(p.event) return result # --------------------------------------------------------------------------- # Admin API — Surfaces # --------------------------------------------------------------------------- class SurfaceListView(APIView): permission_classes = [IsAuthenticated] def get(self, request): surfaces = AdSurface.objects.filter(is_active=True) return JsonResponse({ 'success': True, 'data': [_serialize_surface(s) for s in surfaces], }) # --------------------------------------------------------------------------- # Admin API — Placements CRUD # --------------------------------------------------------------------------- class PlacementListCreateView(APIView): permission_classes = [IsAuthenticated] def get(self, request): """List placements, optionally filtered by surface_id and status.""" qs = AdPlacement.objects.select_related('event', 'event__event_type', 'event__partner', 'surface') surface_id = request.GET.get('surface_id') status = request.GET.get('status') if surface_id: qs = qs.filter(surface_id=surface_id) if status and status != 'ALL': qs = qs.filter(status=status) # Auto-expire: mark past-endAt placements as EXPIRED now = timezone.now() expired = qs.filter( status__in=['ACTIVE', 'SCHEDULED'], end_at__isnull=False, end_at__lt=now, ) if expired.exists(): expired.update(status='EXPIRED', updated_at=now) # Re-fetch after expiry update qs = AdPlacement.objects.select_related( 'event', 'event__event_type', 'event__partner', 'surface', ) if surface_id: qs = qs.filter(surface_id=surface_id) if status and status != 'ALL': qs = qs.filter(status=status) qs = qs.order_by('rank', '-created_at') return JsonResponse({ 'success': True, 'data': [_serialize_placement(p) for p in qs], }) def post(self, request): """Create a new placement.""" try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({'success': False, 'message': 'Invalid JSON'}, status=400) surface_id = data.get('surfaceId') event_id = data.get('eventId') scope = data.get('scope', 'GLOBAL') priority = data.get('priority', 'MANUAL') start_at = data.get('startAt') end_at = data.get('endAt') boost_label = data.get('boostLabel', '') notes = data.get('notes', '') if not surface_id or not event_id: return JsonResponse({'success': False, 'message': 'surfaceId and eventId are required'}, status=400) try: surface = AdSurface.objects.get(id=surface_id) except AdSurface.DoesNotExist: return JsonResponse({'success': False, 'message': 'Surface not found'}, status=404) try: event = Event.objects.get(id=event_id) except Event.DoesNotExist: return JsonResponse({'success': False, 'message': 'Event not found'}, status=404) # Check max slots active_count = surface.placements.filter(status__in=['ACTIVE', 'SCHEDULED']).count() if active_count >= surface.max_slots: return JsonResponse({ 'success': False, 'message': f'Surface "{surface.name}" is full ({surface.max_slots} max slots)', }, status=400) # Check duplicate if AdPlacement.objects.filter( surface=surface, event=event, status__in=['DRAFT', 'ACTIVE', 'SCHEDULED'], ).exists(): return JsonResponse({ 'success': False, 'message': 'This event is already placed on this surface', }, status=400) # Calculate next rank max_rank = surface.placements.aggregate(max_rank=Max('rank'))['max_rank'] or 0 placement = AdPlacement.objects.create( surface=surface, event=event, status='DRAFT', priority=priority, scope=scope, rank=max_rank + 1, start_at=datetime.fromisoformat(start_at) if start_at else None, end_at=datetime.fromisoformat(end_at) if end_at else None, boost_label=boost_label, notes=notes, created_by=request.user, updated_by=request.user, ) return JsonResponse({ 'success': True, 'message': 'Placement created as draft', 'data': _serialize_placement(placement), }, status=201) class PlacementDetailView(APIView): permission_classes = [IsAuthenticated] def patch(self, request, pk): """Update a placement's config.""" try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({'success': False, 'message': 'Invalid JSON'}, status=400) try: placement = AdPlacement.objects.select_related('event', 'surface').get(id=pk) except AdPlacement.DoesNotExist: return JsonResponse({'success': False, 'message': 'Placement not found'}, status=404) if 'startAt' in data: placement.start_at = datetime.fromisoformat(data['startAt']) if data['startAt'] else None if 'endAt' in data: placement.end_at = datetime.fromisoformat(data['endAt']) if data['endAt'] else None if 'scope' in data: placement.scope = data['scope'] if 'priority' in data: placement.priority = data['priority'] if 'boostLabel' in data: placement.boost_label = data['boostLabel'] or '' if 'notes' in data: placement.notes = data['notes'] or '' placement.updated_by = request.user placement.save() return JsonResponse({'success': True, 'message': 'Placement updated'}) def delete(self, request, pk): """Delete a placement.""" try: placement = AdPlacement.objects.get(id=pk) except AdPlacement.DoesNotExist: return JsonResponse({'success': False, 'message': 'Placement not found'}, status=404) placement.delete() return JsonResponse({'success': True, 'message': 'Placement deleted'}) class PlacementPublishView(APIView): permission_classes = [IsAuthenticated] def post(self, request, pk): """Publish a placement (DRAFT → ACTIVE or SCHEDULED).""" try: placement = AdPlacement.objects.get(id=pk) except AdPlacement.DoesNotExist: return JsonResponse({'success': False, 'message': 'Placement not found'}, status=404) now = timezone.now() if placement.start_at and placement.start_at > now: placement.status = 'SCHEDULED' else: placement.status = 'ACTIVE' placement.updated_by = request.user placement.save() return JsonResponse({ 'success': True, 'message': f'Placement {"scheduled" if placement.status == "SCHEDULED" else "published"}', }) class PlacementUnpublishView(APIView): permission_classes = [IsAuthenticated] def post(self, request, pk): """Unpublish a placement (→ DISABLED).""" try: placement = AdPlacement.objects.get(id=pk) except AdPlacement.DoesNotExist: return JsonResponse({'success': False, 'message': 'Placement not found'}, status=404) placement.status = 'DISABLED' placement.updated_by = request.user placement.save() return JsonResponse({'success': True, 'message': 'Placement unpublished'}) class PlacementReorderView(APIView): permission_classes = [IsAuthenticated] def post(self, request): """Bulk-update ranks for a surface's placements.""" try: data = json.loads(request.body) except json.JSONDecodeError: return JsonResponse({'success': False, 'message': 'Invalid JSON'}, status=400) surface_id = data.get('surfaceId') ordered_ids = data.get('orderedIds', []) if not surface_id or not ordered_ids: return JsonResponse({'success': False, 'message': 'surfaceId and orderedIds required'}, status=400) now = timezone.now() for index, pid in enumerate(ordered_ids): AdPlacement.objects.filter(id=pid, surface_id=surface_id).update( rank=index + 1, updated_at=now, ) return JsonResponse({ 'success': True, 'message': f'Reordered {len(ordered_ids)} placements', }) # --------------------------------------------------------------------------- # Admin API — Events picker # --------------------------------------------------------------------------- class PickerEventsView(APIView): permission_classes = [IsAuthenticated] def get(self, request): """List events for the event picker modal (search, paginated).""" search = request.GET.get('search', '').strip() page = int(request.GET.get('page', 1)) page_size = int(request.GET.get('page_size', 20)) qs = Event.objects.select_related('event_type', 'partner').filter( event_status__in=['published', 'live'], ).order_by('-start_date') if search: qs = qs.filter( Q(name__icontains=search) | Q(title__icontains=search) | Q(district__icontains=search) | Q(place__icontains=search) ) total = qs.count() start = (page - 1) * page_size events = qs[start:start + page_size] return JsonResponse({ 'success': True, 'data': [_serialize_picker_event(e) for e in events], 'total': total, 'page': page, 'totalPages': math.ceil(total / page_size) if total > 0 else 1, }) # --------------------------------------------------------------------------- # Consumer API — Featured & Top Events (replaces boolean-based queries) # --------------------------------------------------------------------------- def _haversine_km(lat1, lng1, lat2, lng2): """Great-circle distance in km between two lat/lng points.""" R = 6371 d_lat = math.radians(float(lat2) - float(lat1)) d_lng = math.radians(float(lng2) - float(lng1)) a = ( math.sin(d_lat / 2) ** 2 + math.cos(math.radians(float(lat1))) * math.cos(math.radians(float(lat2))) * math.sin(d_lng / 2) ** 2 ) return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) def _get_placement_events(surface_key, user_lat=None, user_lng=None): """ Core placement resolution logic. 1. Fetch ACTIVE placements on the given surface 2. Filter by schedule window (start_at / end_at) 3. GLOBAL placements → always included 4. LOCAL placements → included only if user is within 50 km of event 5. Sort by priority (SPONSORED > MANUAL > ALGO) then rank 6. Limit to surface.max_slots """ LOCAL_RADIUS_KM = 50 try: surface = AdSurface.objects.get(key=surface_key, is_active=True) except AdSurface.DoesNotExist: return [] now = timezone.now() qs = AdPlacement.objects.select_related( 'event', 'event__event_type', 'event__partner', ).filter( surface=surface, status='ACTIVE', ).filter( Q(start_at__isnull=True) | Q(start_at__lte=now), ).filter( Q(end_at__isnull=True) | Q(end_at__gt=now), ).order_by('rank') result = [] priority_order = {'SPONSORED': 0, 'MANUAL': 1, 'ALGO': 2} for p in qs: if p.scope == 'GLOBAL': result.append(p) elif p.scope == 'LOCAL': # Only include if user sent location AND is within 50 km if user_lat is not None and user_lng is not None: dist = _haversine_km(user_lat, user_lng, p.event.latitude, p.event.longitude) if dist <= LOCAL_RADIUS_KM: result.append(p) # Sort: priority first, then rank result.sort(key=lambda p: (priority_order.get(p.priority, 9), p.rank)) # Limit to max_slots return result[:surface.max_slots] def _serialize_event_for_consumer(e): """Serialize an Event for the consumer mobile/web API (matches existing format).""" try: thumb = EventImages.objects.get(event=e.id, is_primary=True) thumb_url = thumb.event_image.url except EventImages.DoesNotExist: thumb_url = '' return { 'id': e.id, 'name': e.name, 'title': e.title or e.name, 'description': (e.description or '')[:200], 'start_date': str(e.start_date) if e.start_date else '', 'end_date': str(e.end_date) if e.end_date else '', 'start_time': str(e.start_time) if e.start_time else '', 'end_time': str(e.end_time) if e.end_time else '', 'pincode': e.pincode, 'place': e.place, 'district': e.district, 'state': e.state, 'is_bookable': e.is_bookable, 'event_type': e.event_type_id, 'event_status': e.event_status, 'venue_name': e.venue_name, 'latitude': float(e.latitude), 'longitude': float(e.longitude), 'location_name': e.place, 'thumb_img': thumb_url, 'is_eventify_event': e.is_eventify_event, 'source': e.source, } class ConsumerFeaturedEventsView(APIView): """ Public API — returns featured events from the HOME_FEATURED_CAROUSEL surface. POST /api/events/featured-events/ Optional body: { "latitude": float, "longitude": float } """ authentication_classes = [] permission_classes = [] def post(self, request): try: try: data = json.loads(request.body) if request.body else {} except json.JSONDecodeError: data = {} user_lat = data.get('latitude') user_lng = data.get('longitude') placements = _get_placement_events( 'HOME_FEATURED_CAROUSEL', user_lat=user_lat, user_lng=user_lng, ) events = [_serialize_event_for_consumer(p.event) for p in placements] return JsonResponse({'status': 'success', 'events': events}) except Exception as e: return JsonResponse({'status': 'error', 'message': str(e)}, status=500) class ConsumerTopEventsView(APIView): """ Public API — returns top events from the HOME_TOP_EVENTS surface. POST /api/events/top-events/ Optional body: { "latitude": float, "longitude": float } """ authentication_classes = [] permission_classes = [] def post(self, request): try: try: data = json.loads(request.body) if request.body else {} except json.JSONDecodeError: data = {} user_lat = data.get('latitude') user_lng = data.get('longitude') placements = _get_placement_events( 'HOME_TOP_EVENTS', user_lat=user_lat, user_lng=user_lng, ) events = [_serialize_event_for_consumer(p.event) for p in placements] return JsonResponse({'status': 'success', 'events': events}) except Exception as e: return JsonResponse({'status': 'error', 'message': str(e)}, status=500)