diff --git a/ad_control/__init__.py b/ad_control/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ad_control/admin.py b/ad_control/admin.py new file mode 100644 index 0000000..be8f201 --- /dev/null +++ b/ad_control/admin.py @@ -0,0 +1,24 @@ +from django.contrib import admin +from .models import AdSurface, AdPlacement + + +@admin.register(AdSurface) +class AdSurfaceAdmin(admin.ModelAdmin): + list_display = ('key', 'name', 'max_slots', 'layout_type', 'sort_behavior', 'is_active', 'active_count') + list_filter = ('is_active', 'layout_type') + search_fields = ('key', 'name') + readonly_fields = ('created_at',) + + +@admin.register(AdPlacement) +class AdPlacementAdmin(admin.ModelAdmin): + list_display = ( + 'id', 'event', 'surface', 'status', 'scope', 'priority', + 'rank', 'boost_label', 'start_at', 'end_at', 'created_at', + ) + list_filter = ('status', 'scope', 'priority', 'surface') + list_editable = ('status', 'scope', 'rank') + search_fields = ('event__name', 'event__title', 'boost_label') + raw_id_fields = ('event', 'created_by', 'updated_by') + readonly_fields = ('created_at', 'updated_at') + ordering = ('surface', 'rank') diff --git a/ad_control/apps.py b/ad_control/apps.py new file mode 100644 index 0000000..90785e0 --- /dev/null +++ b/ad_control/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AdControlConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'ad_control' + verbose_name = 'Ad Control' diff --git a/ad_control/management/__init__.py b/ad_control/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ad_control/management/commands/__init__.py b/ad_control/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ad_control/management/commands/seed_surfaces.py b/ad_control/management/commands/seed_surfaces.py new file mode 100644 index 0000000..9dbca90 --- /dev/null +++ b/ad_control/management/commands/seed_surfaces.py @@ -0,0 +1,100 @@ +""" +Seed the default AdSurface records and migrate existing boolean flags to placements. + +Usage: + python manage.py seed_surfaces # seed surfaces only + python manage.py seed_surfaces --migrate # also migrate is_featured / is_top_event to placements +""" +from django.core.management.base import BaseCommand +from ad_control.models import AdSurface, AdPlacement +from events.models import Event + + +SURFACES = [ + { + 'key': 'HOME_FEATURED_CAROUSEL', + 'name': 'Featured Carousel', + 'description': 'Homepage hero carousel — high-impact banner-style placement.', + 'max_slots': 8, + 'layout_type': 'carousel', + 'sort_behavior': 'rank', + }, + { + 'key': 'HOME_TOP_EVENTS', + 'name': 'Top Events', + 'description': 'Homepage "Top Events" grid section below the hero.', + 'max_slots': 10, + 'layout_type': 'grid', + 'sort_behavior': 'rank', + }, +] + + +class Command(BaseCommand): + help = 'Seed default ad surfaces and optionally migrate boolean flags to placements.' + + def add_arguments(self, parser): + parser.add_argument( + '--migrate', + action='store_true', + help='Also migrate existing is_featured / is_top_event flags to AdPlacement rows.', + ) + + def handle(self, *args, **options): + # --- Seed surfaces --- + for s in SURFACES: + obj, created = AdSurface.objects.update_or_create( + key=s['key'], + defaults=s, + ) + status = 'CREATED' if created else 'EXISTS' + self.stdout.write(f" [{status}] {obj.key} — {obj.name}") + + # --- Migrate boolean flags --- + if options['migrate']: + self.stdout.write('\nMigrating boolean flags to placements...') + + featured_surface = AdSurface.objects.get(key='HOME_FEATURED_CAROUSEL') + top_surface = AdSurface.objects.get(key='HOME_TOP_EVENTS') + + featured_events = Event.objects.filter(is_featured=True) + top_events = Event.objects.filter(is_top_event=True) + + created_count = 0 + + for rank, event in enumerate(featured_events, start=1): + _, created = AdPlacement.objects.get_or_create( + surface=featured_surface, + event=event, + defaults={ + 'status': 'ACTIVE', + 'priority': 'MANUAL', + 'scope': 'GLOBAL', + 'rank': rank, + 'boost_label': 'Featured', + }, + ) + if created: + created_count += 1 + + for rank, event in enumerate(top_events, start=1): + _, created = AdPlacement.objects.get_or_create( + surface=top_surface, + event=event, + defaults={ + 'status': 'ACTIVE', + 'priority': 'MANUAL', + 'scope': 'GLOBAL', + 'rank': rank, + 'boost_label': 'Top Event', + }, + ) + if created: + created_count += 1 + + self.stdout.write(self.style.SUCCESS( + f' Migrated {created_count} placements ' + f'({featured_events.count()} featured, {top_events.count()} top events).' + )) + + self.stdout.write(self.style.SUCCESS('\nDone.')) diff --git a/ad_control/migrations/0001_initial.py b/ad_control/migrations/0001_initial.py new file mode 100644 index 0000000..75d01a0 --- /dev/null +++ b/ad_control/migrations/0001_initial.py @@ -0,0 +1,104 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('events', '0007_add_is_featured_is_top_event'), + ] + + operations = [ + migrations.CreateModel( + name='AdSurface', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(db_index=True, max_length=50, unique=True)), + ('name', models.CharField(max_length=100)), + ('description', models.TextField(blank=True, default='')), + ('max_slots', models.IntegerField(default=8)), + ('layout_type', models.CharField( + choices=[('carousel', 'Carousel'), ('grid', 'Grid'), ('list', 'List')], + default='carousel', max_length=20, + )), + ('sort_behavior', models.CharField( + choices=[('rank', 'By Rank'), ('date', 'By Date'), ('popularity', 'By Popularity')], + default='rank', max_length=20, + )), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'Ad Surface', + 'verbose_name_plural': 'Ad Surfaces', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='AdPlacement', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField( + choices=[ + ('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('SCHEDULED', 'Scheduled'), + ('EXPIRED', 'Expired'), ('DISABLED', 'Disabled'), + ], + db_index=True, default='DRAFT', max_length=20, + )), + ('priority', models.CharField( + choices=[('SPONSORED', 'Sponsored'), ('MANUAL', 'Manual'), ('ALGO', 'Algorithm')], + default='MANUAL', max_length=20, + )), + ('scope', models.CharField( + choices=[ + ('GLOBAL', 'Global \u2014 shown to all users'), + ('LOCAL', 'Local \u2014 shown to nearby users (50 km radius)'), + ], + db_index=True, default='GLOBAL', max_length=10, + )), + ('rank', models.IntegerField(default=0, help_text='Lower rank = higher position')), + ('start_at', models.DateTimeField(blank=True, help_text='When this placement becomes active', null=True)), + ('end_at', models.DateTimeField(blank=True, help_text='When this placement expires', null=True)), + ('boost_label', models.CharField( + blank=True, default='', help_text='Display label e.g. "Featured", "Top Pick", "Sponsored"', + max_length=50, + )), + ('notes', models.TextField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='created_placements', to=settings.AUTH_USER_MODEL, + )), + ('event', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name='ad_placements', + to='events.event', + )), + ('surface', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name='placements', + to='ad_control.adsurface', + )), + ('updated_by', models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='updated_placements', to=settings.AUTH_USER_MODEL, + )), + ], + options={ + 'verbose_name': 'Ad Placement', + 'verbose_name_plural': 'Ad Placements', + 'ordering': ['rank', '-created_at'], + }, + ), + migrations.AddConstraint( + model_name='adplacement', + constraint=models.UniqueConstraint( + condition=models.Q(('status__in', ['DRAFT', 'ACTIVE', 'SCHEDULED'])), + fields=('surface', 'event'), + name='unique_active_placement_per_surface', + ), + ), + ] diff --git a/ad_control/migrations/__init__.py b/ad_control/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ad_control/models.py b/ad_control/models.py new file mode 100644 index 0000000..d079bb5 --- /dev/null +++ b/ad_control/models.py @@ -0,0 +1,107 @@ +from django.db import models +from django.conf import settings +from events.models import Event + + +class AdSurface(models.Model): + """ + A display surface where ad placements can appear. + e.g. HOME_FEATURED_CAROUSEL, HOME_TOP_EVENTS, CATEGORY_FEATURED + """ + LAYOUT_CHOICES = [ + ('carousel', 'Carousel'), + ('grid', 'Grid'), + ('list', 'List'), + ] + SORT_CHOICES = [ + ('rank', 'By Rank'), + ('date', 'By Date'), + ('popularity', 'By Popularity'), + ] + + key = models.CharField(max_length=50, unique=True, db_index=True) + name = models.CharField(max_length=100) + description = models.TextField(blank=True, default='') + max_slots = models.IntegerField(default=8) + layout_type = models.CharField(max_length=20, choices=LAYOUT_CHOICES, default='carousel') + sort_behavior = models.CharField(max_length=20, choices=SORT_CHOICES, default='rank') + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['name'] + verbose_name = 'Ad Surface' + verbose_name_plural = 'Ad Surfaces' + + def __str__(self): + return f"{self.name} ({self.key})" + + @property + def active_count(self): + return self.placements.filter(status__in=['ACTIVE', 'SCHEDULED']).count() + + +class AdPlacement(models.Model): + """ + A single placement of an event on a surface. + Supports scheduling, ranking, scope-based targeting, and lifecycle status. + """ + STATUS_CHOICES = [ + ('DRAFT', 'Draft'), + ('ACTIVE', 'Active'), + ('SCHEDULED', 'Scheduled'), + ('EXPIRED', 'Expired'), + ('DISABLED', 'Disabled'), + ] + PRIORITY_CHOICES = [ + ('SPONSORED', 'Sponsored'), + ('MANUAL', 'Manual'), + ('ALGO', 'Algorithm'), + ] + SCOPE_CHOICES = [ + ('GLOBAL', 'Global — shown to all users'), + ('LOCAL', 'Local — shown to nearby users (50 km radius)'), + ] + + surface = models.ForeignKey( + AdSurface, on_delete=models.CASCADE, related_name='placements', + ) + event = models.ForeignKey( + Event, on_delete=models.CASCADE, related_name='ad_placements', + ) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='DRAFT', db_index=True) + priority = models.CharField(max_length=20, choices=PRIORITY_CHOICES, default='MANUAL') + scope = models.CharField(max_length=10, choices=SCOPE_CHOICES, default='GLOBAL', db_index=True) + rank = models.IntegerField(default=0, help_text='Lower rank = higher position') + start_at = models.DateTimeField(null=True, blank=True, help_text='When this placement becomes active') + end_at = models.DateTimeField(null=True, blank=True, help_text='When this placement expires') + boost_label = models.CharField( + max_length=50, blank=True, default='', + help_text='Display label e.g. "Featured", "Top Pick", "Sponsored"', + ) + notes = models.TextField(blank=True, default='') + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, + null=True, blank=True, related_name='created_placements', + ) + updated_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, + null=True, blank=True, related_name='updated_placements', + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['rank', '-created_at'] + verbose_name = 'Ad Placement' + verbose_name_plural = 'Ad Placements' + constraints = [ + models.UniqueConstraint( + fields=['surface', 'event'], + condition=models.Q(status__in=['DRAFT', 'ACTIVE', 'SCHEDULED']), + name='unique_active_placement_per_surface', + ), + ] + + def __str__(self): + return f"{self.event.name} on {self.surface.key} [{self.status}]" diff --git a/ad_control/urls.py b/ad_control/urls.py new file mode 100644 index 0000000..7cfedb1 --- /dev/null +++ b/ad_control/urls.py @@ -0,0 +1,18 @@ +from django.urls import path +from . import views + +# Admin CRUD endpoints — mounted at /api/v1/ad-control/ +urlpatterns = [ + # Surfaces + path('surfaces/', views.SurfaceListView.as_view(), name='ad-surfaces'), + + # Placements CRUD + path('placements/', views.PlacementListCreateView.as_view(), name='ad-placements'), + path('placements//', views.PlacementDetailView.as_view(), name='ad-placement-detail'), + path('placements//publish/', views.PlacementPublishView.as_view(), name='ad-placement-publish'), + path('placements//unpublish/', views.PlacementUnpublishView.as_view(), name='ad-placement-unpublish'), + path('placements/reorder/', views.PlacementReorderView.as_view(), name='ad-placements-reorder'), + + # Events picker + path('events/', views.PickerEventsView.as_view(), name='ad-picker-events'), +] diff --git a/ad_control/views.py b/ad_control/views.py new file mode 100644 index 0000000..ee3f96e --- /dev/null +++ b/ad_control/views.py @@ -0,0 +1,546 @@ +""" +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) diff --git a/admin_api/urls.py b/admin_api/urls.py index 301709f..f847948 100644 --- a/admin_api/urls.py +++ b/admin_api/urls.py @@ -1,4 +1,4 @@ -from django.urls import path +from django.urls import path, include from rest_framework_simplejwt.views import TokenRefreshView from . import views @@ -74,4 +74,7 @@ urlpatterns = [ path('rbac/scopes/', views.ScopeListView.as_view(), name='rbac-scope-list'), path('rbac/org-tree/', views.OrgTreeView.as_view(), name='rbac-org-tree'), path('rbac/audit-log/', views.AuditLogListView.as_view(), name='rbac-audit-log'), + + # Ad Control + path('ad-control/', include('ad_control.urls')), ] \ No newline at end of file diff --git a/eventify/settings.py b/eventify/settings.py index d5df4aa..0f0e0da 100644 --- a/eventify/settings.py +++ b/eventify/settings.py @@ -46,6 +46,7 @@ INSTALLED_APPS = [ 'django_summernote', 'ledger', 'notifications', + 'ad_control', ] INSTALLED_APPS += [ diff --git a/mobile_api/urls.py b/mobile_api/urls.py index 98e30cf..ef764fa 100644 --- a/mobile_api/urls.py +++ b/mobile_api/urls.py @@ -1,6 +1,7 @@ from django.urls import path from .views import * from mobile_api.views.reviews import ReviewSubmitView, MobileReviewListView, ReviewHelpfulView, ReviewFlagView +from ad_control.views import ConsumerFeaturedEventsView, ConsumerTopEventsView # Customer URLS @@ -10,6 +11,8 @@ urlpatterns = [ path('user/status/', StatusView.as_view(), name='user_status'), path('user/logout/', LogoutView.as_view(), name='user_logout'), path('user/update-profile/', UpdateProfileView.as_view(), name='update_profile'), + path('user/bulk-public-info/', BulkUserPublicInfoView.as_view(), name='bulk_public_info'), + path('user/google-login/', GoogleLoginView.as_view(), name='google_login'), ] # Event URLS @@ -22,8 +25,9 @@ urlpatterns += [ path('events/events-by-category/', EventsByCategoryAPI.as_view(), name='api_events_by_category'), path('events/events-by-month-year/', EventsByMonthYearAPI.as_view(), name='events_by_month_year'), path('events/events-by-date/', EventsByDateAPI.as_view(), name='events_by_date'), - path('events/featured-events/', FeaturedEventsAPI.as_view(), name='featured_events'), - path('events/top-events/', TopEventsAPI.as_view(), name='top_events'), + path('events/featured-events/', ConsumerFeaturedEventsView.as_view(), name='featured_events'), + path('events/top-events/', ConsumerTopEventsView.as_view(), name='top_events'), + path('events/contributor-profile/', ContributorProfileAPI.as_view(), name='contributor_profile'), ] # Review URLs