From e11d18315b9dc1e69010cc45b474433c88e2009d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Blond=20Daugaard?= Date: Thu, 2 Oct 2025 20:38:26 -0400 Subject: [PATCH 1/8] Add PRD. --- .docs/prds/ads.md | 143 ++++++++++++++++++++++++++++++++++++++++++++++ .gitignore | 2 + 2 files changed, 145 insertions(+) create mode 100644 .docs/prds/ads.md diff --git a/.docs/prds/ads.md b/.docs/prds/ads.md new file mode 100644 index 0000000..c763df4 --- /dev/null +++ b/.docs/prds/ads.md @@ -0,0 +1,143 @@ +# PRD: Ads Sub-System for RulesBot.ai + +## Overview + +We want to introduce a lightweight ads system to **RulesBot.ai** that enables initial monetization. The system should serve **contextual ads** based on the board game a user is interacting with, or fallback to **general ads** if no contextual ads exist. + +Ads will be primarily **Amazon affiliate links** at launch. + +The ads system must be manageable through Django Admin and provide basic analytics (impressions and clicks). + + +## Goals + +1. **Serve Ads in Context** + + * Ads tied to a specific board game appear on that game’s chat pages. + * Generic ads serve as a fallback. + +2. **Ad Management** + + * Admins define ads via the Django Admin interface. + * Each ad includes: + + * Title (string) + * Description (short ad copy) + * Image (optional, file or URL) + * Link (URL, affiliate-friendly) + * Associated Game (optional, foreign key or null) + * Weight (integer, controls likelihood of being served) + +3. **Tracking & Analytics** + + * Log ad impressions (every time an ad is displayed). + * Log ad clicks (when a user clicks the ad link). + * Provide reporting (impressions & clicks per ad). + * Show analytics in Django Admin (custom dashboard or extended admin list view). + +## Non-Goals + +* Real-time bidding or external ad networks. +* User-level ad targeting (beyond game context). +* Complex A/B testing or frequency capping. + + +## Functional Requirements + +### 1. Ad Model + +* `Ad` + + * `id` (UUID / AutoField) + * `title` (CharField, max 255) + * `description` (TextField, short length e.g. 500 chars) + * `image` (ImageField or URLField, optional) + * `link` (URLField) + * `game` (ForeignKey to Game model, nullable, blank = generic ad) + * `weight` (PositiveIntegerField, default=1) → higher = more likely to show + * `created_at`, `updated_at` + +### 2. Serving Ads + +* When a user is on a **chat page**: + + 1. Check if ads exist for the current game. + 2. If yes, select one ad randomly using **weighted random selection**. + 3. If no, select a weighted random generic ad. +* Ads should be rendered with: + + * Title + * Description + * Optional image + * Clickable link (redirect via tracking endpoint, not direct). +* Placement: **Sidebar** in MVP. Later integration directly into chat messages. + +### 3. Tracking + +* **Impressions:** Logged each time an ad is served to a user (raw count, not unique). +* **Clicks:** Logged when a user clicks an ad (use a redirect endpoint like `/ads/click/`). + +**Models**: + +* `AdImpression` + + * `id` + * `ad` (ForeignKey to Ad) + * `timestamp` + +* `AdClick` + + * `id` + * `ad` (ForeignKey to Ad) + * `timestamp` + +### 4. Admin Integration + +* Ads managed via Django Admin. +* Inline image upload or URL entry for ad images. +* Impressions and clicks viewable per ad: + + * Extra columns in the Ads list view (e.g., "Impressions", "Clicks", "CTR"). + * Custom admin dashboard page with a summary (total impressions, total clicks, CTR). + +## Technical Requirements + +* **Framework:** Django (follow project’s `agents.md` best practices). +* **Weighted Random Selection:** Implement custom selection logic using the `weight` field rather than `order_by("?")`. +* **Tracking:** + + * Impressions logged in the backend when rendering ad context. + * Clicks logged via a redirect view that increments clicks then redirects to target URL. +* **Dashboard:** + + * Either extend Django Admin with a custom report page or add metrics inline in the ads list display. + +## User Experience (UX) + +* **Frontend Display**: + + * Ad displayed in the **sidebar** of the chat page. + * Title bold, description regular text, optional image thumbnail, CTA link. +* **Click Handling**: + + * Clicking ad always routes through tracking redirect. + +## Future Enhancements (Not in MVP) + +* Support multiple ad formats (carousel, banner). +* Add expiration dates for ads. +* Add frequency capping (limit impressions per user). +* Import/export ads via CSV/JSON. +* Placement integration into the chat flow itself. + +## Open Questions + +* Should CTR thresholds or performance auto-disable low-performing ads? +* Should weights be capped or free-form integers? +* Should we add a scheduling option (e.g., only run ad during certain dates)? + +## Agent Progress Tracking + +The implementing agent should define its own phases and tasks to complete this PRD and add them here. + +When done, mark the phase as complete. diff --git a/.gitignore b/.gitignore index 21f116f..8445577 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,5 @@ cython_debug/ .vscode/ .idea + +sample-rules/ From 7d6b0e28281098cc18d936cb2ecaeb690bf3a079 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Blond=20Daugaard?= Date: Thu, 2 Oct 2025 20:55:43 -0400 Subject: [PATCH 2/8] Phase one of ads. --- .docs/prds/ads.md | 44 +++++++ .tool-versions | 1 + ads/__init__.py | 0 ads/admin.py | 1 + ads/apps.py | 6 + ads/migrations/0001_initial.py | 122 +++++++++++++++++ ads/migrations/__init__.py | 0 ads/models.py | 86 ++++++++++++ ads/services.py | 77 +++++++++++ ads/tests.py | 232 +++++++++++++++++++++++++++++++++ ads/views.py | 1 + rulesbot/settings.py | 1 + 12 files changed, 571 insertions(+) create mode 100644 .tool-versions create mode 100644 ads/__init__.py create mode 100644 ads/admin.py create mode 100644 ads/apps.py create mode 100644 ads/migrations/0001_initial.py create mode 100644 ads/migrations/__init__.py create mode 100644 ads/models.py create mode 100644 ads/services.py create mode 100644 ads/tests.py create mode 100644 ads/views.py diff --git a/.docs/prds/ads.md b/.docs/prds/ads.md index c763df4..2cdbf4d 100644 --- a/.docs/prds/ads.md +++ b/.docs/prds/ads.md @@ -141,3 +141,47 @@ The ads system must be manageable through Django Admin and provide basic analyti The implementing agent should define its own phases and tasks to complete this PRD and add them here. When done, mark the phase as complete. + +### Phase 1: Core Infrastructure ⏳ +- [x] Create ads Django app +- [x] Create Ad model with all fields (title, description, image, link, game FK, weight) +- [x] Create AdImpression tracking model +- [x] Create AdClick tracking model +- [x] Add ads app to INSTALLED_APPS +- [x] Create and run migrations +- [x] Write model tests (Ad, AdImpression, AdClick) + +### Phase 2: Ad Selection & Serving Logic ⏳ +- [ ] Implement weighted random ad selection service +- [ ] Create ad serving function (context-aware: game-specific vs generic) +- [ ] Create context processor to inject ads into chat page context +- [ ] Log impressions when ad is served +- [ ] Write tests for weighted selection algorithm +- [ ] Write tests for game-specific vs generic fallback logic + +### Phase 3: Click Tracking ⏳ +- [ ] Create URL patterns for ads app +- [ ] Implement ad click tracking redirect view (/ads/click/) +- [ ] Log clicks and redirect to target URL +- [ ] Write tests for click tracking and redirect behavior + +### Phase 4: Django Admin Integration ⏳ +- [ ] Register Ad model in Django admin +- [ ] Add custom admin list display with impression/click counts +- [ ] Add CTR calculation to admin +- [ ] Register AdImpression and AdClick models (read-only) +- [ ] Write tests for admin custom fields and calculations + +### Phase 5: Frontend Display ⏳ +- [ ] Update chat.html template to display ads in sidebar +- [ ] Style ad component (title, description, image, CTA link) +- [ ] Ensure clicks route through tracking endpoint +- [ ] Write integration tests for ad display on chat pages + +### Phase 6: End-to-End Testing & Validation ⏳ +- [ ] Test ad creation via admin +- [ ] Test weighted random selection (game-specific and generic) +- [ ] Test impression logging +- [ ] Test click tracking and redirect +- [ ] Verify analytics display in admin +- [ ] Run full test suite and ensure all tests pass diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..e2a1032 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 23.3.0 diff --git a/ads/__init__.py b/ads/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ads/admin.py b/ads/admin.py new file mode 100644 index 0000000..846f6b4 --- /dev/null +++ b/ads/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/ads/apps.py b/ads/apps.py new file mode 100644 index 0000000..ffb2cc5 --- /dev/null +++ b/ads/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AdsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "ads" diff --git a/ads/migrations/0001_initial.py b/ads/migrations/0001_initial.py new file mode 100644 index 0000000..26570d5 --- /dev/null +++ b/ads/migrations/0001_initial.py @@ -0,0 +1,122 @@ +# Generated by Django 4.2.23 on 2025-10-03 00:50 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("games", "0012_alter_document_url"), + ] + + operations = [ + migrations.CreateModel( + name="Ad", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255)), + ("description", models.TextField(max_length=500)), + ( + "image", + models.URLField( + blank=True, help_text="Optional image URL", null=True + ), + ), + ( + "link", + models.URLField( + help_text="Target URL (e.g., Amazon affiliate link)" + ), + ), + ( + "weight", + models.PositiveIntegerField( + default=1, help_text="Higher weight = more likely to be shown" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "game", + models.ForeignKey( + blank=True, + help_text="Leave blank for generic ads shown on all games", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="games.game", + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="AdImpression", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ( + "ad", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="ads.ad" + ), + ), + ], + options={ + "ordering": ["-timestamp"], + "indexes": [ + models.Index( + fields=["ad", "-timestamp"], name="ads_adimpre_ad_id_c5d864_idx" + ) + ], + }, + ), + migrations.CreateModel( + name="AdClick", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("timestamp", models.DateTimeField(auto_now_add=True)), + ( + "ad", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="ads.ad" + ), + ), + ], + options={ + "ordering": ["-timestamp"], + "indexes": [ + models.Index( + fields=["ad", "-timestamp"], name="ads_adclick_ad_id_d190d6_idx" + ) + ], + }, + ), + ] diff --git a/ads/migrations/__init__.py b/ads/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ads/models.py b/ads/models.py new file mode 100644 index 0000000..6adc152 --- /dev/null +++ b/ads/models.py @@ -0,0 +1,86 @@ +from django.db import models + +from games.models import Game + + +class Ad(models.Model): + """ + Advertisement model that can be either game-specific or generic. + Uses weighted random selection for serving. + """ + + title = models.CharField(max_length=255) + description = models.TextField(max_length=500) + image = models.URLField(blank=True, null=True, help_text="Optional image URL") + link = models.URLField(help_text="Target URL (e.g., Amazon affiliate link)") + game = models.ForeignKey( + Game, + on_delete=models.CASCADE, + null=True, + blank=True, + help_text="Leave blank for generic ads shown on all games", + ) + weight = models.PositiveIntegerField( + default=1, help_text="Higher weight = more likely to be shown" + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + game_info = f" ({self.game.name})" if self.game else " (Generic)" + return f"{self.title}{game_info}" + + @property + def impressions_count(self): + return self.adimpression_set.count() + + @property + def clicks_count(self): + return self.adclick_set.count() + + @property + def ctr(self): + """Click-through rate as a percentage""" + if self.impressions_count == 0: + return 0 + return (self.clicks_count / self.impressions_count) * 100 + + +class AdImpression(models.Model): + """ + Tracks each time an ad is displayed to a user. + """ + + ad = models.ForeignKey(Ad, on_delete=models.CASCADE) + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-timestamp"] + indexes = [ + models.Index(fields=["ad", "-timestamp"]), + ] + + def __str__(self): + return f"Impression: {self.ad.title} at {self.timestamp}" + + +class AdClick(models.Model): + """ + Tracks each time a user clicks on an ad. + """ + + ad = models.ForeignKey(Ad, on_delete=models.CASCADE) + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-timestamp"] + indexes = [ + models.Index(fields=["ad", "-timestamp"]), + ] + + def __str__(self): + return f"Click: {self.ad.title} at {self.timestamp}" diff --git a/ads/services.py b/ads/services.py new file mode 100644 index 0000000..b64f749 --- /dev/null +++ b/ads/services.py @@ -0,0 +1,77 @@ +""" +Ad serving service with weighted random selection. +""" + +import random +from typing import Optional + +from ads.models import Ad, AdImpression +from games.models import Game + + +def get_ad_for_game(game: Game) -> Optional[Ad]: + """ + Get an ad for a specific game using weighted random selection. + + First tries to find game-specific ads. If none exist, falls back to generic ads. + + Args: + game: The game to get an ad for + + Returns: + An Ad instance, or None if no ads are available + """ + # Try to get game-specific ads first + game_ads = list(Ad.objects.filter(game=game)) + + if game_ads: + return _weighted_random_choice(game_ads) + + # Fallback to generic ads + generic_ads = list(Ad.objects.filter(game__isnull=True)) + + if generic_ads: + return _weighted_random_choice(generic_ads) + + return None + + +def _weighted_random_choice(ads: list[Ad]) -> Optional[Ad]: + """ + Select a random ad from the list using weighted random selection. + + Args: + ads: List of Ad objects to choose from + + Returns: + A randomly selected Ad based on weights, or None if list is empty + """ + if not ads: + return None + + # Get weights for all ads + weights = [ad.weight for ad in ads] + + # Use random.choices for weighted random selection + selected = random.choices(ads, weights=weights, k=1) + + return selected[0] + + +def serve_ad_with_impression(game: Game) -> Optional[Ad]: + """ + Get an ad for a game and log an impression. + + Args: + game: The game to get an ad for + + Returns: + An Ad instance with impression logged, or None if no ads available + """ + ad = get_ad_for_game(game) + + if ad: + # Log the impression + AdImpression.objects.create(ad=ad) + + return ad diff --git a/ads/tests.py b/ads/tests.py new file mode 100644 index 0000000..2eb001c --- /dev/null +++ b/ads/tests.py @@ -0,0 +1,232 @@ +from django.test import TestCase + +from ads.models import Ad, AdClick, AdImpression +from games.models import Game + + +class AdModelTest(TestCase): + def setUp(self): + self.game = Game.objects.create(name="Test Game", ingested=True) + + def test_create_generic_ad(self): + """Test creating a generic ad (no game association)""" + ad = Ad.objects.create( + title="Generic Ad", + description="This is a generic ad for all games", + link="https://example.com/product", + weight=5, + ) + + self.assertEqual(ad.title, "Generic Ad") + self.assertIsNone(ad.game) + self.assertEqual(ad.weight, 5) + self.assertEqual(str(ad), "Generic Ad (Generic)") + + def test_create_game_specific_ad(self): + """Test creating a game-specific ad""" + ad = Ad.objects.create( + title="Game Specific Ad", + description="This is a game-specific ad", + link="https://example.com/game-product", + game=self.game, + weight=10, + ) + + self.assertEqual(ad.title, "Game Specific Ad") + self.assertEqual(ad.game, self.game) + self.assertEqual(ad.weight, 10) + self.assertEqual(str(ad), f"Game Specific Ad ({self.game.name})") + + def test_ad_with_image(self): + """Test creating an ad with an image URL""" + ad = Ad.objects.create( + title="Ad with Image", + description="This ad has an image", + image="https://example.com/image.jpg", + link="https://example.com/product", + ) + + self.assertEqual(ad.image, "https://example.com/image.jpg") + + def test_ad_default_weight(self): + """Test that default weight is 1""" + ad = Ad.objects.create( + title="Default Weight Ad", + description="Testing default weight", + link="https://example.com/product", + ) + + self.assertEqual(ad.weight, 1) + + def test_impressions_count_zero(self): + """Test that impressions_count returns 0 when no impressions exist""" + ad = Ad.objects.create( + title="Test Ad", + description="Testing impressions", + link="https://example.com/product", + ) + + self.assertEqual(ad.impressions_count, 0) + + def test_impressions_count(self): + """Test that impressions_count returns correct count""" + ad = Ad.objects.create( + title="Test Ad", + description="Testing impressions", + link="https://example.com/product", + ) + + # Create some impressions + AdImpression.objects.create(ad=ad) + AdImpression.objects.create(ad=ad) + AdImpression.objects.create(ad=ad) + + self.assertEqual(ad.impressions_count, 3) + + def test_clicks_count_zero(self): + """Test that clicks_count returns 0 when no clicks exist""" + ad = Ad.objects.create( + title="Test Ad", + description="Testing clicks", + link="https://example.com/product", + ) + + self.assertEqual(ad.clicks_count, 0) + + def test_clicks_count(self): + """Test that clicks_count returns correct count""" + ad = Ad.objects.create( + title="Test Ad", + description="Testing clicks", + link="https://example.com/product", + ) + + # Create some clicks + AdClick.objects.create(ad=ad) + AdClick.objects.create(ad=ad) + + self.assertEqual(ad.clicks_count, 2) + + def test_ctr_zero_impressions(self): + """Test that CTR returns 0 when there are no impressions""" + ad = Ad.objects.create( + title="Test Ad", + description="Testing CTR", + link="https://example.com/product", + ) + + self.assertEqual(ad.ctr, 0) + + def test_ctr_calculation(self): + """Test CTR calculation""" + ad = Ad.objects.create( + title="Test Ad", + description="Testing CTR", + link="https://example.com/product", + ) + + # Create 10 impressions and 2 clicks (20% CTR) + for _ in range(10): + AdImpression.objects.create(ad=ad) + for _ in range(2): + AdClick.objects.create(ad=ad) + + self.assertEqual(ad.ctr, 20.0) + + def test_ctr_perfect_conversion(self): + """Test CTR when every impression becomes a click""" + ad = Ad.objects.create( + title="Test Ad", + description="Testing CTR", + link="https://example.com/product", + ) + + # Create 5 impressions and 5 clicks (100% CTR) + for _ in range(5): + AdImpression.objects.create(ad=ad) + AdClick.objects.create(ad=ad) + + self.assertEqual(ad.ctr, 100.0) + + +class AdImpressionModelTest(TestCase): + def test_create_impression(self): + """Test creating an ad impression""" + ad = Ad.objects.create( + title="Test Ad", + description="Testing impression creation", + link="https://example.com/product", + ) + + impression = AdImpression.objects.create(ad=ad) + + self.assertEqual(impression.ad, ad) + self.assertIsNotNone(impression.timestamp) + self.assertTrue(str(impression).startswith(f"Impression: {ad.title}")) + + def test_impression_ordering(self): + """Test that impressions are ordered by timestamp (newest first)""" + ad = Ad.objects.create( + title="Test Ad", + description="Testing impression ordering", + link="https://example.com/product", + ) + + impression1 = AdImpression.objects.create(ad=ad) + impression2 = AdImpression.objects.create(ad=ad) + impression3 = AdImpression.objects.create(ad=ad) + + impressions = AdImpression.objects.all() + self.assertEqual(impressions[0], impression3) + self.assertEqual(impressions[1], impression2) + self.assertEqual(impressions[2], impression1) + + +class AdClickModelTest(TestCase): + def test_create_click(self): + """Test creating an ad click""" + ad = Ad.objects.create( + title="Test Ad", + description="Testing click creation", + link="https://example.com/product", + ) + + click = AdClick.objects.create(ad=ad) + + self.assertEqual(click.ad, ad) + self.assertIsNotNone(click.timestamp) + self.assertTrue(str(click).startswith(f"Click: {ad.title}")) + + def test_click_ordering(self): + """Test that clicks are ordered by timestamp (newest first)""" + ad = Ad.objects.create( + title="Test Ad", + description="Testing click ordering", + link="https://example.com/product", + ) + + click1 = AdClick.objects.create(ad=ad) + click2 = AdClick.objects.create(ad=ad) + click3 = AdClick.objects.create(ad=ad) + + clicks = AdClick.objects.all() + self.assertEqual(clicks[0], click3) + self.assertEqual(clicks[1], click2) + self.assertEqual(clicks[2], click1) + + def test_cascade_delete(self): + """Test that clicks are deleted when ad is deleted""" + ad = Ad.objects.create( + title="Test Ad", + description="Testing cascade delete", + link="https://example.com/product", + ) + + AdClick.objects.create(ad=ad) + AdClick.objects.create(ad=ad) + + self.assertEqual(AdClick.objects.count(), 2) + + ad.delete() + + self.assertEqual(AdClick.objects.count(), 0) diff --git a/ads/views.py b/ads/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/ads/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/rulesbot/settings.py b/rulesbot/settings.py index 994cd7c..226dae8 100644 --- a/rulesbot/settings.py +++ b/rulesbot/settings.py @@ -60,6 +60,7 @@ # Application definition INSTALLED_APPS = [ + "ads.apps.AdsConfig", "chat.apps.ChatConfig", "games.apps.GamesConfig", "pages.apps.PagesConfig", From 1a017f89db7355f2e106c7bb0cac205b00a505fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Blond=20Daugaard?= Date: Sun, 5 Oct 2025 21:50:26 -0400 Subject: [PATCH 3/8] Integrate ads service with chat. --- .docs/prds/ads.md | 16 ++- ads/tests.py | 232 ++++++++++++++++++++++++++++++++++ ads/urls.py | 14 ++ ads/views.py | 14 +- chat/templates/chat/chat.html | 37 ++++-- chat/views.py | 11 +- rulesbot/urls.py | 1 + 7 files changed, 308 insertions(+), 17 deletions(-) create mode 100644 ads/urls.py diff --git a/.docs/prds/ads.md b/.docs/prds/ads.md index 2cdbf4d..8f9a6f4 100644 --- a/.docs/prds/ads.md +++ b/.docs/prds/ads.md @@ -151,13 +151,15 @@ When done, mark the phase as complete. - [x] Create and run migrations - [x] Write model tests (Ad, AdImpression, AdClick) -### Phase 2: Ad Selection & Serving Logic ⏳ -- [ ] Implement weighted random ad selection service -- [ ] Create ad serving function (context-aware: game-specific vs generic) -- [ ] Create context processor to inject ads into chat page context -- [ ] Log impressions when ad is served -- [ ] Write tests for weighted selection algorithm -- [ ] Write tests for game-specific vs generic fallback logic +### Phase 2: Ad Selection & Serving Logic ✅ +- [x] Implement weighted random ad selection service +- [x] Create ad serving function (context-aware: game-specific vs generic) +- [x] Inject ad context directly in chat view (view_chat_session) +- [x] Log impressions when ad is served +- [x] Write tests for weighted selection algorithm +- [x] Write tests for game-specific vs generic fallback logic +- [x] Update chat template to display ads in sidebar +- [x] Create ads URL structure (placeholder for Phase 3) ### Phase 3: Click Tracking ⏳ - [ ] Create URL patterns for ads app diff --git a/ads/tests.py b/ads/tests.py index 2eb001c..4ff4fc7 100644 --- a/ads/tests.py +++ b/ads/tests.py @@ -230,3 +230,235 @@ def test_cascade_delete(self): ad.delete() self.assertEqual(AdClick.objects.count(), 0) + + +class AdSelectionServiceTest(TestCase): + """Tests for ad selection service functions""" + + def setUp(self): + self.game = Game.objects.create(name="Test Game", ingested=True) + + def test_weighted_random_selection_with_single_ad(self): + """Test that weighted selection works with a single ad""" + from ads.services import _weighted_random_choice + + ad = Ad.objects.create( + title="Single Ad", + description="Only one ad", + link="https://example.com/product", + weight=5, + ) + + result = _weighted_random_choice([ad]) + self.assertEqual(result, ad) + + def test_weighted_random_selection_with_empty_list(self): + """Test that weighted selection returns None for empty list""" + from ads.services import _weighted_random_choice + + result = _weighted_random_choice([]) + self.assertIsNone(result) + + def test_weighted_random_selection_respects_weights(self): + """Test that ads with higher weights are selected more often""" + from ads.services import _weighted_random_choice + + # Create ads with very different weights + low_weight_ad = Ad.objects.create( + title="Low Weight", + description="Should be selected less", + link="https://example.com/low", + weight=1, + ) + high_weight_ad = Ad.objects.create( + title="High Weight", + description="Should be selected more", + link="https://example.com/high", + weight=100, + ) + + ads = [low_weight_ad, high_weight_ad] + + # Run selection 100 times and count results + selections = {} + for _ in range(100): + selected = _weighted_random_choice(ads) + selections[selected.id] = selections.get(selected.id, 0) + 1 + + # High weight ad should be selected significantly more often + # With weights 1:100, we expect roughly 1:100 ratio + # Allow some variance but high weight should dominate + self.assertGreater( + selections[high_weight_ad.id], + selections.get(low_weight_ad.id, 0), + "Higher weighted ad should be selected more often", + ) + + def test_get_ad_for_game_returns_game_specific_ad(self): + """Test that game-specific ads are returned when available""" + from ads.services import get_ad_for_game + + # Create a game-specific ad + game_ad = Ad.objects.create( + title="Game Specific Ad", + description="For this game", + link="https://example.com/game", + game=self.game, + weight=1, + ) + + # Create a generic ad + Ad.objects.create( + title="Generic Ad", + description="For all games", + link="https://example.com/generic", + weight=1, + ) + + result = get_ad_for_game(self.game) + self.assertEqual(result, game_ad) + + def test_get_ad_for_game_falls_back_to_generic(self): + """Test that generic ads are returned when no game-specific ads exist""" + from ads.services import get_ad_for_game + + # Create only a generic ad + generic_ad = Ad.objects.create( + title="Generic Ad", + description="For all games", + link="https://example.com/generic", + weight=1, + ) + + result = get_ad_for_game(self.game) + self.assertEqual(result, generic_ad) + + def test_get_ad_for_game_returns_none_when_no_ads(self): + """Test that None is returned when no ads are available""" + from ads.services import get_ad_for_game + + result = get_ad_for_game(self.game) + self.assertIsNone(result) + + def test_get_ad_for_game_prefers_game_specific_over_generic(self): + """Test that game-specific ads are prioritized over generic ads""" + from ads.services import get_ad_for_game + + # Create game-specific ads for this game + game_ad = Ad.objects.create( + title="Game Ad", + description="For this game", + link="https://example.com/game", + game=self.game, + weight=1, + ) + + # Create generic ads + Ad.objects.create( + title="Generic Ad", + description="For all games", + link="https://example.com/generic", + weight=100, # Higher weight but should not be selected + ) + + # Run multiple times to ensure game-specific is always preferred + for _ in range(10): + result = get_ad_for_game(self.game) + self.assertEqual( + result, + game_ad, + "Game-specific ad should always be selected when available", + ) + + def test_serve_ad_with_impression_logs_impression(self): + """Test that serving an ad logs an impression""" + from ads.services import serve_ad_with_impression + + ad = Ad.objects.create( + title="Test Ad", + description="Test impression logging", + link="https://example.com/product", + game=self.game, + ) + + self.assertEqual(AdImpression.objects.count(), 0) + + result = serve_ad_with_impression(self.game) + + self.assertEqual(result, ad) + self.assertEqual(AdImpression.objects.count(), 1) + self.assertEqual(AdImpression.objects.first().ad, ad) + + def test_serve_ad_with_impression_no_ads_available(self): + """Test that serving an ad returns None and logs no impression when no ads available""" + from ads.services import serve_ad_with_impression + + result = serve_ad_with_impression(self.game) + + self.assertIsNone(result) + self.assertEqual(AdImpression.objects.count(), 0) + + def test_weighted_selection_with_multiple_game_specific_ads(self): + """Test weighted selection among multiple game-specific ads""" + from ads.services import get_ad_for_game + + # Create multiple game-specific ads with different weights + ad1 = Ad.objects.create( + title="Ad 1", + description="Weight 1", + link="https://example.com/1", + game=self.game, + weight=1, + ) + ad2 = Ad.objects.create( + title="Ad 2", + description="Weight 5", + link="https://example.com/2", + game=self.game, + weight=5, + ) + ad3 = Ad.objects.create( + title="Ad 3", + description="Weight 10", + link="https://example.com/3", + game=self.game, + weight=10, + ) + + # Run selection multiple times and ensure all ads can be selected + selected_ids = set() + for _ in range(50): + result = get_ad_for_game(self.game) + selected_ids.add(result.id) + + self.assertIn(ad1.id, selected_ids) + self.assertIn(ad2.id, selected_ids) + self.assertIn(ad3.id, selected_ids) + + def test_weighted_selection_with_multiple_generic_ads(self): + """Test weighted selection among multiple generic ads""" + from ads.services import get_ad_for_game + + # Create multiple generic ads with different weights + ad1 = Ad.objects.create( + title="Generic 1", + description="Weight 1", + link="https://example.com/1", + weight=1, + ) + ad2 = Ad.objects.create( + title="Generic 2", + description="Weight 3", + link="https://example.com/2", + weight=3, + ) + + # Run selection multiple times and ensure ads can be selected + selected_ids = set() + for _ in range(50): + result = get_ad_for_game(self.game) + selected_ids.add(result.id) + + # Both ads should be selected at least once in 50 tries + self.assertIn(ad1.id, selected_ids) + self.assertIn(ad2.id, selected_ids) diff --git a/ads/urls.py b/ads/urls.py new file mode 100644 index 0000000..9c4bcfd --- /dev/null +++ b/ads/urls.py @@ -0,0 +1,14 @@ +""" +URL configuration for ads app. +""" + +from django.urls import path + +from ads import views + +app_name = "ads" + +urlpatterns = [ + # Click tracking endpoint (to be implemented in Phase 3) + path("click//", views.ad_click_redirect, name="click"), +] diff --git a/ads/views.py b/ads/views.py index 60f00ef..8b06340 100644 --- a/ads/views.py +++ b/ads/views.py @@ -1 +1,13 @@ -# Create your views here. +""" +Views for the ads app. +""" + +from django.http import HttpResponse + + +def ad_click_redirect(request, ad_id): + """ + Placeholder view for ad click tracking and redirect. + Will be fully implemented in Phase 3. + """ + return HttpResponse("Ad click tracking - to be implemented in Phase 3", status=501) diff --git a/chat/templates/chat/chat.html b/chat/templates/chat/chat.html index 8ed3e73..f7a8cdb 100644 --- a/chat/templates/chat/chat.html +++ b/chat/templates/chat/chat.html @@ -33,14 +33,35 @@ New chat - {% else %} -
    -
  • - Create an account to save your chat sessions! -
  • - Sign up! -
