feat(ad_control): new AdSurface + AdPlacement module for placement-based featured/top events
- New ad_control Django app: AdSurface + AdPlacement models with GLOBAL/LOCAL scope - Admin CRUD API at /api/v1/ad-control/ (JWT-protected): surfaces, placements, picker events - Placement lifecycle: DRAFT → ACTIVE|SCHEDULED → EXPIRED|DISABLED - LOCAL scope: Haversine ≤ 50km from event lat/lng (fixed radius, no config needed) - Consumer APIs: /api/events/featured-events/ and /api/events/top-events/ rewritten to use placement-based queries (same URL paths + response shape — no breaking changes) - Seed command: seed_surfaces --migrate converts existing is_featured/is_top_event booleans - mount: admin_api/urls.py → ad-control/, mobile_api/urls.py → replaced consumer views - settings.py: added ad_control to INSTALLED_APPS Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
107
ad_control/models.py
Normal file
107
ad_control/models.py
Normal file
@@ -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}]"
|
||||
Reference in New Issue
Block a user