From acf169e250cf47e0c396323c8543a48fc4c1d22f Mon Sep 17 00:00:00 2001 From: David Fischer Date: Mon, 16 Oct 2023 15:02:50 -0700 Subject: [PATCH] Offers now store if they are paid ad eligible Adds two new fields to the offer table: - paid_eligible - whether this offer could be a paid ad - is_proxy - whether the offer is to a proxy/vpn These were combined so we have a single migration against the offers table. Whether an offer could be paid requires the publisher to be approved for paid campaigns and that the request didn't exclude paid campaigns. --- adserver/admin.py | 5 +- adserver/api/views.py | 32 ++++++++--- .../migrations/0089_paid_eligible_isproxy.py | 55 +++++++++++++++++++ adserver/models.py | 45 ++++++++++++++- adserver/tests/test_api.py | 9 ++- 5 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 adserver/migrations/0089_paid_eligible_isproxy.py diff --git a/adserver/admin.py b/adserver/admin.py index a03410f4..599cad92 100644 --- a/adserver/admin.py +++ b/adserver/admin.py @@ -917,7 +917,8 @@ class AdBaseAdmin(RemoveDeleteMixin, admin.ModelAdmin): "browser_family", "os_family", "is_mobile", - "is_bot", + "is_proxy", + "paid_eligible", "user_agent", "ip", "div_id", @@ -930,6 +931,8 @@ class AdBaseAdmin(RemoveDeleteMixin, admin.ModelAdmin): list_select_related = ("advertisement", "publisher") list_filter = ( "is_mobile", + "is_proxy", + "paid_eligible", "publisher", "advertisement__flight__campaign__advertiser", ) diff --git a/adserver/api/views.py b/adserver/api/views.py index 88534d23..8b4fde90 100644 --- a/adserver/api/views.py +++ b/adserver/api/views.py @@ -13,6 +13,7 @@ from rest_framework.views import APIView from rest_framework_jsonp.renderers import JSONPRenderer +from ..constants import PAID_CAMPAIGN from ..decisionengine import get_ad_decision_backend from ..models import AdImpression from ..models import Advertisement @@ -155,7 +156,9 @@ class AdDecisionView(GeoIpMixin, APIView): permission_classes = (AdDecisionPermission,) renderer_classes = (JSONRenderer, JSONPRenderer) - def _prepare_response(self, ad, placement, publisher, keywords, url, forced=False): + def _prepare_response( + self, ad, placement, publisher, keywords, url, forced=False, paid_eligible=False + ): """ Wrap `offer_ad` with the placement for the publisher. @@ -180,6 +183,7 @@ def _prepare_response(self, ad, placement, publisher, keywords, url, forced=Fals div_id=div_id, keywords=keywords, url=url, + paid_eligible=paid_eligible, ) return {} @@ -191,6 +195,7 @@ def _prepare_response(self, ad, placement, publisher, keywords, url, forced=Fals keywords=keywords, url=url, forced=forced, + paid_eligible=paid_eligible, ) log.debug( "Offering ad. publisher=%s ad_type=%s div_id=%s keywords=%s", @@ -275,18 +280,33 @@ def decision(self, request, data): :return: An add decision (JSON) or an empty JSON dict """ serializer = AdDecisionSerializer(data=data) - forced = False if serializer.is_valid(): publisher = serializer.validated_data["publisher"] self.check_object_permissions(request, publisher) url = serializer.validated_data.get("url") keywords = serializer.validated_data.get("keywords") + campaign_types = serializer.validated_data.get("campaign_types") + + forced = False + paid_eligible = False # Ignore keywords from the API for certain publishers if not publisher.allow_api_keywords: keywords = [] + if serializer.validated_data.get( + "force_ad" + ) or serializer.validated_data.get("force_campaign"): + forced = True + + if ( + not forced + and publisher.allow_paid_campaigns + and (not campaign_types or PAID_CAMPAIGN in campaign_types) + ): + paid_eligible = True + backend = get_ad_decision_backend()( # Required parameters request=request, @@ -294,7 +314,7 @@ def decision(self, request, data): publisher=publisher, # Optional parameters keywords=keywords, - campaign_types=serializer.validated_data.get("campaign_types"), + campaign_types=campaign_types, url=url, placement_index=serializer.validated_data.get("placement_index"), # Debugging parameters @@ -303,11 +323,6 @@ def decision(self, request, data): ) ad, placement = backend.get_ad_and_placement() - if serializer.validated_data.get( - "force_ad" - ) or serializer.validated_data.get("force_campaign"): - forced = True - return Response( self._prepare_response( ad=ad, @@ -317,6 +332,7 @@ def decision(self, request, data): keywords=backend.keywords, url=url, forced=forced, + paid_eligible=paid_eligible, ) ) diff --git a/adserver/migrations/0089_paid_eligible_isproxy.py b/adserver/migrations/0089_paid_eligible_isproxy.py new file mode 100644 index 00000000..2b336626 --- /dev/null +++ b/adserver/migrations/0089_paid_eligible_isproxy.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.4 on 2023-10-16 21:31 +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("adserver", "0088_linked_discounts"), + ] + + operations = [ + migrations.AddField( + model_name="click", + name="is_proxy", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="click", + name="paid_eligible", + field=models.BooleanField( + default=None, + help_text="Whether the impression was eligible for a paid ad", + null=True, + ), + ), + migrations.AddField( + model_name="offer", + name="is_proxy", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="offer", + name="paid_eligible", + field=models.BooleanField( + default=None, + help_text="Whether the impression was eligible for a paid ad", + null=True, + ), + ), + migrations.AddField( + model_name="view", + name="is_proxy", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="view", + name="paid_eligible", + field=models.BooleanField( + default=None, + help_text="Whether the impression was eligible for a paid ad", + null=True, + ), + ), + ] diff --git a/adserver/models.py b/adserver/models.py index c3459c1e..3cd74a33 100644 --- a/adserver/models.py +++ b/adserver/models.py @@ -62,6 +62,7 @@ from .utils import get_client_ip from .utils import get_client_user_agent from .utils import get_domain_from_url +from .utils import is_proxy_ip from .validators import TargetingParametersValidator from .validators import TopicPricingValidator from .validators import TrafficFillValidator @@ -1627,7 +1628,15 @@ def incr(self, impression_type, publisher): ) def _record_base( - self, request, model, publisher, keywords, url, div_id, ad_type_slug + self, + request, + model, + publisher, + keywords, + url, + div_id, + ad_type_slug, + paid_eligible=False, ): """ Save the actual AdBase model to the database. @@ -1665,11 +1674,13 @@ def _record_base( client_id=client_id, country=country, url=url, + paid_eligible=paid_eligible, # Derived user agent data browser_family=parsed_ua.browser.family, os_family=parsed_ua.os.family, is_bot=parsed_ua.is_bot, is_mobile=parsed_ua.is_mobile, + is_proxy=is_proxy_ip(ip_address), # Client Data keywords=keywords if keywords else None, # Don't save empty lists div_id=div_id, @@ -1699,6 +1710,7 @@ def track_click(self, request, publisher, offer): url=offer.url, div_id=offer.div_id, ad_type_slug=offer.ad_type_slug, + paid_eligible=offer.paid_eligible, ) def track_view(self, request, publisher, offer): @@ -1725,6 +1737,7 @@ def track_view(self, request, publisher, offer): url=offer.url, div_id=offer.div_id, ad_type_slug=offer.ad_type_slug, + paid_eligible=offer.paid_eligible, ) log.debug("Not recording ad view.") @@ -1750,7 +1763,15 @@ def track_view_time(self, offer, view_time): return False def offer_ad( - self, request, publisher, ad_type_slug, div_id, keywords, url=None, forced=False + self, + request, + publisher, + ad_type_slug, + div_id, + keywords, + url=None, + forced=False, + paid_eligible=False, ): """ Offer to display this ad on a specific publisher and a specific display (ad type). @@ -1768,6 +1789,7 @@ def offer_ad( url=url, div_id=div_id, ad_type_slug=ad_type_slug, + paid_eligible=paid_eligible, ) if forced and self.flight.campaign.campaign_type == PAID_CAMPAIGN: @@ -1832,7 +1854,16 @@ def offer_ad( } @classmethod - def record_null_offer(cls, request, publisher, ad_type_slug, div_id, keywords, url): + def record_null_offer( + cls, + request, + publisher, + ad_type_slug, + div_id, + keywords, + url, + paid_eligible=False, + ): """ Store null offers, so that we can keep track of our fill rate. @@ -1849,6 +1880,7 @@ def record_null_offer(cls, request, publisher, ad_type_slug, div_id, keywords, u url=url, div_id=div_id, ad_type_slug=ad_type_slug, + paid_eligible=paid_eligible, ) def is_valid_offer(self, impression_type, offer): @@ -2386,6 +2418,12 @@ class AdBase(TimeStampedModel, IndestructibleModel): on_delete=models.PROTECT, ) + paid_eligible = models.BooleanField( + help_text=_("Whether the impression was eligible for a paid ad"), + default=None, + null=True, + ) + # User Data ip = models.GenericIPAddressField(_("Ip Address")) # anonymized user_agent = models.CharField( @@ -2419,6 +2457,7 @@ class AdBase(TimeStampedModel, IndestructibleModel): is_bot = models.BooleanField(default=False) is_mobile = models.BooleanField(default=False) + is_proxy = models.BooleanField(default=False) is_refunded = models.BooleanField(default=False) impression_type = None diff --git a/adserver/tests/test_api.py b/adserver/tests/test_api.py index 512ea2c9..a61d6520 100644 --- a/adserver/tests/test_api.py +++ b/adserver/tests/test_api.py @@ -434,7 +434,9 @@ def test_force_ad_counted(self): self.assertTrue("id" in resp.json()) self.assertEqual(resp.json()["id"], "ad-slug") self.proxy_client.get(resp.json()["view_url"]) - self.assertFalse(self.ad.offers.first().viewed) + offer = self.ad.offers.first() + self.assertFalse(offer.viewed) + self.assertFalse(offer.paid_eligible) # House ads are counted even when forced self.ad.flight.campaign.campaign_type = "house" @@ -615,6 +617,9 @@ def test_campaign_types(self): self.assertEqual(resp_json["id"], "ad-slug", resp_json) self.assertEqual(resp_json["campaign_type"], PAID_CAMPAIGN) + offer = Offer.objects.get(pk=resp_json["nonce"]) + self.assertTrue(offer.paid_eligible) + # Try community only data["campaign_types"] = [COMMUNITY_CAMPAIGN] resp = self.client.post( @@ -633,6 +638,8 @@ def test_campaign_types(self): resp_json = resp.json() self.assertEqual(resp_json["id"], "ad-slug", resp_json) self.assertEqual(resp_json["campaign_type"], COMMUNITY_CAMPAIGN) + offer = Offer.objects.get(pk=resp_json["nonce"]) + self.assertFalse(offer.paid_eligible) # Try multiple campaign types data["campaign_types"] = [PAID_CAMPAIGN, HOUSE_CAMPAIGN]