diff --git a/solution/services/backend/apps/advertiser/__init__.py b/solution/services/backend/apps/advertiser/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/services/backend/apps/advertiser/admin.py b/solution/services/backend/apps/advertiser/admin.py new file mode 100644 index 0000000..6da703b --- /dev/null +++ b/solution/services/backend/apps/advertiser/admin.py @@ -0,0 +1,14 @@ +from django.contrib import admin + +from apps.advertiser.models import Advertiser + + +class AdvertiserAdmin(admin.ModelAdmin): + readonly_fields = (Advertiser.id.field.name,) + fields = ( + Advertiser.id.field.name, + Advertiser.name.field.name, + ) + + +admin.site.register(Advertiser, AdvertiserAdmin) diff --git a/solution/services/backend/apps/advertiser/apps.py b/solution/services/backend/apps/advertiser/apps.py new file mode 100644 index 0000000..95c6d62 --- /dev/null +++ b/solution/services/backend/apps/advertiser/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AdvertiserConfig(AppConfig): + name = "apps.advertiser" + label = "advertiser" diff --git a/solution/services/backend/apps/advertiser/migrations/0001_initial.py b/solution/services/backend/apps/advertiser/migrations/0001_initial.py new file mode 100644 index 0000000..249cfd7 --- /dev/null +++ b/solution/services/backend/apps/advertiser/migrations/0001_initial.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.6 on 2025-02-13 21:41 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Advertiser', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.TextField()), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/solution/services/backend/apps/advertiser/migrations/__init__.py b/solution/services/backend/apps/advertiser/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/services/backend/apps/advertiser/models.py b/solution/services/backend/apps/advertiser/models.py new file mode 100644 index 0000000..536579a --- /dev/null +++ b/solution/services/backend/apps/advertiser/models.py @@ -0,0 +1,149 @@ +from uuid import UUID + +from decimal import Decimal, ROUND_HALF_UP +from django.core.cache import cache +from django.db import models + +from apps.core.models import BaseModel + + +class Advertiser(BaseModel): + name = models.TextField() + + def __str__(self) -> str: + return self.name + + @property + def advertiser_id(self) -> UUID: + return self.id + + @advertiser_id.setter + def advertiser_id(self, value: UUID) -> None: + self.id = value + + def get_statistics(self) -> dict[str, int | float]: + campaigns = self.campaigns.all() + + total_impressions = 0 + total_clicks = 0 + total_spent_impressions = Decimal("0.0") + total_spent_clicks = Decimal("0.0") + + for campaign in campaigns: + stats = campaign.get_statistics() + total_impressions += stats["impressions_count"] + total_clicks += stats["clicks_count"] + total_spent_impressions += Decimal(str(stats["spent_impressions"])) + total_spent_clicks += Decimal(str(stats["spent_clicks"])) + + total_spent = total_spent_impressions + total_spent_clicks + conversion = ( + ( + Decimal(str(total_clicks)) + / Decimal(str(total_impressions)) + * Decimal("100") + ) + if total_impressions > 0 + else Decimal("0") + ) + + return { + "impressions_count": total_impressions, + "clicks_count": total_clicks, + "conversion": float( + conversion.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + ), + "spent_impressions": float( + total_spent_impressions.quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + ), + "spent_clicks": float( + total_spent_clicks.quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + ), + "spent_total": float( + total_spent.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + ), + } + + def get_daily_statistics(self) -> list[dict[str, int | float]]: + campaigns = self.campaigns.all() + + daily_stats_map = {} + + for campaign in campaigns: + daily_stats = campaign.get_daily_statistics() + for stat in daily_stats: + date = stat["date"] + if date not in daily_stats_map: + daily_stats_map[date] = { + "impressions_count": 0, + "clicks_count": 0, + "spent_impressions": Decimal("0.0"), + "spent_clicks": Decimal("0.0"), + } + + daily_stats_map[date]["impressions_count"] += stat[ + "impressions_count" + ] + daily_stats_map[date]["clicks_count"] += stat["clicks_count"] + daily_stats_map[date]["spent_impressions"] += Decimal( + str(stat["spent_impressions"]) + ) + daily_stats_map[date]["spent_clicks"] += Decimal( + str(stat["spent_clicks"]) + ) + + days_range = range(cache.get("current_date", 0) + 1) + + for day in days_range: + if day not in daily_stats_map: + daily_stats_map[day] = { + "impressions_count": 0, + "clicks_count": 0, + "spent_impressions": Decimal("0.0"), + "spent_clicks": Decimal("0.0"), + } + + daily_stats = [] + for date, metrics in daily_stats_map.items(): + total_spent = ( + metrics["spent_impressions"] + metrics["spent_clicks"] + ) + conversion = ( + Decimal(str(metrics["clicks_count"])) + / Decimal(str(metrics["impressions_count"])) + * Decimal("100") + if metrics["impressions_count"] > 0 + else Decimal("0") + ) + + daily_stats.append({ + "date": date, + "impressions_count": metrics["impressions_count"], + "clicks_count": metrics["clicks_count"], + "conversion": float( + conversion.quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + ), + "spent_impressions": float( + metrics["spent_impressions"].quantize( + Decimal("0.0000000001"), rounding=ROUND_HALF_UP + ) + ), + "spent_clicks": float( + metrics["spent_clicks"].quantize( + Decimal("0.0000000001"), rounding=ROUND_HALF_UP + ) + ), + "spent_total": float( + total_spent.quantize( + Decimal("0.0000000001"), rounding=ROUND_HALF_UP + ) + ), + }) + + return sorted(daily_stats, key=lambda item: item["date"]) diff --git a/solution/services/backend/apps/advertiser/tests/__init__.py b/solution/services/backend/apps/advertiser/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/services/backend/apps/advertiser/tests/test_advertiser_model.py b/solution/services/backend/apps/advertiser/tests/test_advertiser_model.py new file mode 100644 index 0000000..0310591 --- /dev/null +++ b/solution/services/backend/apps/advertiser/tests/test_advertiser_model.py @@ -0,0 +1,48 @@ +from uuid import uuid4 +from django.test import TestCase, override_settings +from django.core.cache import cache +from apps.advertiser.models import Advertiser +from apps.campaign.models import Campaign + + +class AdvertiserModelTest(TestCase): + def setUp(self) -> None: + self.advertiser = Advertiser.objects.create(name="Test Advertiser") + + def test_advertiser_creation(self): + self.assertIsInstance(self.advertiser, Advertiser) + self.assertEqual(self.advertiser.name, "Test Advertiser") + + def test_advertiser_str_method(self): + self.assertEqual(str(self.advertiser), "Test Advertiser") + + def test_advertiser_id_property(self): + self.assertEqual(self.advertiser.advertiser_id, self.advertiser.id) + + new_id = uuid4() + self.advertiser.advertiser_id = new_id + self.assertEqual(self.advertiser.id, new_id) + + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def test_advertiser_campaigns_relationship(self): + loll = cache.get("current_date", 0) + + campaign = Campaign.objects.create( + advertiser=self.advertiser, + impressions_limit=0, + clicks_limit=0, + cost_per_impression=0, + cost_per_click=0, + ad_title="title", + ad_text="text", + start_date=15, + end_date=16, + ) + + self.assertIn(campaign, self.advertiser.campaigns.all()) diff --git a/solution/services/backend/apps/advertiser/tests/test_advertiser_statistics.py b/solution/services/backend/apps/advertiser/tests/test_advertiser_statistics.py new file mode 100644 index 0000000..a4d4cbe --- /dev/null +++ b/solution/services/backend/apps/advertiser/tests/test_advertiser_statistics.py @@ -0,0 +1,189 @@ +from django.core.cache import cache +from django.test import TestCase, override_settings +from apps.advertiser.models import Advertiser +from apps.campaign.models import Campaign, CampaignImpression, CampaignClick +from apps.client.models import Client + + +class AdvertiserStatisticsTest(TestCase): + @classmethod + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def setUpTestData(cls): + cache.set("current_date", 1) + + cls.advertiser = Advertiser.objects.create(name="Test Advertiser") + cls.campaign1 = Campaign.objects.create( + advertiser=cls.advertiser, + impressions_limit=1000, + clicks_limit=500, + cost_per_impression=0.05, + cost_per_click=0.10, + ad_title="Campaign 1", + ad_text="This is the first test campaign.", + start_date=1, + end_date=10, + ) + cls.campaign2 = Campaign.objects.create( + advertiser=cls.advertiser, + impressions_limit=2000, + clicks_limit=1000, + cost_per_impression=0.04, + cost_per_click=0.08, + ad_title="Campaign 2", + ad_text="This is the second test campaign.", + start_date=2, + end_date=12, + ) + cls.client_instance = Client.objects.create( + login="test_client", + age=30, + gender="MALE", + location="Test Location", + ) + + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def setUp(self): + cache.clear() + cache.set("current_date", 5) + + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def test_get_statistics_no_data(self): + stats = self.advertiser.get_statistics() + expected_stats = { + "impressions_count": 0, + "clicks_count": 0, + "conversion": 0, + "spent_impressions": 0.0, + "spent_clicks": 0.0, + "spent_total": 0.0, + } + + self.assertEqual(stats, expected_stats) + + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def test_get_statistics_with_data(self): + CampaignImpression.objects.create( + campaign=self.campaign1, + client=self.client_instance, + price=self.campaign1.cost_per_impression, + date=3, + ) + CampaignClick.objects.create( + campaign=self.campaign1, + client=self.client_instance, + price=self.campaign1.cost_per_click, + date=3, + ) + CampaignImpression.objects.create( + campaign=self.campaign2, + client=self.client_instance, + price=self.campaign2.cost_per_impression, + date=4, + ) + + stats = self.advertiser.get_statistics() + expected_stats = { + "impressions_count": 2, + "clicks_count": 1, + "conversion": 50.0, + "spent_impressions": 0.09, + "spent_clicks": 0.10, + "spent_total": 0.19, + } + + self.assertEqual(stats, expected_stats) + + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def test_get_daily_statistics_no_data(self): + daily_stats = self.advertiser.get_daily_statistics() + expected_stats = [ + { + "impressions_count": 0, + "clicks_count": 0, + "conversion": 0, + "spent_impressions": 0.0, + "spent_clicks": 0.0, + "spent_total": 0.0, + "date": day, + } + for day in range(cache.get("current_date", 0) + 1) + ] + + self.assertEqual(daily_stats, expected_stats) + + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def test_get_daily_statistics_with_data(self): + CampaignImpression.objects.create( + campaign=self.campaign1, + client=self.client_instance, + price=self.campaign1.cost_per_impression, + date=3, + ) + CampaignClick.objects.create( + campaign=self.campaign1, + client=self.client_instance, + price=self.campaign1.cost_per_click, + date=3, + ) + CampaignImpression.objects.create( + campaign=self.campaign2, + client=self.client_instance, + price=self.campaign2.cost_per_impression, + date=4, + ) + + daily_stats = self.advertiser.get_daily_statistics() + expected_stats = [ + { + "impressions_count": 1 if day == 3 else 1 if day == 4 else 0, + "clicks_count": 1 if day == 3 else 0, + "conversion": 100.0 if day == 3 else 0.0, + "spent_impressions": 0.05 + if day == 3 + else 0.04 + if day == 4 + else 0.0, + "spent_clicks": 0.10 if day == 3 else 0.0, + "spent_total": 0.15 if day == 3 else 0.04 if day == 4 else 0.0, + "date": day, + } + for day in range(cache.get("current_date") + 1) + ] + + self.assertEqual(daily_stats, expected_stats) diff --git a/solution/services/backend/apps/campaign/__init__.py b/solution/services/backend/apps/campaign/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/services/backend/apps/campaign/admin.py b/solution/services/backend/apps/campaign/admin.py new file mode 100644 index 0000000..f9a1853 --- /dev/null +++ b/solution/services/backend/apps/campaign/admin.py @@ -0,0 +1,120 @@ +from django.contrib import admin +from django.http import HttpRequest + +from apps.campaign.forms import CampaignForm, CampaignReportForm +from apps.campaign.models import ( + Campaign, + CampaignClick, + CampaignImpression, + CampaignReport, +) + + +class CampaignAdmin(admin.ModelAdmin): + form = CampaignForm + readonly_fields = ( + Campaign.id.field.name, + Campaign.advertiser.field.name, + ) + fields = ( + Campaign.id.field.name, + Campaign.advertiser.field.name, + Campaign.impressions_limit.field.name, + Campaign.clicks_limit.field.name, + Campaign.cost_per_impression.field.name, + Campaign.cost_per_click.field.name, + Campaign.ad_title.field.name, + Campaign.ad_text.field.name, + Campaign.ad_image.field.name, + Campaign.start_date.field.name, + Campaign.end_date.field.name, + Campaign.gender.field.name, + Campaign.age_from.field.name, + Campaign.age_to.field.name, + Campaign.location.field.name, + ) + + def has_add_permission( + self, request: HttpRequest, obj: Campaign = None + ) -> bool: + return False + + +class CampaignImpressionAdmin(admin.ModelAdmin): + readonly_fields = ( + CampaignImpression.id.field.name, + CampaignImpression.campaign.field.name, + CampaignImpression.client.field.name, + CampaignImpression.date.field.name, + ) + fields = ( + CampaignImpression.id.field.name, + CampaignImpression.campaign.field.name, + CampaignImpression.client.field.name, + CampaignImpression.date.field.name, + CampaignImpression.price.field.name, + ) + + def has_add_permission( + self, request: HttpRequest, obj: CampaignImpression = None + ) -> bool: + return False + + +class CampaignClickAdmin(admin.ModelAdmin): + readonly_fields = ( + CampaignClick.id.field.name, + CampaignClick.campaign.field.name, + CampaignClick.client.field.name, + CampaignClick.date.field.name, + ) + fields = ( + CampaignClick.id.field.name, + CampaignClick.campaign.field.name, + CampaignClick.client.field.name, + CampaignClick.date.field.name, + CampaignClick.price.field.name, + ) + + def has_add_permission( + self, request: HttpRequest, obj: CampaignClick = None + ) -> bool: + return False + + +class CampaignReportAdmin(admin.ModelAdmin): + form = CampaignReportForm + readonly_fields = ( + CampaignReport.id.field.name, + CampaignReport.campaign.field.name, + CampaignReport.client.field.name, + CampaignReport.message.field.name, + CampaignReport.flagged_by_llm.field.name, + ) + fields = ( + CampaignReport.id.field.name, + CampaignReport.campaign.field.name, + CampaignReport.client.field.name, + CampaignReport.state.field.name, + CampaignReport.message.field.name, + CampaignReport.flagged_by_llm.field.name, + ) + list_filter = ( + CampaignReport.state.field.name, + CampaignReport.flagged_by_llm.field.name, + ) + list_display = ( + "__str__", + CampaignReport.flagged_by_llm.field.name, + ) + + def has_add_permission( + self, request: HttpRequest, obj: CampaignReport = None + ) -> bool: + return False + + +admin.site.register(Campaign, CampaignAdmin) +admin.site.register(CampaignImpression, CampaignImpressionAdmin) +admin.site.register(CampaignClick, CampaignClickAdmin) +admin.site.register(CampaignReport, CampaignReportAdmin) diff --git a/solution/services/backend/apps/campaign/apps.py b/solution/services/backend/apps/campaign/apps.py new file mode 100644 index 0000000..4446f2c --- /dev/null +++ b/solution/services/backend/apps/campaign/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CampaignConfig(AppConfig): + name = "apps.campaign" + label = "campaign" diff --git a/solution/services/backend/apps/campaign/forms.py b/solution/services/backend/apps/campaign/forms.py new file mode 100644 index 0000000..21544ce --- /dev/null +++ b/solution/services/backend/apps/campaign/forms.py @@ -0,0 +1,35 @@ +from typing import Any + +from django import forms + +from apps.campaign.models import Campaign, CampaignReport + + +class CampaignForm(forms.ModelForm): + class Meta: + model = Campaign + fields = "__all__" + + def clean(self) -> dict[str, Any]: + cleaned_data = super().clean() + location = cleaned_data.get("location") + + if location == "": + cleaned_data["location"] = None + + return cleaned_data + + +class CampaignReportForm(forms.ModelForm): + class Meta: + model = CampaignReport + fields = "__all__" + + def clean(self) -> dict[str, Any]: + cleaned_data = super().clean() + message = cleaned_data.get("message") + + if message == "": + cleaned_data["message"] = None + + return cleaned_data diff --git a/solution/services/backend/apps/campaign/migrations/0001_initial.py b/solution/services/backend/apps/campaign/migrations/0001_initial.py new file mode 100644 index 0000000..ba9800f --- /dev/null +++ b/solution/services/backend/apps/campaign/migrations/0001_initial.py @@ -0,0 +1,84 @@ +# Generated by Django 5.1.6 on 2025-02-17 18:16 + +import apps.campaign.models +import django.core.validators +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('advertiser', '0001_initial'), + ('client', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Campaign', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('impressions_limit', models.PositiveBigIntegerField()), + ('clicks_limit', models.PositiveBigIntegerField()), + ('cost_per_impression', models.FloatField(validators=[django.core.validators.MinValueValidator(0)])), + ('cost_per_click', models.FloatField(validators=[django.core.validators.MinValueValidator(0)])), + ('ad_title', models.TextField()), + ('ad_text', models.TextField()), + ('ad_image', models.ImageField(blank=True, max_length=256, null=True, upload_to=apps.campaign.models.Campaign.ad_image_directory_path)), + ('start_date', models.PositiveIntegerField()), + ('end_date', models.PositiveIntegerField()), + ('gender', models.CharField(blank=True, choices=[('MALE', 'MALE'), ('FEMALE', 'FEMALE'), ('ALL', 'ALL')], max_length=6, null=True)), + ('age_from', models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(100)])), + ('age_to', models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(100)])), + ('location', models.TextField(blank=True, null=True, validators=[django.core.validators.MinLengthValidator(1)])), + ('advertiser', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='campaigns', to='advertiser.advertiser')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='CampaignClick', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('price', models.FloatField()), + ('date', models.PositiveIntegerField()), + ('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='clicks', to='campaign.campaign')), + ('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='clicks', to='client.client')), + ], + options={ + 'unique_together': {('campaign', 'client')}, + }, + ), + migrations.CreateModel( + name='CampaignImpression', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('price', models.FloatField()), + ('date', models.PositiveIntegerField()), + ('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='impressions', to='campaign.campaign')), + ('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='impressions', to='client.client')), + ], + options={ + 'unique_together': {('campaign', 'client')}, + }, + ), + migrations.CreateModel( + name='CampaignReport', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('state', models.CharField(choices=[('s', 'Sent'), ('r', 'Under review'), ('t', 'Took action'), ('f', 'Skipped')], default='s', max_length=1)), + ('message', models.TextField(blank=True, null=True)), + ('flagged_by_llm', models.BooleanField(blank=True, null=True)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('campaign', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reports', to='campaign.campaign')), + ('client', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reports', to='client.client')), + ], + options={ + 'unique_together': {('campaign', 'client')}, + }, + ), + ] diff --git a/solution/services/backend/apps/campaign/migrations/__init__.py b/solution/services/backend/apps/campaign/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/services/backend/apps/campaign/models.py b/solution/services/backend/apps/campaign/models.py new file mode 100644 index 0000000..195dc2e --- /dev/null +++ b/solution/services/backend/apps/campaign/models.py @@ -0,0 +1,383 @@ +import contextlib +from typing import Any, Self +from uuid import UUID + +from django.core.cache import cache +from django.core.exceptions import ValidationError +from django.core.validators import ( + MaxValueValidator, + MinLengthValidator, + MinValueValidator, +) +from django.db import models +from decimal import Decimal, ROUND_HALF_UP + +from apps.advertiser.models import Advertiser +from apps.campaign.validators import ( + CampaignAgeValidator, + CampaignDurationValidator, + CampaignLimitsValidator, + CampaignReportMessageValidator, + CampaignTargetingLocationValidator, +) +from apps.client.models import Client +from apps.core.models import BaseModel +from config.errors import ConflictError, ForbiddenError + + +class Campaign(BaseModel): + class GenderChoices(models.TextChoices): + MALE = "MALE", "MALE" + FEMALE = "FEMALE", "FEMALE" + ALL = "ALL", "ALL" + + def ad_image_directory_path(instance, filename: str) -> str: # noqa: N805 + return f"campaigns/{instance.id}/{filename}" + + advertiser = models.ForeignKey( + Advertiser, + on_delete=models.CASCADE, + related_name="campaigns", + ) + + impressions_limit = models.PositiveBigIntegerField() + clicks_limit = models.PositiveBigIntegerField() + cost_per_impression = models.FloatField(validators=[MinValueValidator(0)]) + cost_per_click = models.FloatField(validators=[MinValueValidator(0)]) + ad_title = models.TextField() + ad_text = models.TextField() + ad_image = models.ImageField( + max_length=256, + null=True, + blank=True, + upload_to=ad_image_directory_path, + ) + start_date = models.PositiveIntegerField() + end_date = models.PositiveIntegerField() + + gender = models.CharField( + max_length=6, + null=True, + blank=True, + choices=GenderChoices, + ) + age_from = models.PositiveSmallIntegerField( + null=True, + blank=True, + validators=[MaxValueValidator(100)], + ) + age_to = models.PositiveSmallIntegerField( + null=True, + blank=True, + validators=[MaxValueValidator(100)], + ) + location = models.TextField( + null=True, + blank=True, + validators=[MinLengthValidator(1)], + ) + + READONLY_AFTER_START_FIELDS = ( + "impressions_limit", + "clicks_limit", + "start_date", + "end_date", + ) + + def __str__(self) -> str: + return self.ad_title + + def clean(self) -> None: + CampaignTargetingLocationValidator()(self) + CampaignAgeValidator()(self) + CampaignDurationValidator()(self) + CampaignLimitsValidator()(self) + + current_date = cache.get("current_date", default=0) + + err = "start_date must be greater than the current date." + + try: + original = Campaign.objects.get(id=self.id or "") + if ( + original.start_date != self.start_date + and self.start_date <= current_date + ): + raise ValidationError(err) + except Campaign.DoesNotExist: + if self.start_date < current_date: + raise ValidationError(err) + + @property + def ad_id(self) -> UUID: + return self.id + + @property + def campaign_id(self) -> UUID: + return self.id + + @campaign_id.setter + def campaign_id(self, value: UUID) -> None: + self.id = value + + @property + def started(self) -> bool: + return isinstance( + self.start_date, int + ) and self.start_date <= cache.get("current_date", default=0) + + @property + def active(self) -> bool: + return ( + self.started + and cache.get("current_date", default=0) <= self.end_date + ) + + def view(self, client: Client) -> None: + with contextlib.suppress(ConflictError): + CampaignImpression.objects.create( + campaign=self, + client=client, + price=self.cost_per_impression, + date=cache.get("current_date", default=0), + ) + + def click(self, client: Client) -> None: + if not self.active: + err = "Can't click on inactive campaign." + raise ForbiddenError(err) + + try: + CampaignImpression.objects.get(campaign=self, client=client) + except CampaignImpression.DoesNotExist: + raise ForbiddenError from None + + with contextlib.suppress(ConflictError): + CampaignClick.objects.create( + campaign=self, + client=client, + price=self.cost_per_click, + date=cache.get("current_date", default=0), + ) + + def get_statistics(self) -> dict[str, Any]: + impressions = self.impressions.aggregate( + total=models.Count("id"), spent=models.Sum("price") + ) + clicks = self.clicks.aggregate( + total=models.Count("id"), spent=models.Sum("price") + ) + + return self._calculate_metrics(impressions, clicks) + + def get_daily_statistics(self) -> list[dict[str, Any]]: + last_click_date = self.clicks.aggregate(last_date=models.Max("date"))[ + "last_date" + ] + if not last_click_date: + last_click_date = self.end_date + + current_day = cache.get("current_date", 0) + start_day = self.start_date + end_day = min(last_click_date, current_day) + + days_range = list(range(start_day, end_day + 1)) + + impressions = self.impressions.values("date").annotate( + total=models.Count("id"), + spent=models.Sum("price", default=0.0), + ) + + clicks = self.clicks.values("date").annotate( + total=models.Count("id"), + spent=models.Sum("price", default=0.0), + ) + + imp_map = {imp["date"]: imp for imp in impressions} + clk_map = {clk["date"]: clk for clk in clicks} + + daily_stats = [] + for day in days_range: + imp = imp_map.get(day, {"total": 0, "spent": 0}) + clk = clk_map.get(day, {"total": 0, "spent": 0}) + + metrics = self._calculate_metrics(imp, clk) + metrics["date"] = day + daily_stats.append(metrics) + + daily_stats.sort(key=lambda x: x["date"]) + + return daily_stats + + @staticmethod + def _calculate_metrics( + impressions: dict[str, Any], clicks: dict[str, Any] + ) -> dict[str, Any]: + impressions_count = impressions.get("total", 0) or 0 + clicks_count = clicks.get("total", 0) or 0 + conversion = ( + ( + Decimal(str(clicks_count)) + / Decimal(str(impressions_count)) + * Decimal("100") + ) + if impressions_count > 0 + else Decimal("0") + ) + spent_impressions = Decimal(str(impressions.get("spent", 0) or 0)) + spent_clicks = Decimal(str(clicks.get("spent", 0) or 0)) + spent_total = spent_impressions + spent_clicks + + return { + "impressions_count": impressions_count, + "clicks_count": clicks_count, + "conversion": float( + conversion.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + ), + "spent_impressions": float( + spent_impressions.quantize( + Decimal("0.000000001"), rounding=ROUND_HALF_UP + ) + ), + "spent_clicks": float( + spent_clicks.quantize( + Decimal("0.000000001"), rounding=ROUND_HALF_UP + ) + ), + "spent_total": float( + spent_total.quantize( + Decimal("0.000000001"), rounding=ROUND_HALF_UP + ) + ), + } + + @classmethod + def get_available_campaigns( + cls, client: Client + ) -> models.manager.BaseManager[Self]: + current_date = cache.get("current_date", default=0) + + date_filter = models.Q(start_date__lte=current_date) & models.Q( + end_date__gte=current_date + ) + location_filter = models.Q(location__isnull=True) | models.Q( + location=client.location + ) + gender_filter = ( + models.Q(gender__isnull=True) + | models.Q(gender=cls.GenderChoices.ALL) + | models.Q(gender=client.gender) + ) + age_filter = ( + models.Q(age_from__lte=client.age) + | models.Q(age_from__isnull=True) + ) & (models.Q(age_to__gte=client.age) | models.Q(age_to__isnull=True)) + + queryset = cls.objects.filter( + date_filter, + location_filter, + gender_filter, + age_filter, + ).prefetch_related("clicks", "impressions") + + return queryset + + @classmethod + def suggest(cls, client: Client) -> Self: + aboba = cls.get_revelant(client).all() + return aboba[0] + + +class CampaignImpression(BaseModel): + campaign = models.ForeignKey( + Campaign, + on_delete=models.CASCADE, + related_name="impressions", + ) + client = models.ForeignKey( + Client, + on_delete=models.CASCADE, + related_name="impressions", + ) + price = models.FloatField() + date = models.PositiveIntegerField() + + def __str__(self) -> str: + return f"{self.client.login} > {self.campaign.ad_title}" + + class Meta: + unique_together = ( + "campaign", + "client", + ) + + +class CampaignClick(BaseModel): + campaign = models.ForeignKey( + Campaign, + on_delete=models.CASCADE, + related_name="clicks", + ) + client = models.ForeignKey( + Client, + on_delete=models.CASCADE, + related_name="clicks", + ) + price = models.FloatField() + date = models.PositiveIntegerField() + + def __str__(self) -> str: + return f"{self.client.login} > {self.campaign.ad_title}" + + class Meta: + unique_together = ( + "campaign", + "client", + ) + + +class CampaignReport(BaseModel): + class CampaignReportState(models.TextChoices): + SENT = "s", "Sent" + UNDER_REVIEW = "r", "Under review" + TOOK_ACTION = "t", "Took action" + SKIPPED = "f", "Skipped" + + campaign = models.ForeignKey( + Campaign, + on_delete=models.SET_NULL, + related_name="reports", + blank=True, + null=True, + ) + client = models.ForeignKey( + Client, + on_delete=models.SET_NULL, + related_name="reports", + blank=True, + null=True, + ) + state = models.CharField( + max_length=1, + choices=CampaignReportState, + default=CampaignReportState.SENT, + ) + message = models.TextField(null=True, blank=True) + flagged_by_llm = models.BooleanField(null=True, blank=True) + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ( + "campaign", + "client", + ) + + def __str__(self) -> str: + login = self.client.login if self.client else "(client deleted)" + ad_title = ( + self.campaign.ad_title if self.campaign else "(campaign deleted)" + ) + return f"{login} > {ad_title}" + + def clean(self) -> None: + CampaignReportMessageValidator()(self) diff --git a/solution/services/backend/apps/campaign/tasks.py b/solution/services/backend/apps/campaign/tasks.py new file mode 100644 index 0000000..1939055 --- /dev/null +++ b/solution/services/backend/apps/campaign/tasks.py @@ -0,0 +1,38 @@ +import contextlib +from concurrent.futures import ThreadPoolExecutor + +from celery import shared_task + +from apps.campaign.models import CampaignReport +from integrations.yandexai.generators.ad_text import YandexAIAdTextGenerator +from integrations.yandexai.moderation import YandexAIModerator + + +@shared_task +def generate_ad_text_task(advertiser_name: str, ad_title: str) -> str | None: + return YandexAIAdTextGenerator().generate_ad_text( + advertiser_name, ad_title + ) + + +@shared_task(ignore_result=True) +def moderate_campaign_task( + report_id: int, ad_title: str, ad_text: str +) -> None: + with ThreadPoolExecutor(max_workers=2) as executor: + future_text = executor.submit( + YandexAIModerator().get_moderation_verdict, ad_text + ) + future_title = executor.submit( + YandexAIModerator().get_moderation_verdict, ad_title + ) + + ad_text_verdict = future_text.result() + ad_title_verdict = future_title.result() + + overall_verdict = ad_title_verdict or ad_text_verdict + + with contextlib.suppress(CampaignReport.DoesNotExist): + report = CampaignReport.objects.get(id=report_id) + report.flagged_by_llm = overall_verdict + report.save() diff --git a/solution/services/backend/apps/campaign/tests/__init__.py b/solution/services/backend/apps/campaign/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solution/services/backend/apps/campaign/tests/test_campaign_click_model.py b/solution/services/backend/apps/campaign/tests/test_campaign_click_model.py new file mode 100644 index 0000000..3c52262 --- /dev/null +++ b/solution/services/backend/apps/campaign/tests/test_campaign_click_model.py @@ -0,0 +1,53 @@ +from django.test import TestCase, override_settings +from django.core.cache import cache +from apps.advertiser.models import Advertiser +from apps.campaign.models import Campaign, CampaignClick +from apps.client.models import Client + + +class CampaignClickModelTest(TestCase): + @classmethod + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def setUpTestData(cls): + cache.set("current_date", 1) + + cls.advertiser = Advertiser.objects.create(name="Test Advertiser") + cls.campaign = Campaign.objects.create( + advertiser=cls.advertiser, + impressions_limit=1000, + clicks_limit=500, + cost_per_impression=0.05, + cost_per_click=0.10, + ad_title="Test Campaign", + ad_text="This is a test campaign.", + start_date=1, + end_date=10, + ) + cls.client = Client.objects.create( + login="test_client", age=15, location="Moscow", gender="FEMALE" + ) + cls.click = CampaignClick.objects.create( + campaign=cls.campaign, + client=cls.client, + price=0.10, + date=1, + ) + + def test_campaign_click_creation(self): + self.assertIsInstance(self.click, CampaignClick) + self.assertEqual(self.click.price, 0.10) + + def test_unique_together_constraint(self): + with self.assertRaises(Exception): + CampaignClick.objects.create( + campaign=self.campaign, + client=self.client, + price=0.10, + date=1, + ) diff --git a/solution/services/backend/apps/campaign/tests/test_campaign_impression_model.py b/solution/services/backend/apps/campaign/tests/test_campaign_impression_model.py new file mode 100644 index 0000000..6482e02 --- /dev/null +++ b/solution/services/backend/apps/campaign/tests/test_campaign_impression_model.py @@ -0,0 +1,50 @@ +from django.test import TestCase, override_settings +from apps.advertiser.models import Advertiser +from apps.campaign.models import Campaign, CampaignImpression +from apps.client.models import Client + + +class CampaignImpressionModelTest(TestCase): + @classmethod + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def setUpTestData(cls): + cls.advertiser = Advertiser.objects.create(name="Test Advertiser") + cls.campaign = Campaign.objects.create( + advertiser=cls.advertiser, + impressions_limit=1000, + clicks_limit=500, + cost_per_impression=0.05, + cost_per_click=0.10, + ad_title="Test Campaign", + ad_text="This is a test campaign.", + start_date=1, + end_date=10, + ) + cls.client = Client.objects.create( + login="test_client", age=15, location="Moscow", gender="FEMALE" + ) + cls.impression = CampaignImpression.objects.create( + campaign=cls.campaign, + client=cls.client, + price=0.05, + date=1, + ) + + def test_campaign_impression_creation(self): + self.assertIsInstance(self.impression, CampaignImpression) + self.assertEqual(self.impression.price, 0.05) + + def test_unique_together_constraint(self): + with self.assertRaises(Exception): + CampaignImpression.objects.create( + campaign=self.campaign, + client=self.client, + price=0.05, + date=1, + ) diff --git a/solution/services/backend/apps/campaign/tests/test_campaign_model.py b/solution/services/backend/apps/campaign/tests/test_campaign_model.py new file mode 100644 index 0000000..46e7cb0 --- /dev/null +++ b/solution/services/backend/apps/campaign/tests/test_campaign_model.py @@ -0,0 +1,118 @@ +from uuid import uuid4 +from django.core.cache import cache +from django.core.exceptions import ValidationError +from django.test import TestCase, override_settings +from apps.advertiser.models import Advertiser +from apps.campaign.models import Campaign +from apps.client.models import Client + + +class CampaignModelTest(TestCase): + @classmethod + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def setUpTestData(cls): + cls.advertiser = Advertiser.objects.create(name="Test Advertiser") + cls.campaign = Campaign.objects.create( + advertiser=cls.advertiser, + impressions_limit=1000, + clicks_limit=500, + cost_per_impression=0.05, + cost_per_click=0.10, + ad_title="Test Campaign", + ad_text="This is a test campaign.", + start_date=1, + end_date=10, + ) + + def test_campaign_creation(self): + self.assertIsInstance(self.campaign, Campaign) + self.assertEqual(self.campaign.ad_title, "Test Campaign") + + def test_campaign_str_method(self): + self.assertEqual(str(self.campaign), "Test Campaign") + + def test_campaign_id_property(self): + self.assertEqual(self.campaign.campaign_id, self.campaign.id) + new_id = uuid4() + self.campaign.campaign_id = new_id + self.assertEqual(self.campaign.id, new_id) + + def test_ad_id_property(self): + self.assertEqual(self.campaign.ad_id, self.campaign.id) + + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def test_started_property(self): + cache.set("current_date", 5) + self.assertTrue(self.campaign.started) + cache.set("current_date", 0) + self.assertFalse(self.campaign.started) + + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def test_active_property(self): + cache.set("current_date", 5) + self.assertTrue(self.campaign.active) + cache.set("current_date", 11) + self.assertFalse(self.campaign.active) + cache.set("current_date", 5) + + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def test_clean_method(self): + self.campaign.start_date = -1 + + with self.assertRaises(ValidationError): + self.campaign.clean() + + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def test_view_method(self): + client = Client.objects.create( + login="test_client", age=15, location="Moscow", gender="FEMALE" + ) + self.campaign.view(client) + + self.assertEqual(self.campaign.impressions.count(), 1) + + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def test_click_method(self): + client = Client.objects.create( + login="test_client", age=15, location="Moscow", gender="FEMALE" + ) + self.campaign.view(client) + self.campaign.click(client) + + self.assertEqual(self.campaign.clicks.count(), 1) diff --git a/solution/services/backend/apps/campaign/tests/test_campaign_report_model.py b/solution/services/backend/apps/campaign/tests/test_campaign_report_model.py new file mode 100644 index 0000000..4018292 --- /dev/null +++ b/solution/services/backend/apps/campaign/tests/test_campaign_report_model.py @@ -0,0 +1,65 @@ +from django.test import TestCase, override_settings +from apps.advertiser.models import Advertiser +from apps.campaign.models import Campaign, CampaignReport +from apps.client.models import Client + + +class CampaignReportModelTest(TestCase): + @classmethod + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def setUpTestData(cls): + cls.advertiser = Advertiser.objects.create(name="Test Advertiser") + cls.campaign = Campaign.objects.create( + advertiser=cls.advertiser, + impressions_limit=1000, + clicks_limit=500, + cost_per_impression=0.05, + cost_per_click=0.10, + ad_title="Test Campaign", + ad_text="This is a test campaign.", + start_date=1, + end_date=10, + ) + cls.client_instance = Client.objects.create( + login="test_client", + age=30, + gender="MALE", + location="Test Location", + ) + + def test_campaign_report_creation(self): + report = CampaignReport.objects.create( + campaign=self.campaign, + client=self.client_instance, + state=CampaignReport.CampaignReportState.SENT, + message="Inappropriate content", + flagged_by_llm=True, + ) + + self.assertIsInstance(report, CampaignReport) + self.assertEqual(report.campaign, self.campaign) + self.assertEqual(report.client, self.client_instance) + self.assertEqual(report.state, CampaignReport.CampaignReportState.SENT) + self.assertEqual(report.message, "Inappropriate content") + self.assertTrue(report.flagged_by_llm) + + + def test_campaign_report_unique_together_constraint(self): + CampaignReport.objects.create( + campaign=self.campaign, + client=self.client_instance, + state=CampaignReport.CampaignReportState.SENT, + ) + + with self.assertRaises(Exception): + CampaignReport.objects.create( + campaign=self.campaign, + client=self.client_instance, + state=CampaignReport.CampaignReportState.UNDER_REVIEW, + ) diff --git a/solution/services/backend/apps/campaign/tests/test_campaign_statistics.py b/solution/services/backend/apps/campaign/tests/test_campaign_statistics.py new file mode 100644 index 0000000..0745a39 --- /dev/null +++ b/solution/services/backend/apps/campaign/tests/test_campaign_statistics.py @@ -0,0 +1,148 @@ +from django.core.cache import cache +from django.test import TestCase, override_settings +from apps.advertiser.models import Advertiser +from apps.campaign.models import Campaign, CampaignClick, CampaignImpression +from apps.client.models import Client + + +class CampaignStatisticsTest(TestCase): + @classmethod + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def setUpTestData(cls): + cls.advertiser = Advertiser.objects.create(name="Test Advertiser") + cls.campaign = Campaign.objects.create( + advertiser=cls.advertiser, + impressions_limit=1000, + clicks_limit=500, + cost_per_impression=0.05, + cost_per_click=0.10, + ad_title="Test Campaign", + ad_text="This is a test campaign.", + start_date=1, + end_date=10, + ) + cls.client_instance = Client.objects.create( + login="test_client", + age=30, + gender="MALE", + location="Test Location", + ) + + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def setUp(self): + cache.clear() + cache.set("current_date", 5) + + def test_get_statistics_no_data(self): + stats = self.campaign.get_statistics() + expected_stats = { + "impressions_count": 0, + "clicks_count": 0, + "conversion": 0, + "spent_impressions": 0, + "spent_clicks": 0, + "spent_total": 0, + } + + self.assertEqual(stats, expected_stats) + + def test_get_statistics_with_data(self): + CampaignImpression.objects.create( + campaign=self.campaign, + client=self.client_instance, + price=self.campaign.cost_per_impression, + date=5, + ) + CampaignClick.objects.create( + campaign=self.campaign, + client=self.client_instance, + price=self.campaign.cost_per_click, + date=5, + ) + stats = self.campaign.get_statistics() + expected_stats = { + "impressions_count": 1, + "clicks_count": 1, + "conversion": 100.0, + "spent_impressions": 0.05, + "spent_clicks": 0.10, + "spent_total": 0.15, + } + + self.assertEqual(stats, expected_stats) + + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def test_get_daily_statistics_no_data(self): + daily_stats = self.campaign.get_daily_statistics() + expected_stats = [ + { + "date": day, + "impressions_count": 0, + "clicks_count": 0, + "conversion": 0, + "spent_impressions": 0, + "spent_clicks": 0, + "spent_total": 0, + } + for day in range( + self.campaign.start_date, cache.get("current_date") + 1 + ) + ] + + self.assertEqual(daily_stats, expected_stats) + + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } + ) + def test_get_daily_statistics_with_data(self): + CampaignImpression.objects.create( + campaign=self.campaign, + client=self.client_instance, + price=self.campaign.cost_per_impression, + date=5, + ) + CampaignClick.objects.create( + campaign=self.campaign, + client=self.client_instance, + price=self.campaign.cost_per_click, + date=5, + ) + daily_stats = self.campaign.get_daily_statistics() + expected_stats = [ + { + "date": day, + "impressions_count": 1 if day == 5 else 0, + "clicks_count": 1 if day == 5 else 0, + "conversion": 100.0 if day == 5 else 0, + "spent_impressions": 0.05 if day == 5 else 0, + "spent_clicks": 0.10 if day == 5 else 0, + "spent_total": 0.15 if day == 5 else 0, + } + for day in range( + self.campaign.start_date, cache.get("current_date") + 1 + ) + ] + + self.assertEqual(daily_stats, expected_stats) diff --git a/solution/services/backend/apps/campaign/validators.py b/solution/services/backend/apps/campaign/validators.py new file mode 100644 index 0000000..ec0a6f2 --- /dev/null +++ b/solution/services/backend/apps/campaign/validators.py @@ -0,0 +1,49 @@ +from django.core.cache import cache +from django.core.exceptions import ValidationError + + +class CampaignAgeValidator: + def __call__(self, instance) -> None: # noqa: ANN001 + if ( + isinstance(instance.age_from, int) + and isinstance(instance.age_to, int) + and instance.age_from > instance.age_to + ): + err = "age_from can't be greater than age_to" + raise ValidationError(err) + + +class CampaignDurationValidator: + def __call__(self, instance) -> None: # noqa: ANN001 + if ( + isinstance(instance.start_date, int) + and isinstance(instance.end_date, int) + and instance.start_date > instance.end_date + ): + err = "start_date can't be greater than end_date" + raise ValidationError(err) + + +class CampaignLimitsValidator: + def __call__(self, instance) -> None: # noqa: ANN001 + if ( + isinstance(instance.impressions_limit, int) + and isinstance(instance.clicks_limit, int) + and instance.impressions_limit < instance.clicks_limit + ): + err = "clicks_limit can't be greater than impressions_limit" + raise ValidationError(err) + + +class CampaignTargetingLocationValidator: + def __call__(self, instance) -> None: # noqa: ANN001 + if instance.location == "": + err = "targeting.location cannot be blank" + raise ValidationError(err) + + +class CampaignReportMessageValidator: + def __call__(self, instance) -> None: # noqa: ANN001 + if instance.message == "": + err = "message cannot be blank" + raise ValidationError(err)