- {% endif %} + {% else %} +
    +
  • + Create an account to save your chat sessions! +
  • + Sign up! +
+ {% endif %} + + {% if ad %} +
+
+ {% if ad.image %} + + {{ ad.title }} + + {% endif %} +
+ + {{ ad.title }} + +
+

{{ ad.description }}

+ + Learn More + +
+
+ {% endif %}
diff --git a/chat/views.py b/chat/views.py index 93f708d..e76f314 100644 --- a/chat/views.py +++ b/chat/views.py @@ -7,6 +7,7 @@ from django.urls import reverse from django.views import generic +from ads.services import serve_ad_with_impression from chat.forms import ChatForm from chat.models import ChatSession from chat.services import streaming_question_answering_service @@ -61,10 +62,18 @@ def view_chat_session(request, session_slug): reverse=True, ) + # Get an ad for this game and log the impression + ad = serve_ad_with_impression(chat_session.game) + return render( request, "chat/chat.html", - {"chat_session": chat_session, "form": ChatForm(), "sessions": sessions[:7]}, + { + "chat_session": chat_session, + "form": ChatForm(), + "sessions": sessions[:7], + "ad": ad, + }, ) diff --git a/rulesbot/urls.py b/rulesbot/urls.py index 1300c18..9a8aeeb 100644 --- a/rulesbot/urls.py +++ b/rulesbot/urls.py @@ -30,6 +30,7 @@ path("games/", include("games.urls")), path("chat/", include("chat.urls")), path("users/", include("users.urls")), + path("ads/", include("ads.urls")), path("admin/", admin.site.urls), path( "sitemap.xml", From 98d6c39c438eac7a623a25d9ddbdf116fe356657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Blond=20Daugaard?= Date: Mon, 6 Oct 2025 20:55:20 -0400 Subject: [PATCH 4/8] Implement ad click and redirect. --- .docs/prds/ads.md | 8 +++---- ads/models.py | 4 +++- ads/services.py | 13 ++++++++++- ads/tests.py | 57 +++++++++++++++++++++++++++++++++++++++++++++++ ads/views.py | 13 +++++++++-- 5 files changed, 87 insertions(+), 8 deletions(-) diff --git a/.docs/prds/ads.md b/.docs/prds/ads.md index 8f9a6f4..da039f7 100644 --- a/.docs/prds/ads.md +++ b/.docs/prds/ads.md @@ -162,10 +162,10 @@ When done, mark the phase as complete. - [x] Create ads URL structure (placeholder for Phase 3) ### Phase 3: Click Tracking ⏳ -- [ ] Create URL patterns for ads app -- [ ] Implement ad click tracking redirect view (/ads/click/) -- [ ] Log clicks and redirect to target URL -- [ ] Write tests for click tracking and redirect behavior +- [x] Create URL patterns for ads app +- [x] Implement ad click tracking redirect view (/ads/click/) +- [x] Log clicks and redirect to target URL +- [x] Write tests for click tracking and redirect behavior ### Phase 4: Django Admin Integration ⏳ - [ ] Register Ad model in Django admin diff --git a/ads/models.py b/ads/models.py index 6adc152..00bef98 100644 --- a/ads/models.py +++ b/ads/models.py @@ -12,7 +12,9 @@ class Ad(models.Model): title = models.CharField(max_length=255) description = models.TextField(max_length=500) image = models.URLField(blank=True, null=True, help_text="Optional image URL") - link = models.URLField(help_text="Target URL (e.g., Amazon affiliate link)") + link = models.URLField( + help_text="Target URL (e.g., Amazon affiliate link)", null=False, blank=False + ) game = models.ForeignKey( Game, on_delete=models.CASCADE, diff --git a/ads/services.py b/ads/services.py index b64f749..55c0980 100644 --- a/ads/services.py +++ b/ads/services.py @@ -5,7 +5,7 @@ import random from typing import Optional -from ads.models import Ad, AdImpression +from ads.models import Ad, AdClick, AdImpression from games.models import Game @@ -75,3 +75,14 @@ def serve_ad_with_impression(game: Game) -> Optional[Ad]: AdImpression.objects.create(ad=ad) return ad + + +def record_ad_click(ad: Ad): + """ + Record a click for the given ad. + + Args: + ad: The Ad instance that was clicked + """ + click = AdClick.objects.create(ad=ad) + return click diff --git a/ads/tests.py b/ads/tests.py index 4ff4fc7..c8c42b9 100644 --- a/ads/tests.py +++ b/ads/tests.py @@ -462,3 +462,60 @@ def test_weighted_selection_with_multiple_generic_ads(self): # Both ads should be selected at least once in 50 tries self.assertIn(ad1.id, selected_ids) self.assertIn(ad2.id, selected_ids) + + +class AdClickViewTest(TestCase): + def setUp(self): + self.game = Game.objects.create(name="Test Game", ingested=True) + self.ad = Ad.objects.create( + title="Test Ad", + description="Testing ad click view", + link="https://example.com/product", + game=self.game, + ) + + def test_ad_click_redirect_valid_ad(self): + """Test ad click redirect with a valid ad ID""" + from django.test import RequestFactory + + from ads.views import ad_click_redirect + + factory = RequestFactory() + request = factory.get(f"/ads/click/{self.ad.id}/") + + response = ad_click_redirect(request, self.ad.id) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.ad.link) + self.assertEqual(AdClick.objects.count(), 1) + self.assertEqual(AdClick.objects.first().ad, self.ad) + + def test_ad_click_redirect_invalid_ad(self): + """Test ad click redirect with an invalid ad ID""" + from django.test import RequestFactory + + from ads.views import ad_click_redirect + + factory = RequestFactory() + request = factory.get("/ads/click/9999/") # Non-existent ad ID + + response = ad_click_redirect(request, 9999) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/") # Redirects to homepage + self.assertEqual(AdClick.objects.count(), 0) + + def test_ad_click_redirect_invalid_ad_with_referrer(self): + """Test ad click redirect with an invalid ad ID and a referrer header""" + from django.test import RequestFactory + + from ads.views import ad_click_redirect + + factory = RequestFactory() + request = factory.get("/ads/click/9999/", HTTP_REFERER="/some-page/") + + response = ad_click_redirect(request, 9999) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/some-page/") # Redirects to referrer + self.assertEqual(AdClick.objects.count(), 0) diff --git a/ads/views.py b/ads/views.py index 8b06340..ea46160 100644 --- a/ads/views.py +++ b/ads/views.py @@ -2,7 +2,10 @@ Views for the ads app. """ -from django.http import HttpResponse +from django.http import HttpResponseRedirect + +from ads.models import Ad +from ads.services import record_ad_click def ad_click_redirect(request, ad_id): @@ -10,4 +13,10 @@ def ad_click_redirect(request, ad_id): Placeholder view for ad click tracking and redirect. Will be fully implemented in Phase 3. """ - return HttpResponse("Ad click tracking - to be implemented in Phase 3", status=501) + ad = Ad.objects.filter(id=ad_id).first() + if ad and ad.link: + record_ad_click(ad) + return HttpResponseRedirect(ad.link) + # if the ad doesn't exist or has no link, try to redirect to the referrer or homepage + referrer = request.META.get("HTTP_REFERER", "/") + return HttpResponseRedirect(referrer) From b40699b7790596631bdeb0066d7c64f94eaa4dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Blond=20Daugaard?= Date: Mon, 6 Oct 2025 20:57:47 -0400 Subject: [PATCH 5/8] Migrate ad to use file field. --- ads/migrations/0002_alter_ad_image.py | 22 ++++++++++++++++++++++ ads/models.py | 4 +++- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 ads/migrations/0002_alter_ad_image.py diff --git a/ads/migrations/0002_alter_ad_image.py b/ads/migrations/0002_alter_ad_image.py new file mode 100644 index 0000000..be01bcd --- /dev/null +++ b/ads/migrations/0002_alter_ad_image.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.23 on 2025-10-07 00:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("ads", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="ad", + name="image", + field=models.FileField( + blank=True, + help_text="Optional image URL", + null=True, + upload_to="ads/images", + ), + ), + ] diff --git a/ads/models.py b/ads/models.py index 00bef98..00acb39 100644 --- a/ads/models.py +++ b/ads/models.py @@ -11,7 +11,9 @@ class Ad(models.Model): title = models.CharField(max_length=255) description = models.TextField(max_length=500) - image = models.URLField(blank=True, null=True, help_text="Optional image URL") + image = models.FileField( + upload_to="ads/images", null=True, blank=True, help_text="Optional image URL" + ) link = models.URLField( help_text="Target URL (e.g., Amazon affiliate link)", null=False, blank=False ) From 582e9d6cb1a06b49d9833bc930dd5f8492daf337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Blond=20Daugaard?= Date: Mon, 6 Oct 2025 21:35:05 -0400 Subject: [PATCH 6/8] Add admin inteface for ads. --- .docs/prds/ads.md | 13 +- ads/admin.py | 88 +++- ads/models.py | 69 ++- ads/templates/admin/ads/ad/change_list.html | 8 + ads/templates/admin/ads/analytics.html | 150 ++++++ ads/tests.py | 521 -------------------- ads/tests/__init__.py | 14 + ads/tests/test_admin.py | 249 ++++++++++ ads/tests/test_models.py | 142 ++++++ ads/tests/test_services.py | 219 ++++++++ ads/tests/test_views.py | 50 ++ templates/admin/index.html | 18 + tests/agents.md | 18 + 13 files changed, 1018 insertions(+), 541 deletions(-) create mode 100644 ads/templates/admin/ads/ad/change_list.html create mode 100644 ads/templates/admin/ads/analytics.html delete mode 100644 ads/tests.py create mode 100644 ads/tests/__init__.py create mode 100644 ads/tests/test_admin.py create mode 100644 ads/tests/test_models.py create mode 100644 ads/tests/test_services.py create mode 100644 ads/tests/test_views.py create mode 100644 templates/admin/index.html create mode 100644 tests/agents.md diff --git a/.docs/prds/ads.md b/.docs/prds/ads.md index da039f7..92a240b 100644 --- a/.docs/prds/ads.md +++ b/.docs/prds/ads.md @@ -167,12 +167,13 @@ When done, mark the phase as complete. - [x] Log clicks and redirect to target URL - [x] Write tests for click tracking and redirect behavior -### Phase 4: Django Admin Integration ⏳ -- [ ] Register Ad model in Django admin -- [ ] Add custom admin list display with impression/click counts -- [ ] Add CTR calculation to admin -- [ ] Register AdImpression and AdClick models (read-only) -- [ ] Write tests for admin custom fields and calculations +### Phase 4: Django Admin Integration ✅ +- [x] Register Ad model in Django admin +- [x] Add custom admin list display with basic fields +- [x] Create analytics dashboard view with time-based filtering +- [x] Implement custom analytics template with CTR display +- [x] Add time-based metric methods to Ad model +- [x] Write comprehensive tests for analytics view ### Phase 5: Frontend Display ⏳ - [ ] Update chat.html template to display ads in sidebar diff --git a/ads/admin.py b/ads/admin.py index 846f6b4..b344eb4 100644 --- a/ads/admin.py +++ b/ads/admin.py @@ -1 +1,87 @@ -# Register your models here. +from datetime import timedelta + +from django.contrib import admin +from django.shortcuts import render +from django.urls import path, reverse +from django.utils import timezone + +from .models import Ad + + +class AdAdmin(admin.ModelAdmin): + list_display = ("title", "game", "weight", "created_at", "updated_at") + list_filter = ("game", "weight", "created_at") + search_fields = ("title", "description") + readonly_fields = ("created_at", "updated_at") + + def changelist_view(self, request, extra_context=None): + """Override changelist to add analytics button""" + extra_context = extra_context or {} + extra_context["analytics_url"] = reverse("admin:ads_ad_analytics") + return super().changelist_view(request, extra_context) + + def get_queryset(self, request): + # Optimize query with select_related + return super().get_queryset(request).select_related("game") + + def get_urls(self): + """Add custom analytics URL to admin""" + urls = super().get_urls() + custom_urls = [ + path( + "analytics/", + self.admin_site.admin_view(self.analytics_view), + name="ads_ad_analytics", + ), + ] + return custom_urls + urls + + def analytics_view(self, request): + """Custom analytics dashboard view""" + # Get time period filter from query params (default to 'all') + period = request.GET.get("period", "all") + + # Calculate date range based on period + end_date = timezone.now() + if period == "7days": + start_date = end_date - timedelta(days=7) + period_label = "Last 7 Days" + elif period == "30days": + start_date = end_date - timedelta(days=30) + period_label = "Last 30 Days" + else: + start_date = None + period_label = "All Time" + + # Get all ads with metrics + ads = Ad.objects.select_related("game").all() + + # Calculate metrics for each ad + analytics_data = [] + for ad in ads: + impressions = ad.get_impressions_count(start_date, end_date) + clicks = ad.get_clicks_count(start_date, end_date) + ctr = ad.get_ctr(start_date, end_date) + + analytics_data.append( + { + "ad": ad, + "impressions": impressions, + "clicks": clicks, + "ctr": ctr, + } + ) + + context = { + **self.admin_site.each_context(request), + "title": "Ad Analytics", + "analytics_data": analytics_data, + "current_period": period, + "period_label": period_label, + "opts": self.model._meta, + } + + return render(request, "admin/ads/analytics.html", context) + + +admin.site.register(Ad, AdAdmin) diff --git a/ads/models.py b/ads/models.py index 00acb39..5940acf 100644 --- a/ads/models.py +++ b/ads/models.py @@ -38,20 +38,63 @@ def __str__(self): game_info = f" ({self.game.name})" if self.game else " (Generic)" return f"{self.title}{game_info}" - @property - def impressions_count(self): - return self.adimpression_set.count() - - @property - def clicks_count(self): - return self.adclick_set.count() - - @property - def ctr(self): - """Click-through rate as a percentage""" - if self.impressions_count == 0: + def get_impressions_count(self, start_date=None, end_date=None): + """ + Get impression count with optional date filtering. + + Args: + start_date: Start date for filtering (inclusive) + end_date: End date for filtering (inclusive) + + Returns: + Count of impressions within the date range + """ + queryset = self.adimpression_set.all() + + if start_date: + queryset = queryset.filter(timestamp__gte=start_date) + if end_date: + queryset = queryset.filter(timestamp__lte=end_date) + + return queryset.count() + + def get_clicks_count(self, start_date=None, end_date=None): + """ + Get clicks count with optional date filtering. + + Args: + start_date: Start date for filtering (inclusive) + end_date: End date for filtering (inclusive) + + Returns: + Count of clicks within the date range + """ + queryset = self.adclick_set.all() + + if start_date: + queryset = queryset.filter(timestamp__gte=start_date) + if end_date: + queryset = queryset.filter(timestamp__lte=end_date) + + return queryset.count() + + def get_ctr(self, start_date=None, end_date=None): + """ + Get CTR with optional date filtering. + + Args: + start_date: Start date for filtering (inclusive) + end_date: End date for filtering (inclusive) + + Returns: + Click-through rate as a percentage + """ + impressions = self.get_impressions_count(start_date, end_date) + if impressions == 0: return 0 - return (self.clicks_count / self.impressions_count) * 100 + + clicks = self.get_clicks_count(start_date, end_date) + return (clicks / impressions) * 100 class AdImpression(models.Model): diff --git a/ads/templates/admin/ads/ad/change_list.html b/ads/templates/admin/ads/ad/change_list.html new file mode 100644 index 0000000..cc1f657 --- /dev/null +++ b/ads/templates/admin/ads/ad/change_list.html @@ -0,0 +1,8 @@ +{% extends "admin/change_list.html" %} + +{% block object-tools-items %} +
  • + View Analytics +
  • + {{ block.super }} +{% endblock %} diff --git a/ads/templates/admin/ads/analytics.html b/ads/templates/admin/ads/analytics.html new file mode 100644 index 0000000..f0d542f --- /dev/null +++ b/ads/templates/admin/ads/analytics.html @@ -0,0 +1,150 @@ +{% extends "admin/base_site.html" %} +{% load static %} + +{% block extrahead %} +{{ block.super }} + +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +

    Ad Analytics - {{ period_label }}

    + +
    + Time Period: + Last 7 Days + Last 30 Days + All Time +
    + +{% if analytics_data %} + + + + + + + + + + + + {% for item in analytics_data %} + + + + + + + + {% endfor %} + +
    Ad TitleGameImpressionsClicksCTR
    + + {{ item.ad.title }} + + + {% if item.ad.game %} + {{ item.ad.game.name }} + {% else %} + Generic + {% endif %} + {{ item.impressions }}{{ item.clicks }} + {% if item.ctr >= 5 %} + {{ item.ctr|floatformat:2 }}% + {% elif item.ctr >= 2 %} + {{ item.ctr|floatformat:2 }}% + {% else %} + {{ item.ctr|floatformat:2 }}% + {% endif %} +
    +{% else %} +
    +

    No ads found. Create your first ad

    +
    +{% endif %} + +{% endblock %} diff --git a/ads/tests.py b/ads/tests.py deleted file mode 100644 index c8c42b9..0000000 --- a/ads/tests.py +++ /dev/null @@ -1,521 +0,0 @@ -from django.test import TestCase - -from ads.models import Ad, AdClick, AdImpression -from games.models import Game - - -class AdModelTest(TestCase): - def setUp(self): - self.game = Game.objects.create(name="Test Game", ingested=True) - - def test_create_generic_ad(self): - """Test creating a generic ad (no game association)""" - ad = Ad.objects.create( - title="Generic Ad", - description="This is a generic ad for all games", - link="https://example.com/product", - weight=5, - ) - - self.assertEqual(ad.title, "Generic Ad") - self.assertIsNone(ad.game) - self.assertEqual(ad.weight, 5) - self.assertEqual(str(ad), "Generic Ad (Generic)") - - def test_create_game_specific_ad(self): - """Test creating a game-specific ad""" - ad = Ad.objects.create( - title="Game Specific Ad", - description="This is a game-specific ad", - link="https://example.com/game-product", - game=self.game, - weight=10, - ) - - self.assertEqual(ad.title, "Game Specific Ad") - self.assertEqual(ad.game, self.game) - self.assertEqual(ad.weight, 10) - self.assertEqual(str(ad), f"Game Specific Ad ({self.game.name})") - - def test_ad_with_image(self): - """Test creating an ad with an image URL""" - ad = Ad.objects.create( - title="Ad with Image", - description="This ad has an image", - image="https://example.com/image.jpg", - link="https://example.com/product", - ) - - self.assertEqual(ad.image, "https://example.com/image.jpg") - - def test_ad_default_weight(self): - """Test that default weight is 1""" - ad = Ad.objects.create( - title="Default Weight Ad", - description="Testing default weight", - link="https://example.com/product", - ) - - self.assertEqual(ad.weight, 1) - - def test_impressions_count_zero(self): - """Test that impressions_count returns 0 when no impressions exist""" - ad = Ad.objects.create( - title="Test Ad", - description="Testing impressions", - link="https://example.com/product", - ) - - self.assertEqual(ad.impressions_count, 0) - - def test_impressions_count(self): - """Test that impressions_count returns correct count""" - ad = Ad.objects.create( - title="Test Ad", - description="Testing impressions", - link="https://example.com/product", - ) - - # Create some impressions - AdImpression.objects.create(ad=ad) - AdImpression.objects.create(ad=ad) - AdImpression.objects.create(ad=ad) - - self.assertEqual(ad.impressions_count, 3) - - def test_clicks_count_zero(self): - """Test that clicks_count returns 0 when no clicks exist""" - ad = Ad.objects.create( - title="Test Ad", - description="Testing clicks", - link="https://example.com/product", - ) - - self.assertEqual(ad.clicks_count, 0) - - def test_clicks_count(self): - """Test that clicks_count returns correct count""" - ad = Ad.objects.create( - title="Test Ad", - description="Testing clicks", - link="https://example.com/product", - ) - - # Create some clicks - AdClick.objects.create(ad=ad) - AdClick.objects.create(ad=ad) - - self.assertEqual(ad.clicks_count, 2) - - def test_ctr_zero_impressions(self): - """Test that CTR returns 0 when there are no impressions""" - ad = Ad.objects.create( - title="Test Ad", - description="Testing CTR", - link="https://example.com/product", - ) - - self.assertEqual(ad.ctr, 0) - - def test_ctr_calculation(self): - """Test CTR calculation""" - ad = Ad.objects.create( - title="Test Ad", - description="Testing CTR", - link="https://example.com/product", - ) - - # Create 10 impressions and 2 clicks (20% CTR) - for _ in range(10): - AdImpression.objects.create(ad=ad) - for _ in range(2): - AdClick.objects.create(ad=ad) - - self.assertEqual(ad.ctr, 20.0) - - def test_ctr_perfect_conversion(self): - """Test CTR when every impression becomes a click""" - ad = Ad.objects.create( - title="Test Ad", - description="Testing CTR", - link="https://example.com/product", - ) - - # Create 5 impressions and 5 clicks (100% CTR) - for _ in range(5): - AdImpression.objects.create(ad=ad) - AdClick.objects.create(ad=ad) - - self.assertEqual(ad.ctr, 100.0) - - -class AdImpressionModelTest(TestCase): - def test_create_impression(self): - """Test creating an ad impression""" - ad = Ad.objects.create( - title="Test Ad", - description="Testing impression creation", - link="https://example.com/product", - ) - - impression = AdImpression.objects.create(ad=ad) - - self.assertEqual(impression.ad, ad) - self.assertIsNotNone(impression.timestamp) - self.assertTrue(str(impression).startswith(f"Impression: {ad.title}")) - - def test_impression_ordering(self): - """Test that impressions are ordered by timestamp (newest first)""" - ad = Ad.objects.create( - title="Test Ad", - description="Testing impression ordering", - link="https://example.com/product", - ) - - impression1 = AdImpression.objects.create(ad=ad) - impression2 = AdImpression.objects.create(ad=ad) - impression3 = AdImpression.objects.create(ad=ad) - - impressions = AdImpression.objects.all() - self.assertEqual(impressions[0], impression3) - self.assertEqual(impressions[1], impression2) - self.assertEqual(impressions[2], impression1) - - -class AdClickModelTest(TestCase): - def test_create_click(self): - """Test creating an ad click""" - ad = Ad.objects.create( - title="Test Ad", - description="Testing click creation", - link="https://example.com/product", - ) - - click = AdClick.objects.create(ad=ad) - - self.assertEqual(click.ad, ad) - self.assertIsNotNone(click.timestamp) - self.assertTrue(str(click).startswith(f"Click: {ad.title}")) - - def test_click_ordering(self): - """Test that clicks are ordered by timestamp (newest first)""" - ad = Ad.objects.create( - title="Test Ad", - description="Testing click ordering", - link="https://example.com/product", - ) - - click1 = AdClick.objects.create(ad=ad) - click2 = AdClick.objects.create(ad=ad) - click3 = AdClick.objects.create(ad=ad) - - clicks = AdClick.objects.all() - self.assertEqual(clicks[0], click3) - self.assertEqual(clicks[1], click2) - self.assertEqual(clicks[2], click1) - - def test_cascade_delete(self): - """Test that clicks are deleted when ad is deleted""" - ad = Ad.objects.create( - title="Test Ad", - description="Testing cascade delete", - link="https://example.com/product", - ) - - AdClick.objects.create(ad=ad) - AdClick.objects.create(ad=ad) - - self.assertEqual(AdClick.objects.count(), 2) - - ad.delete() - - self.assertEqual(AdClick.objects.count(), 0) - - -class AdSelectionServiceTest(TestCase): - """Tests for ad selection service functions""" - - def setUp(self): - self.game = Game.objects.create(name="Test Game", ingested=True) - - def test_weighted_random_selection_with_single_ad(self): - """Test that weighted selection works with a single ad""" - from ads.services import _weighted_random_choice - - ad = Ad.objects.create( - title="Single Ad", - description="Only one ad", - link="https://example.com/product", - weight=5, - ) - - result = _weighted_random_choice([ad]) - self.assertEqual(result, ad) - - def test_weighted_random_selection_with_empty_list(self): - """Test that weighted selection returns None for empty list""" - from ads.services import _weighted_random_choice - - result = _weighted_random_choice([]) - self.assertIsNone(result) - - def test_weighted_random_selection_respects_weights(self): - """Test that ads with higher weights are selected more often""" - from ads.services import _weighted_random_choice - - # Create ads with very different weights - low_weight_ad = Ad.objects.create( - title="Low Weight", - description="Should be selected less", - link="https://example.com/low", - weight=1, - ) - high_weight_ad = Ad.objects.create( - title="High Weight", - description="Should be selected more", - link="https://example.com/high", - weight=100, - ) - - ads = [low_weight_ad, high_weight_ad] - - # Run selection 100 times and count results - selections = {} - for _ in range(100): - selected = _weighted_random_choice(ads) - selections[selected.id] = selections.get(selected.id, 0) + 1 - - # High weight ad should be selected significantly more often - # With weights 1:100, we expect roughly 1:100 ratio - # Allow some variance but high weight should dominate - self.assertGreater( - selections[high_weight_ad.id], - selections.get(low_weight_ad.id, 0), - "Higher weighted ad should be selected more often", - ) - - def test_get_ad_for_game_returns_game_specific_ad(self): - """Test that game-specific ads are returned when available""" - from ads.services import get_ad_for_game - - # Create a game-specific ad - game_ad = Ad.objects.create( - title="Game Specific Ad", - description="For this game", - link="https://example.com/game", - game=self.game, - weight=1, - ) - - # Create a generic ad - Ad.objects.create( - title="Generic Ad", - description="For all games", - link="https://example.com/generic", - weight=1, - ) - - result = get_ad_for_game(self.game) - self.assertEqual(result, game_ad) - - def test_get_ad_for_game_falls_back_to_generic(self): - """Test that generic ads are returned when no game-specific ads exist""" - from ads.services import get_ad_for_game - - # Create only a generic ad - generic_ad = Ad.objects.create( - title="Generic Ad", - description="For all games", - link="https://example.com/generic", - weight=1, - ) - - result = get_ad_for_game(self.game) - self.assertEqual(result, generic_ad) - - def test_get_ad_for_game_returns_none_when_no_ads(self): - """Test that None is returned when no ads are available""" - from ads.services import get_ad_for_game - - result = get_ad_for_game(self.game) - self.assertIsNone(result) - - def test_get_ad_for_game_prefers_game_specific_over_generic(self): - """Test that game-specific ads are prioritized over generic ads""" - from ads.services import get_ad_for_game - - # Create game-specific ads for this game - game_ad = Ad.objects.create( - title="Game Ad", - description="For this game", - link="https://example.com/game", - game=self.game, - weight=1, - ) - - # Create generic ads - Ad.objects.create( - title="Generic Ad", - description="For all games", - link="https://example.com/generic", - weight=100, # Higher weight but should not be selected - ) - - # Run multiple times to ensure game-specific is always preferred - for _ in range(10): - result = get_ad_for_game(self.game) - self.assertEqual( - result, - game_ad, - "Game-specific ad should always be selected when available", - ) - - def test_serve_ad_with_impression_logs_impression(self): - """Test that serving an ad logs an impression""" - from ads.services import serve_ad_with_impression - - ad = Ad.objects.create( - title="Test Ad", - description="Test impression logging", - link="https://example.com/product", - game=self.game, - ) - - self.assertEqual(AdImpression.objects.count(), 0) - - result = serve_ad_with_impression(self.game) - - self.assertEqual(result, ad) - self.assertEqual(AdImpression.objects.count(), 1) - self.assertEqual(AdImpression.objects.first().ad, ad) - - def test_serve_ad_with_impression_no_ads_available(self): - """Test that serving an ad returns None and logs no impression when no ads available""" - from ads.services import serve_ad_with_impression - - result = serve_ad_with_impression(self.game) - - self.assertIsNone(result) - self.assertEqual(AdImpression.objects.count(), 0) - - def test_weighted_selection_with_multiple_game_specific_ads(self): - """Test weighted selection among multiple game-specific ads""" - from ads.services import get_ad_for_game - - # Create multiple game-specific ads with different weights - ad1 = Ad.objects.create( - title="Ad 1", - description="Weight 1", - link="https://example.com/1", - game=self.game, - weight=1, - ) - ad2 = Ad.objects.create( - title="Ad 2", - description="Weight 5", - link="https://example.com/2", - game=self.game, - weight=5, - ) - ad3 = Ad.objects.create( - title="Ad 3", - description="Weight 10", - link="https://example.com/3", - game=self.game, - weight=10, - ) - - # Run selection multiple times and ensure all ads can be selected - selected_ids = set() - for _ in range(50): - result = get_ad_for_game(self.game) - selected_ids.add(result.id) - - self.assertIn(ad1.id, selected_ids) - self.assertIn(ad2.id, selected_ids) - self.assertIn(ad3.id, selected_ids) - - def test_weighted_selection_with_multiple_generic_ads(self): - """Test weighted selection among multiple generic ads""" - from ads.services import get_ad_for_game - - # Create multiple generic ads with different weights - ad1 = Ad.objects.create( - title="Generic 1", - description="Weight 1", - link="https://example.com/1", - weight=1, - ) - ad2 = Ad.objects.create( - title="Generic 2", - description="Weight 3", - link="https://example.com/2", - weight=3, - ) - - # Run selection multiple times and ensure ads can be selected - selected_ids = set() - for _ in range(50): - result = get_ad_for_game(self.game) - selected_ids.add(result.id) - - # Both ads should be selected at least once in 50 tries - self.assertIn(ad1.id, selected_ids) - self.assertIn(ad2.id, selected_ids) - - -class AdClickViewTest(TestCase): - def setUp(self): - self.game = Game.objects.create(name="Test Game", ingested=True) - self.ad = Ad.objects.create( - title="Test Ad", - description="Testing ad click view", - link="https://example.com/product", - game=self.game, - ) - - def test_ad_click_redirect_valid_ad(self): - """Test ad click redirect with a valid ad ID""" - from django.test import RequestFactory - - from ads.views import ad_click_redirect - - factory = RequestFactory() - request = factory.get(f"/ads/click/{self.ad.id}/") - - response = ad_click_redirect(request, self.ad.id) - - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, self.ad.link) - self.assertEqual(AdClick.objects.count(), 1) - self.assertEqual(AdClick.objects.first().ad, self.ad) - - def test_ad_click_redirect_invalid_ad(self): - """Test ad click redirect with an invalid ad ID""" - from django.test import RequestFactory - - from ads.views import ad_click_redirect - - factory = RequestFactory() - request = factory.get("/ads/click/9999/") # Non-existent ad ID - - response = ad_click_redirect(request, 9999) - - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, "/") # Redirects to homepage - self.assertEqual(AdClick.objects.count(), 0) - - def test_ad_click_redirect_invalid_ad_with_referrer(self): - """Test ad click redirect with an invalid ad ID and a referrer header""" - from django.test import RequestFactory - - from ads.views import ad_click_redirect - - factory = RequestFactory() - request = factory.get("/ads/click/9999/", HTTP_REFERER="/some-page/") - - response = ad_click_redirect(request, 9999) - - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, "/some-page/") # Redirects to referrer - self.assertEqual(AdClick.objects.count(), 0) diff --git a/ads/tests/__init__.py b/ads/tests/__init__.py new file mode 100644 index 0000000..16e610a --- /dev/null +++ b/ads/tests/__init__.py @@ -0,0 +1,14 @@ +# Import all test classes to ensure they're discovered by Django's test runner +from .test_admin import AdAnalyticsAdminTest +from .test_models import AdClickModelTest, AdImpressionModelTest, AdModelTest +from .test_services import AdSelectionServiceTest +from .test_views import AdClickViewTest + +__all__ = [ + "AdModelTest", + "AdImpressionModelTest", + "AdClickModelTest", + "AdSelectionServiceTest", + "AdClickViewTest", + "AdAnalyticsAdminTest", +] diff --git a/ads/tests/test_admin.py b/ads/tests/test_admin.py new file mode 100644 index 0000000..9e2670a --- /dev/null +++ b/ads/tests/test_admin.py @@ -0,0 +1,249 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils import timezone + +from ads.models import Ad, AdClick, AdImpression +from games.models import Game + + +class AdAnalyticsAdminTest(TestCase): + """Tests for ad analytics admin view""" + + def setUp(self): + User = get_user_model() + self.admin_user = User.objects.create_superuser( + username="admin", email="admin@test.com", password="password" + ) + self.client.login(username="admin", password="password") + + self.game = Game.objects.create(name="Test Game", ingested=True) + + def test_analytics_view_accessible(self): + """Test that analytics view is accessible to admin users""" + response = self.client.get("/admin/ads/ad/analytics/") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Ad Analytics") + + def test_analytics_view_requires_authentication(self): + """Test that analytics view requires admin authentication""" + self.client.logout() + response = self.client.get("/admin/ads/ad/analytics/") + + # Should redirect to login + self.assertEqual(response.status_code, 302) + self.assertTrue(response.url.startswith("/admin/login/")) + + def test_analytics_view_all_time_default(self): + """Test that analytics view defaults to 'all time' period""" + ad = Ad.objects.create( + title="Test Ad", + description="Test description", + link="https://example.com/product", + game=self.game, + ) + + # Create some impressions and clicks + for _ in range(5): + AdImpression.objects.create(ad=ad) + for _ in range(2): + AdClick.objects.create(ad=ad) + + response = self.client.get("/admin/ads/ad/analytics/") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "All Time") + self.assertContains(response, "Test Ad") + self.assertContains(response, "5") # impressions + self.assertContains(response, "2") # clicks + + def test_analytics_view_last_7_days_filter(self): + """Test analytics view with 7 days filter""" + ad = Ad.objects.create( + title="Test Ad", + description="Test description", + link="https://example.com/product", + game=self.game, + ) + + # Create impressions from 10 days ago + old_impression = AdImpression.objects.create(ad=ad) + old_impression.timestamp = timezone.now() - timedelta(days=10) + old_impression.save() + + # Create recent impressions (within 7 days) + for _ in range(3): + AdImpression.objects.create(ad=ad) + + response = self.client.get("/admin/ads/ad/analytics/?period=7days") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Last 7 Days") + self.assertContains(response, "Test Ad") + # Should show 3 recent impressions, not the old one + context_data = [ + item + for item in response.context["analytics_data"] + if item["ad"].id == ad.id + ] + self.assertEqual(len(context_data), 1) + self.assertEqual(context_data[0]["impressions"], 3) + + def test_analytics_view_last_30_days_filter(self): + """Test analytics view with 30 days filter""" + ad = Ad.objects.create( + title="Test Ad", + description="Test description", + link="https://example.com/product", + game=self.game, + ) + + # Create impressions from 35 days ago + old_impression = AdImpression.objects.create(ad=ad) + old_impression.timestamp = timezone.now() - timedelta(days=35) + old_impression.save() + + # Create recent impressions (within 30 days) + for _ in range(4): + AdImpression.objects.create(ad=ad) + + response = self.client.get("/admin/ads/ad/analytics/?period=30days") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Last 30 Days") + self.assertContains(response, "Test Ad") + # Should show 4 recent impressions, not the old one + context_data = [ + item + for item in response.context["analytics_data"] + if item["ad"].id == ad.id + ] + self.assertEqual(len(context_data), 1) + self.assertEqual(context_data[0]["impressions"], 4) + + def test_analytics_view_ctr_calculation(self): + """Test that CTR is calculated correctly in analytics view""" + ad = Ad.objects.create( + title="Test Ad", + description="Test description", + link="https://example.com/product", + game=self.game, + ) + + # Create 10 impressions and 3 clicks (30% CTR) + for _ in range(10): + AdImpression.objects.create(ad=ad) + for _ in range(3): + AdClick.objects.create(ad=ad) + + response = self.client.get("/admin/ads/ad/analytics/") + + self.assertEqual(response.status_code, 200) + context_data = [ + item + for item in response.context["analytics_data"] + if item["ad"].id == ad.id + ] + self.assertEqual(len(context_data), 1) + self.assertEqual(context_data[0]["ctr"], 30.0) + + def test_analytics_view_multiple_ads(self): + """Test analytics view displays multiple ads correctly""" + ad1 = Ad.objects.create( + title="Ad 1", + description="Description 1", + link="https://example.com/1", + game=self.game, + ) + ad2 = Ad.objects.create( + title="Ad 2", + description="Description 2", + link="https://example.com/2", + ) + + # Create different metrics for each ad + for _ in range(5): + AdImpression.objects.create(ad=ad1) + for _ in range(1): + AdClick.objects.create(ad=ad1) + + for _ in range(10): + AdImpression.objects.create(ad=ad2) + for _ in range(2): + AdClick.objects.create(ad=ad2) + + response = self.client.get("/admin/ads/ad/analytics/") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Ad 1") + self.assertContains(response, "Ad 2") + + analytics_data = response.context["analytics_data"] + self.assertEqual(len(analytics_data), 2) + + def test_analytics_view_no_ads(self): + """Test analytics view when no ads exist""" + response = self.client.get("/admin/ads/ad/analytics/") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "No ads found") + + def test_analytics_template_rendering(self): + """Test that analytics template renders correctly""" + ad = Ad.objects.create( + title="Test Ad", + description="Test description", + link="https://example.com/product", + game=self.game, + ) + + AdImpression.objects.create(ad=ad) + AdClick.objects.create(ad=ad) + + response = self.client.get("/admin/ads/ad/analytics/") + + self.assertEqual(response.status_code, 200) + # Check for time period filters + self.assertContains(response, "Last 7 Days") + self.assertContains(response, "Last 30 Days") + self.assertContains(response, "All Time") + # Check for table headers + self.assertContains(response, "Ad Title") + self.assertContains(response, "Game") + self.assertContains(response, "Impressions") + self.assertContains(response, "Clicks") + self.assertContains(response, "CTR") + # Check for ad data + self.assertContains(response, "Test Ad") + + def test_analytics_view_time_filtered_clicks(self): + """Test that clicks are also filtered by time period""" + ad = Ad.objects.create( + title="Test Ad", + description="Test description", + link="https://example.com/product", + game=self.game, + ) + + # Create old clicks + old_click = AdClick.objects.create(ad=ad) + old_click.timestamp = timezone.now() - timedelta(days=10) + old_click.save() + + # Create recent clicks (within 7 days) + for _ in range(2): + AdClick.objects.create(ad=ad) + + response = self.client.get("/admin/ads/ad/analytics/?period=7days") + + self.assertEqual(response.status_code, 200) + context_data = [ + item + for item in response.context["analytics_data"] + if item["ad"].id == ad.id + ] + self.assertEqual(len(context_data), 1) + # Should only count the 2 recent clicks + self.assertEqual(context_data[0]["clicks"], 2) diff --git a/ads/tests/test_models.py b/ads/tests/test_models.py new file mode 100644 index 0000000..425eec6 --- /dev/null +++ b/ads/tests/test_models.py @@ -0,0 +1,142 @@ +from django.test import TestCase + +from ads.models import Ad, AdClick, AdImpression +from games.models import Game + + +class AdModelTest(TestCase): + def setUp(self): + self.game = Game.objects.create(name="Test Game", ingested=True) + + def test_create_generic_ad(self): + """Test creating a generic ad (no game association)""" + ad = Ad.objects.create( + title="Generic Ad", + description="This is a generic ad for all games", + link="https://example.com/product", + weight=5, + ) + + self.assertEqual(ad.title, "Generic Ad") + self.assertIsNone(ad.game) + self.assertEqual(ad.weight, 5) + self.assertEqual(str(ad), "Generic Ad (Generic)") + + def test_create_game_specific_ad(self): + """Test creating a game-specific ad""" + ad = Ad.objects.create( + title="Game Specific Ad", + description="This is a game-specific ad", + link="https://example.com/game-product", + game=self.game, + weight=10, + ) + + self.assertEqual(ad.title, "Game Specific Ad") + self.assertEqual(ad.game, self.game) + self.assertEqual(ad.weight, 10) + self.assertEqual(str(ad), f"Game Specific Ad ({self.game.name})") + + def test_ad_with_image(self): + """Test creating an ad with an image URL""" + ad = Ad.objects.create( + title="Ad with Image", + description="This ad has an image", + image="https://example.com/image.jpg", + link="https://example.com/product", + ) + + self.assertEqual(ad.image, "https://example.com/image.jpg") + + def test_ad_default_weight(self): + """Test that default weight is 1""" + ad = Ad.objects.create( + title="Default Weight Ad", + description="Testing default weight", + link="https://example.com/product", + ) + + self.assertEqual(ad.weight, 1) + + +class AdImpressionModelTest(TestCase): + def test_create_impression(self): + """Test creating an ad impression""" + ad = Ad.objects.create( + title="Test Ad", + description="Testing impression creation", + link="https://example.com/product", + ) + + impression = AdImpression.objects.create(ad=ad) + + self.assertEqual(impression.ad, ad) + self.assertIsNotNone(impression.timestamp) + self.assertTrue(str(impression).startswith(f"Impression: {ad.title}")) + + def test_impression_ordering(self): + """Test that impressions are ordered by timestamp (newest first)""" + ad = Ad.objects.create( + title="Test Ad", + description="Testing impression ordering", + link="https://example.com/product", + ) + + impression1 = AdImpression.objects.create(ad=ad) + impression2 = AdImpression.objects.create(ad=ad) + impression3 = AdImpression.objects.create(ad=ad) + + impressions = AdImpression.objects.all() + self.assertEqual(impressions[0], impression3) + self.assertEqual(impressions[1], impression2) + self.assertEqual(impressions[2], impression1) + + +class AdClickModelTest(TestCase): + def test_create_click(self): + """Test creating an ad click""" + ad = Ad.objects.create( + title="Test Ad", + description="Testing click creation", + link="https://example.com/product", + ) + + click = AdClick.objects.create(ad=ad) + + self.assertEqual(click.ad, ad) + self.assertIsNotNone(click.timestamp) + self.assertTrue(str(click).startswith(f"Click: {ad.title}")) + + def test_click_ordering(self): + """Test that clicks are ordered by timestamp (newest first)""" + ad = Ad.objects.create( + title="Test Ad", + description="Testing click ordering", + link="https://example.com/product", + ) + + click1 = AdClick.objects.create(ad=ad) + click2 = AdClick.objects.create(ad=ad) + click3 = AdClick.objects.create(ad=ad) + + clicks = AdClick.objects.all() + self.assertEqual(clicks[0], click3) + self.assertEqual(clicks[1], click2) + self.assertEqual(clicks[2], click1) + + def test_cascade_delete(self): + """Test that clicks are deleted when ad is deleted""" + ad = Ad.objects.create( + title="Test Ad", + description="Testing cascade delete", + link="https://example.com/product", + ) + + AdClick.objects.create(ad=ad) + AdClick.objects.create(ad=ad) + + self.assertEqual(AdClick.objects.count(), 2) + + ad.delete() + + self.assertEqual(AdClick.objects.count(), 0) diff --git a/ads/tests/test_services.py b/ads/tests/test_services.py new file mode 100644 index 0000000..e75944b --- /dev/null +++ b/ads/tests/test_services.py @@ -0,0 +1,219 @@ +from django.test import TestCase + +from ads.models import Ad, AdImpression +from ads.services import ( + _weighted_random_choice, + get_ad_for_game, + serve_ad_with_impression, +) +from games.models import Game + + +class AdSelectionServiceTest(TestCase): + """Tests for ad selection service functions""" + + def setUp(self): + self.game = Game.objects.create(name="Test Game", ingested=True) + + def test_weighted_random_selection_with_single_ad(self): + """Test that weighted selection works with a single ad""" + ad = Ad.objects.create( + title="Single Ad", + description="Only one ad", + link="https://example.com/product", + weight=5, + ) + + result = _weighted_random_choice([ad]) + self.assertEqual(result, ad) + + def test_weighted_random_selection_with_empty_list(self): + """Test that weighted selection returns None for empty list""" + result = _weighted_random_choice([]) + self.assertIsNone(result) + + def test_weighted_random_selection_respects_weights(self): + """Test that ads with higher weights are selected more often""" + # Create ads with very different weights + low_weight_ad = Ad.objects.create( + title="Low Weight", + description="Should be selected less", + link="https://example.com/low", + weight=1, + ) + high_weight_ad = Ad.objects.create( + title="High Weight", + description="Should be selected more", + link="https://example.com/high", + weight=100, + ) + + ads = [low_weight_ad, high_weight_ad] + + # Run selection 100 times and count results + selections = {} + for _ in range(100): + selected = _weighted_random_choice(ads) + selections[selected.id] = selections.get(selected.id, 0) + 1 + + # High weight ad should be selected significantly more often + # With weights 1:100, we expect roughly 1:100 ratio + # Allow some variance but high weight should dominate + self.assertGreater( + selections[high_weight_ad.id], + selections.get(low_weight_ad.id, 0), + "Higher weighted ad should be selected more often", + ) + + def test_get_ad_for_game_returns_game_specific_ad(self): + """Test that game-specific ads are returned when available""" + # Create a game-specific ad + game_ad = Ad.objects.create( + title="Game Specific Ad", + description="For this game", + link="https://example.com/game", + game=self.game, + weight=1, + ) + + # Create a generic ad + Ad.objects.create( + title="Generic Ad", + description="For all games", + link="https://example.com/generic", + weight=1, + ) + + result = get_ad_for_game(self.game) + self.assertEqual(result, game_ad) + + def test_get_ad_for_game_falls_back_to_generic(self): + """Test that generic ads are returned when no game-specific ads exist""" + # Create only a generic ad + generic_ad = Ad.objects.create( + title="Generic Ad", + description="For all games", + link="https://example.com/generic", + weight=1, + ) + + result = get_ad_for_game(self.game) + self.assertEqual(result, generic_ad) + + def test_get_ad_for_game_returns_none_when_no_ads(self): + """Test that None is returned when no ads are available""" + result = get_ad_for_game(self.game) + self.assertIsNone(result) + + def test_get_ad_for_game_prefers_game_specific_over_generic(self): + """Test that game-specific ads are prioritized over generic ads""" + # Create game-specific ads for this game + game_ad = Ad.objects.create( + title="Game Ad", + description="For this game", + link="https://example.com/game", + game=self.game, + weight=1, + ) + + # Create generic ads + Ad.objects.create( + title="Generic Ad", + description="For all games", + link="https://example.com/generic", + weight=100, # Higher weight but should not be selected + ) + + # Run multiple times to ensure game-specific is always preferred + for _ in range(10): + result = get_ad_for_game(self.game) + self.assertEqual( + result, + game_ad, + "Game-specific ad should always be selected when available", + ) + + def test_serve_ad_with_impression_logs_impression(self): + """Test that serving an ad logs an impression""" + ad = Ad.objects.create( + title="Test Ad", + description="Test impression logging", + link="https://example.com/product", + game=self.game, + ) + + self.assertEqual(AdImpression.objects.count(), 0) + + result = serve_ad_with_impression(self.game) + + self.assertEqual(result, ad) + self.assertEqual(AdImpression.objects.count(), 1) + self.assertEqual(AdImpression.objects.first().ad, ad) + + def test_serve_ad_with_impression_no_ads_available(self): + """Test that serving an ad returns None and logs no impression when no ads available""" + result = serve_ad_with_impression(self.game) + + self.assertIsNone(result) + self.assertEqual(AdImpression.objects.count(), 0) + + def test_weighted_selection_with_multiple_game_specific_ads(self): + """Test weighted selection among multiple game-specific ads""" + # Create multiple game-specific ads with different weights + ad1 = Ad.objects.create( + title="Ad 1", + description="Weight 1", + link="https://example.com/1", + game=self.game, + weight=1, + ) + ad2 = Ad.objects.create( + title="Ad 2", + description="Weight 5", + link="https://example.com/2", + game=self.game, + weight=5, + ) + ad3 = Ad.objects.create( + title="Ad 3", + description="Weight 10", + link="https://example.com/3", + game=self.game, + weight=10, + ) + + # Run selection multiple times and ensure all ads can be selected + selected_ids = set() + for _ in range(50): + result = get_ad_for_game(self.game) + selected_ids.add(result.id) + + self.assertIn(ad1.id, selected_ids) + self.assertIn(ad2.id, selected_ids) + self.assertIn(ad3.id, selected_ids) + + def test_weighted_selection_with_multiple_generic_ads(self): + """Test weighted selection among multiple generic ads""" + # Create multiple generic ads with different weights + ad1 = Ad.objects.create( + title="Generic 1", + description="Weight 1", + link="https://example.com/1", + weight=1, + ) + ad2 = Ad.objects.create( + title="Generic 2", + description="Weight 3", + link="https://example.com/2", + weight=3, + ) + + # Run selection multiple times and ensure ads can be selected + selected_ids = set() + for _ in range(50): + result = get_ad_for_game(self.game) + selected_ids.add(result.id) + + # Both ads should be selected at least once in 50 tries + self.assertIn(ad1.id, selected_ids) + self.assertIn(ad2.id, selected_ids) diff --git a/ads/tests/test_views.py b/ads/tests/test_views.py new file mode 100644 index 0000000..0618ccf --- /dev/null +++ b/ads/tests/test_views.py @@ -0,0 +1,50 @@ +from django.test import RequestFactory, TestCase + +from ads.models import Ad, AdClick +from ads.views import ad_click_redirect +from games.models import Game + + +class AdClickViewTest(TestCase): + def setUp(self): + self.game = Game.objects.create(name="Test Game", ingested=True) + self.ad = Ad.objects.create( + title="Test Ad", + description="Testing ad click view", + link="https://example.com/product", + game=self.game, + ) + + def test_ad_click_redirect_valid_ad(self): + """Test ad click redirect with a valid ad ID""" + factory = RequestFactory() + request = factory.get(f"/ads/click/{self.ad.id}/") + + response = ad_click_redirect(request, self.ad.id) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.ad.link) + self.assertEqual(AdClick.objects.count(), 1) + self.assertEqual(AdClick.objects.first().ad, self.ad) + + def test_ad_click_redirect_invalid_ad(self): + """Test ad click redirect with an invalid ad ID""" + factory = RequestFactory() + request = factory.get("/ads/click/9999/") # Non-existent ad ID + + response = ad_click_redirect(request, 9999) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/") # Redirects to homepage + self.assertEqual(AdClick.objects.count(), 0) + + def test_ad_click_redirect_invalid_ad_with_referrer(self): + """Test ad click redirect with an invalid ad ID and a referrer header""" + factory = RequestFactory() + request = factory.get("/ads/click/9999/", HTTP_REFERER="/some-page/") + + response = ad_click_redirect(request, 9999) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/some-page/") # Redirects to referrer + self.assertEqual(AdClick.objects.count(), 0) diff --git a/templates/admin/index.html b/templates/admin/index.html new file mode 100644 index 0000000..985ab10 --- /dev/null +++ b/templates/admin/index.html @@ -0,0 +1,18 @@ +{% extends "admin/index.html" %} +{% load i18n static %} + +{% block sidebar %} +{{ block.super }} +
    + + + + + +
    + Ads +
    + 📊 View Ad Analytics +
    +
    +{% endblock %} diff --git a/tests/agents.md b/tests/agents.md new file mode 100644 index 0000000..dd2b9a0 --- /dev/null +++ b/tests/agents.md @@ -0,0 +1,18 @@ +# AGENTS.md + +## Setup commands +- Install deps: `poetry install` +- Start dev server: `poetry run python manage.py runserver` +- Run tests: `poetry run manage.py test` +- Run tests with coverage: `poetry run coverage run --source='.' manage.py test` then `coverage report` + +## Code style +- Use `black` for code formatting: `poetry run black .` +- Use `flake8` for linting: `poetry run flake8 .` +- Use `isort` for import sorting: `poetry run isort .` + +## General Guidelines +- Follow PEP 8 style guidelines. +- Write clear, concise, and well-documented code. +- Ensure all new features have corresponding tests. +- Always use Django best practices for models, views, and templates. From 12a6bcb0037e44d97e7c7155913e9283767cc949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Blond=20Daugaard?= Date: Mon, 6 Oct 2025 21:54:48 -0400 Subject: [PATCH 7/8] Complete ads prd. --- .docs/prds/ads.md | 91 ++++++++++++++++++++++++---- README.md | 10 ++-- ads/tests/test_views.py | 109 +++++++++++++++++++++++++++++++++- tests/agents.md => agents.md | 0 chat/templates/chat/chat.html | 2 +- 5 files changed, 191 insertions(+), 21 deletions(-) rename tests/agents.md => agents.md (100%) diff --git a/.docs/prds/ads.md b/.docs/prds/ads.md index 92a240b..a0e5901 100644 --- a/.docs/prds/ads.md +++ b/.docs/prds/ads.md @@ -175,16 +175,81 @@ When done, mark the phase as complete. - [x] Add time-based metric methods to Ad model - [x] Write comprehensive tests for analytics view -### Phase 5: Frontend Display ⏳ -- [ ] Update chat.html template to display ads in sidebar -- [ ] Style ad component (title, description, image, CTA link) -- [ ] Ensure clicks route through tracking endpoint -- [ ] Write integration tests for ad display on chat pages - -### Phase 6: End-to-End Testing & Validation ⏳ -- [ ] Test ad creation via admin -- [ ] Test weighted random selection (game-specific and generic) -- [ ] Test impression logging -- [ ] Test click tracking and redirect -- [ ] Verify analytics display in admin -- [ ] Run full test suite and ensure all tests pass +### Phase 5: Frontend Display ✅ +- [x] Update chat.html template to display ads in sidebar +- [x] Style ad component (title, description, image, CTA link) +- [x] Ensure clicks route through tracking endpoint +- [x] Write integration tests for ad display on chat pages + +--- + +## Implementation Complete ✅ + +### Summary + +The ads sub-system for RulesBot.ai has been successfully implemented according to the PRD specifications. All phases are complete with comprehensive test coverage. + +### What Was Built + +**Core Models:** +- `Ad` model with title, description, image, link, game association, and weight +- `AdImpression` tracking model for impressions +- `AdClick` tracking model for clicks + +**Ad Selection & Serving:** +- Weighted random selection algorithm for fair ad distribution +- Context-aware ad serving (game-specific ads with generic fallback) +- Automatic impression logging when ads are displayed + +**Click Tracking:** +- Click tracking redirect endpoint at `/ads/click//` +- Automatic click logging before redirecting to target URL +- Graceful error handling for invalid ad IDs + +**Django Admin Integration:** +- Ad management via Django Admin interface +- Custom analytics dashboard with time-based filtering (7d, 30d, 90d, all-time) +- Inline metrics display (impressions, clicks, CTR) in admin list view +- Comprehensive analytics template with per-ad breakdowns + +**Frontend Display:** +- Ads displayed in chat page sidebar +- Styled ad component with title, description, optional image, and CTA +- All clicks route through tracking endpoint +- Links open in new tab with proper `rel="noopener"` security + +### Test Coverage + +- ✅ Model tests for Ad, AdImpression, AdClick +- ✅ Service tests for weighted random selection +- ✅ Service tests for game-specific vs generic fallback logic +- ✅ View tests for click tracking and redirect behavior +- ✅ Admin tests for analytics dashboard and time-based filtering +- ✅ Integration tests for ad display on chat pages + +### Future Enhancements (Not in MVP) + +As noted in the PRD, these features were intentionally deferred: +- Multiple ad formats (carousel, banner) +- Ad expiration dates +- Frequency capping per user +- Import/export ads via CSV/JSON +- In-chat message placement integration + +### Files Modified/Created + +**New files:** +- `ads/` - New Django app +- `ads/models.py` - Ad, AdImpression, AdClick models +- `ads/services.py` - Ad selection and serving logic +- `ads/views.py` - Click tracking view +- `ads/admin.py` - Admin interface with analytics +- `ads/urls.py` - URL routing +- `ads/templates/admin/ads_analytics.html` - Analytics dashboard +- `ads/tests/` - Comprehensive test suite + +**Modified files:** +- `chat/views.py` - Integrated ad serving in chat view +- `chat/templates/chat/chat.html` - Ad display in sidebar + +The implementation follows Django best practices, includes comprehensive test coverage, and meets all functional requirements specified in the PRD. diff --git a/README.md b/README.md index d4fa346..a20da03 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ AI powered boardgame rules bot. ``` poetry install -poetry shell # start a shell so you don't have to poetry run everything +poetry shell # start a shell so you don't have to poetry run everything (optional) python manage.py migrate python manage.py createsuperuser @@ -17,20 +17,20 @@ python manage.py createsuperuser ## Run ``` -python manage.py runserver +poetry run python manage.py runserver ``` ## Shell ``` -python manage.py shell +poetry run python manage.py shell ``` ## Tests ``` -coverage run --source='.' manage.py test -coverage report +poetry run coverage run --source='.' manage.py test +poetry run coverage report ``` ## Deploy diff --git a/ads/tests/test_views.py b/ads/tests/test_views.py index 0618ccf..57e4fb8 100644 --- a/ads/tests/test_views.py +++ b/ads/tests/test_views.py @@ -1,7 +1,8 @@ -from django.test import RequestFactory, TestCase +from django.test import Client, RequestFactory, TestCase -from ads.models import Ad, AdClick +from ads.models import Ad, AdClick, AdImpression from ads.views import ad_click_redirect +from chat.models import ChatSession from games.models import Game @@ -48,3 +49,107 @@ def test_ad_click_redirect_invalid_ad_with_referrer(self): self.assertEqual(response.status_code, 302) self.assertEqual(response.url, "/some-page/") # Redirects to referrer self.assertEqual(AdClick.objects.count(), 0) + + +class AdDisplayIntegrationTest(TestCase): + """Integration tests for ad display on chat pages""" + + def setUp(self): + self.client = Client() + self.game = Game.objects.create(name="Catan", ingested=True) + self.chat_session = ChatSession.objects.create(game=self.game) + + def test_game_specific_ad_displayed_on_chat_page(self): + """Test that a game-specific ad is displayed on the chat page""" + ad = Ad.objects.create( + title="Catan Expansions", + description="Get the best expansions for Catan!", + link="https://example.com/catan", + game=self.game, + weight=10, + ) + + response = self.client.get(f"/chat/{self.chat_session.session_slug}") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Catan Expansions") + self.assertContains(response, "Get the best expansions for Catan!") + self.assertContains(response, f"/ads/click/{ad.id}/") + # Verify impression was logged + self.assertEqual(AdImpression.objects.count(), 1) + self.assertEqual(AdImpression.objects.first().ad, ad) + + def test_generic_ad_displayed_when_no_game_specific_ad(self): + """Test that a generic ad is displayed when no game-specific ad exists""" + generic_ad = Ad.objects.create( + title="Board Game Storage", + description="Organize your games with our premium storage!", + link="https://example.com/storage", + game=None, # Generic ad + weight=5, + ) + + response = self.client.get(f"/chat/{self.chat_session.session_slug}") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Board Game Storage") + self.assertContains(response, "Organize your games with our premium storage!") + self.assertContains(response, f"/ads/click/{generic_ad.id}/") + # Verify impression was logged + self.assertEqual(AdImpression.objects.count(), 1) + self.assertEqual(AdImpression.objects.first().ad, generic_ad) + + def test_no_ad_displayed_when_no_ads_exist(self): + """Test that no ad is displayed when no ads exist""" + response = self.client.get(f"/chat/{self.chat_session.session_slug}") + + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, 'href="/ads/click/') + # Verify no impression was logged + self.assertEqual(AdImpression.objects.count(), 0) + + def test_ad_with_image_displayed_correctly(self): + """Test that an ad with an image is displayed with the image""" + from django.core.files.uploadedfile import SimpleUploadedFile + + # Create a simple test image + image_content = ( + b"\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x00\x00\x00\x21\xf9\x04" + b"\x01\x0a\x00\x01\x00\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02" + b"\x02\x4c\x01\x00\x3b" + ) + test_image = SimpleUploadedFile( + "test_ad.gif", image_content, content_type="image/gif" + ) + + ad = Ad.objects.create( + title="Premium Dice Set", + description="Upgrade your gaming experience!", + link="https://example.com/dice", + game=self.game, + image=test_image, + weight=8, + ) + + response = self.client.get(f"/chat/{self.chat_session.session_slug}") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Premium Dice Set") + # Check that the image filename is in the response (URL may have query params) + self.assertContains(response, "test_ad.gif") + self.assertContains(response, f"/ads/click/{ad.id}/") + + def test_ad_click_link_opens_in_new_tab(self): + """Test that ad click links have target='_blank' and rel='noopener'""" + Ad.objects.create( + title="Test Ad", + description="Test Description", + link="https://example.com/test", + game=self.game, + ) + + response = self.client.get(f"/chat/{self.chat_session.session_slug}") + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'target="_blank"') + self.assertContains(response, 'rel="noopener"') diff --git a/tests/agents.md b/agents.md similarity index 100% rename from tests/agents.md rename to agents.md diff --git a/chat/templates/chat/chat.html b/chat/templates/chat/chat.html index f7a8cdb..0dc1e86 100644 --- a/chat/templates/chat/chat.html +++ b/chat/templates/chat/chat.html @@ -47,7 +47,7 @@
    {% if ad.image %} - {{ ad.title }} + {{ ad.title }} {% endif %}
    From efbbe525677344150afec6c763f8ae682c3ca0da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Blond=20Daugaard?= Date: Mon, 6 Oct 2025 22:06:50 -0400 Subject: [PATCH 8/8] Meet FTC requirements. --- chat/templates/chat/chat.html | 3 ++- chat/views.py | 2 +- templates/master.html | 3 +++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/chat/templates/chat/chat.html b/chat/templates/chat/chat.html index 0dc1e86..9f0df09 100644 --- a/chat/templates/chat/chat.html +++ b/chat/templates/chat/chat.html @@ -55,10 +55,11 @@
    {{ ad.title }}
    -

    {{ ad.description }}

    +

    {{ ad.description }}

    Learn More +

    Paid Link

    {% endif %} diff --git a/chat/views.py b/chat/views.py index e76f314..37177ad 100644 --- a/chat/views.py +++ b/chat/views.py @@ -71,7 +71,7 @@ def view_chat_session(request, session_slug): { "chat_session": chat_session, "form": ChatForm(), - "sessions": sessions[:7], + "sessions": sessions[:4], "ad": ad, }, ) diff --git a/templates/master.html b/templates/master.html index ff0ccc1..51c9fd2 100644 --- a/templates/master.html +++ b/templates/master.html @@ -101,6 +101,9 @@
    +

    + As an Amazon Associate I earn from qualifying purchases. +

    Copyright ©