From 9b3527f8c6035f85508d8846e88506b40891c20c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Jun 2026 20:30:01 +0000 Subject: [PATCH] feat(transit): serve platform=all ads to specific platforms + admin bulk retag select_ad now treats platform=all campaigns as eligible for any specific client platform (ios/android) for both active and default ads. Adds an AdAdmin bulk action to set selected ads' platform to all (R10, KTD8). Co-authored-by: henrique --- src/transit/admin.py | 9 +++ src/transit/services/ads.py | 14 +++-- src/transit/tests/test_ads.py | 101 ++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 src/transit/tests/test_ads.py diff --git a/src/transit/admin.py b/src/transit/admin.py index 9783343..3cc8d38 100644 --- a/src/transit/admin.py +++ b/src/transit/admin.py @@ -99,3 +99,12 @@ class AdAdmin(IslandScopedAdmin): list_filter = ('island', 'platform', 'status') search_fields = ('entity', 'description') date_hierarchy = 'start' + actions = ('set_platform_to_all',) + + @admin.action(description='Set platform to all (selected)') + def set_platform_to_all(self, request, queryset): + updated = queryset.update(platform='all') + self.message_user( + request, + f'{updated} ad{"s" if updated != 1 else ""} set to platform=all.', + ) diff --git a/src/transit/services/ads.py b/src/transit/services/ads.py index e2b98ba..b233be2 100644 --- a/src/transit/services/ads.py +++ b/src/transit/services/ads.py @@ -5,6 +5,7 @@ from datetime import datetime from difflib import SequenceMatcher +from django.db.models import Q from django.utils import timezone from transit.models import Ad, Stop, StopGroup @@ -62,7 +63,9 @@ def select_ad(*, advertise_on: str, platform: str, now_ts: float | None = None) ads = Ad.objects.filter(status='active', start__lte=ad_dt, end__gte=ad_dt) if platform != 'all': - ads = ads.filter(platform=platform) + # A specific client platform (e.g. ios/android) is eligible for both its + # own targeted campaigns and any cross-platform `platform='all'` campaign. + ads = ads.filter(Q(platform=platform) | Q(platform='all')) if advertise_on in ('home', 'all'): if advertise_on != 'all': @@ -77,11 +80,10 @@ def select_ad(*, advertise_on: str, platform: str, now_ts: float | None = None) ad = ads.order_by('?').first() if ad is None: - ad = ( - Ad.objects.filter(platform=platform, status='default') - .order_by('?') - .first() - ) + default_qs = Ad.objects.filter(status='default') + if platform != 'all': + default_qs = default_qs.filter(Q(platform=platform) | Q(platform='all')) + ad = default_qs.order_by('?').first() if ad is None and platform not in ('all', ''): # Dev sqlite often has android-only defaults; serve any default rather than 404 ad = Ad.objects.filter(status='default').order_by('?').first() diff --git a/src/transit/tests/test_ads.py b/src/transit/tests/test_ads.py new file mode 100644 index 0000000..72a17ba --- /dev/null +++ b/src/transit/tests/test_ads.py @@ -0,0 +1,101 @@ +"""Tests for ad selection platform filtering and the admin bulk retag action.""" + +from __future__ import annotations + +from datetime import timedelta + +from django.contrib.admin.sites import AdminSite +from django.test import RequestFactory, TestCase +from django.utils import timezone + +from tenancy.services import for_island +from transit.admin import AdAdmin +from transit.models import Ad +from transit.services.ads import select_ad +from transit.tests.fixtures import ensure_transit_fixtures + + +class _DummyMessages: + def __init__(self): + self.messages = [] + + def add(self, level, message, extra_tags=''): + self.messages.append(message) + + +class SelectAdPlatformTests(TestCase): + def setUp(self): + self.island, _, _ = ensure_transit_fixtures() + self.now = timezone.now() + + def _make_ad(self, *, platform: str, status: str = 'active', advertise_on: str = 'home') -> Ad: + return Ad.objects.create( + island=self.island, + entity=f'{platform}-{status}', + media='https://example.com/banner.png', + start=self.now - timedelta(days=1), + end=self.now + timedelta(days=1), + advertise_on=advertise_on, + platform=platform, + status=status, + ) + + def test_platform_all_active_returned_for_ios(self): + ad = self._make_ad(platform='all') + with for_island(self.island): + selected = select_ad(advertise_on='home', platform='ios') + self.assertEqual(selected, ad) + + def test_platform_web_not_returned_for_ios(self): + self._make_ad(platform='web') + with for_island(self.island): + selected = select_ad(advertise_on='home', platform='ios') + self.assertIsNone(selected) + + def test_platform_web_default_fallback_for_ios(self): + default = self._make_ad(platform='all', status='default') + self._make_ad(platform='web') + with for_island(self.island): + selected = select_ad(advertise_on='home', platform='ios') + self.assertEqual(selected, default) + + def test_ios_targeted_ad_returned_for_ios(self): + ad = self._make_ad(platform='ios') + with for_island(self.island): + selected = select_ad(advertise_on='home', platform='ios') + self.assertEqual(selected, ad) + + +class AdAdminBulkActionTests(TestCase): + def setUp(self): + self.island, _, _ = ensure_transit_fixtures() + self.now = timezone.now() + self.admin = AdAdmin(Ad, AdminSite()) + self.factory = RequestFactory() + + def _make_ad(self, platform: str) -> Ad: + return Ad.objects.create( + island=self.island, + entity=f'ad-{platform}', + media='https://example.com/banner.png', + start=self.now - timedelta(days=1), + end=self.now + timedelta(days=1), + advertise_on='home', + platform=platform, + status='active', + ) + + def test_set_platform_to_all_updates_selected(self): + a = self._make_ad('web') + b = self._make_ad('android') + request = self.factory.post('/admin/') + request._messages = _DummyMessages() + + queryset = Ad.objects.filter(id__in=[a.id, b.id]) + self.admin.set_platform_to_all(request, queryset) + + a.refresh_from_db() + b.refresh_from_db() + self.assertEqual(a.platform, 'all') + self.assertEqual(b.platform, 'all') + self.assertEqual(request._messages.messages, ['2 ads set to platform=all.'])