- 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
108 lines
3.9 KiB
Python
108 lines
3.9 KiB
Python
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}]"
|