add sponsored shelf that uses Adzerk for placement (#15670)
* add sponsored shelf that uses Adzerk for placement * cleanup of some commented out code * log a broken json response as an adzerk fail too * totally not just renaming the test class to work around an ES indexing bug * change to use mixins instead of subclassing TestESAddonSerializerOutput
This commit is contained in:
Родитель
024628c237
Коммит
92f3acc5da
|
@ -41,8 +41,8 @@ using the API.
|
|||
categories
|
||||
collections
|
||||
discovery
|
||||
hero
|
||||
ratings
|
||||
reviewers
|
||||
scanners
|
||||
shelves
|
||||
signing
|
||||
|
|
|
@ -378,6 +378,8 @@ v4 API changelog
|
|||
* 2020-09-17: dropped ``recommended=true`` filter from addons api - use ``promoted=recommended`` filter instead. https://github.com/mozilla/addons-server/issues/15467
|
||||
* 2020-09-17: added ``?promoted=badged`` search filter to addons api. https://github.com/mozilla/addons-server/issues/15468
|
||||
* 2020-10-08: added channel-specific reviewer submission subscriptions endpoints. https://github.com/mozilla/addons-server/issues/15605
|
||||
* 2020-10-15: moved hero shelves documentation to /shelves from /hero.
|
||||
* 2020-10-15: added /shelves/sponsored/ endpoint https://github.com/mozilla/addons-server/issues/15617
|
||||
|
||||
.. _`#11380`: https://github.com/mozilla/addons-server/issues/11380/
|
||||
.. _`#11379`: https://github.com/mozilla/addons-server/issues/11379/
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
============
|
||||
Hero Shelves
|
||||
============
|
||||
=======
|
||||
Shelves
|
||||
=======
|
||||
|
||||
.. note::
|
||||
|
||||
|
@ -80,3 +80,25 @@ small number of shelves - and likely only one - this endpoint is not paginated.
|
|||
:>json object|null results[].modules[].cta: The optional call to action link and text to be displayed with the item.
|
||||
:>json string results[].modules[].cta.url: The url the call to action would link to.
|
||||
:>json string results[].modules[].cta.text: The call to action text.
|
||||
|
||||
|
||||
---------------
|
||||
Sponsored Shelf
|
||||
---------------
|
||||
|
||||
.. _sponsored-shelf:
|
||||
|
||||
This endpoint returns the addons that should be shown on the sponsored shelf.
|
||||
Current implementation relies on Adzerk to determine which addons are returned and in which order.
|
||||
|
||||
|
||||
.. http:get:: /api/v4/shelves/sponsored/
|
||||
|
||||
:query string lang: Activate translations in the specific language for that query. (See :ref:`translated fields <api-overview-translations>`)
|
||||
:query int page_size: specify how many addons should be returned. Defaults to 6. Note: fewer addons could be returned if there are fewer than specifed sponsored addons currently, or the Adzerk service is unavailable.
|
||||
:query string wrap_outgoing_links: If this parameter is present, wrap outgoing links through ``outgoing.prod.mozaws.net`` (See :ref:`Outgoing Links <api-overview-outgoing>`)
|
||||
:>json array results: The array containing the addon results for this query. The object is a :ref:`add-on <addon-detail-object>` as returned by :ref:`add-on search endpoint <addon-search>` with extra fields of ``click_url`` and ``click_data``
|
||||
:>json string results[].click_url: the url to ping if the sponsored addon's detail page is navigated to.
|
||||
:>json string results[].click_data: the data payload to send to ``click_url`` that identifies the sponsored placement clicked on.
|
||||
:>json string impression_url: the url to ping when the contents of this sponsored shelf is rendered on screen to the user.
|
||||
:>json string impression_data: the data payload to send to ``impression_url`` that identifies the sponsored placements displayed.
|
|
@ -37,7 +37,12 @@ class AddonSerializerOutputTestMixin(object):
|
|||
|
||||
def setUp(self):
|
||||
super(AddonSerializerOutputTestMixin, self).setUp()
|
||||
self.request = APIRequestFactory().get('/')
|
||||
self.request = self.get_request('/')
|
||||
|
||||
def get_request(self, path, data=None, **extra):
|
||||
request = APIRequestFactory().get(path, data, **extra)
|
||||
request.version = None
|
||||
return request
|
||||
|
||||
def _test_author(self, author, data):
|
||||
assert data == {
|
||||
|
@ -296,7 +301,7 @@ class AddonSerializerOutputTestMixin(object):
|
|||
contributions=u'https://paypal.me/fôobar',
|
||||
homepage='http://support.example.com/',
|
||||
support_url=u'https://support.example.org/support/my-âddon/')
|
||||
self.request = APIRequestFactory().get('/', {'wrap_outgoing_links': 1})
|
||||
self.request = self.get_request('/', {'wrap_outgoing_links': 1})
|
||||
result = self.serialize()
|
||||
utm_string = '&'.join(
|
||||
f'{key}={value}'
|
||||
|
@ -311,7 +316,7 @@ class AddonSerializerOutputTestMixin(object):
|
|||
}
|
||||
|
||||
# Try a single translation.
|
||||
self.request = APIRequestFactory().get('/', {
|
||||
self.request = self.get_request('/', {
|
||||
'lang': 'en-US', 'wrap_outgoing_links': 1})
|
||||
result = self.serialize()
|
||||
assert result['contributions_url'] == get_outgoing_url(
|
||||
|
@ -323,7 +328,7 @@ class AddonSerializerOutputTestMixin(object):
|
|||
'en-US': get_outgoing_url(str(self.addon.support_url)),
|
||||
}
|
||||
# And again, but with v3 style flat strings
|
||||
gates = {None: ('l10n_flat_input_output',)}
|
||||
gates = {self.request.version: ('l10n_flat_input_output',)}
|
||||
with override_settings(DRF_API_GATES=gates):
|
||||
result = self.serialize()
|
||||
assert result['contributions_url'] == get_outgoing_url(
|
||||
|
@ -375,7 +380,7 @@ class AddonSerializerOutputTestMixin(object):
|
|||
assert 'is_source_public' not in result
|
||||
|
||||
# It's only present in v3
|
||||
gates = {None: ('is-source-public-shim',)}
|
||||
gates = {self.request.version: ('is-source-public-shim',)}
|
||||
with override_settings(DRF_API_GATES=gates):
|
||||
result = self.serialize()
|
||||
assert result['is_source_public'] is False
|
||||
|
@ -460,7 +465,7 @@ class AddonSerializerOutputTestMixin(object):
|
|||
assert 'is_featured' not in result
|
||||
|
||||
# It's only present in v3
|
||||
gates = {None: ('is-featured-addon-shim',)}
|
||||
gates = {self.request.version: ('is-featured-addon-shim',)}
|
||||
with override_settings(DRF_API_GATES=gates):
|
||||
result = self.serialize()
|
||||
assert result['is_featured'] is True
|
||||
|
@ -517,7 +522,7 @@ class AddonSerializerOutputTestMixin(object):
|
|||
# Try a single translation. The locale activation is normally done by
|
||||
# LocaleAndAppURLMiddleware, but since we're directly calling the
|
||||
# serializer we need to do it ourselves.
|
||||
self.request = APIRequestFactory().get('/', {'lang': 'fr'})
|
||||
self.request = self.get_request('/', {'lang': 'fr'})
|
||||
with override('fr'):
|
||||
result = self.serialize()
|
||||
assert result['description'] == {'fr': translated_descriptions['fr']}
|
||||
|
@ -525,7 +530,7 @@ class AddonSerializerOutputTestMixin(object):
|
|||
|
||||
# And again, but with v3 style flat strings
|
||||
with override('fr'):
|
||||
gates = {None: ('l10n_flat_input_output',)}
|
||||
gates = {self.request.version: ('l10n_flat_input_output',)}
|
||||
with override_settings(DRF_API_GATES=gates):
|
||||
result = self.serialize()
|
||||
assert result['description'] == translated_descriptions['fr']
|
||||
|
@ -639,7 +644,7 @@ class AddonSerializerOutputTestMixin(object):
|
|||
self.addon.created.replace(microsecond=0).isoformat() + 'Z')
|
||||
|
||||
# And to make sure it's not present in v3
|
||||
gates = {None: ('del-addons-created-field',)}
|
||||
gates = {self.request.version: ('del-addons-created-field',)}
|
||||
with override_settings(DRF_API_GATES=gates):
|
||||
result = self.serialize()
|
||||
assert 'created' not in result
|
||||
|
@ -696,7 +701,7 @@ class TestAddonSerializerOutput(AddonSerializerOutputTestMixin, TestCase):
|
|||
min_app_version='58.0', max_app_version='58.*')
|
||||
not_public_version_for_58.update(created=self.days_ago(1))
|
||||
|
||||
self.request = APIRequestFactory().get('/?app=firefox&appversion=58.0')
|
||||
self.request = self.get_request('/?app=firefox&appversion=58.0')
|
||||
self.action = 'retrieve'
|
||||
|
||||
result = self.serialize()
|
||||
|
@ -719,7 +724,7 @@ class TestAddonSerializerOutput(AddonSerializerOutputTestMixin, TestCase):
|
|||
addon=self.addon,
|
||||
file_kw={'strict_compatibility': True},
|
||||
min_app_version='59.0', max_app_version='59.*')
|
||||
self.request = APIRequestFactory().get('/?app=firefox&appversion=58.0')
|
||||
self.request = self.get_request('/?app=firefox&appversion=58.0')
|
||||
self.action = 'retrieve'
|
||||
|
||||
result = self.serialize()
|
||||
|
@ -772,7 +777,7 @@ class TestAddonSerializerOutput(AddonSerializerOutputTestMixin, TestCase):
|
|||
min_app_version='59.0', max_app_version='59.*')
|
||||
# The parameters are going to be ignored, since we're not dealing with
|
||||
# the detail API.
|
||||
self.request = APIRequestFactory().get('/?app=firefox&appversion=58.0')
|
||||
self.request = self.get_request('/?app=firefox&appversion=58.0')
|
||||
self.action = 'list'
|
||||
|
||||
result = self.serialize()
|
||||
|
@ -798,7 +803,7 @@ class TestAddonSerializerOutput(AddonSerializerOutputTestMixin, TestCase):
|
|||
file_kw={'strict_compatibility': True},
|
||||
min_app_version='59.0', max_app_version='59.*')
|
||||
# The parameters are going to be ignored since it's not a langpack.
|
||||
self.request = APIRequestFactory().get('/?app=firefox&appversion=58.0')
|
||||
self.request = self.get_request('/?app=firefox&appversion=58.0')
|
||||
self.action = 'retrieve'
|
||||
|
||||
result = self.serialize()
|
||||
|
@ -848,7 +853,7 @@ class TestESAddonSerializerOutput(AddonSerializerOutputTestMixin, ESTestCase):
|
|||
def serialize(self):
|
||||
self.serializer = self.serializer_class(context={
|
||||
'request': self.request,
|
||||
'view': AddonSearchView(action='list')
|
||||
'view': AddonSearchView(action='list'),
|
||||
})
|
||||
|
||||
obj = self.search()
|
||||
|
|
|
@ -1935,3 +1935,8 @@ GOOGLE_APPLICATION_CREDENTIALS = env(
|
|||
# See: https://bugzilla.mozilla.org/show_bug.cgi?id=1633746
|
||||
BIGQUERY_PROJECT = 'moz-fx-data-shared-prod'
|
||||
BIGQUERY_AMO_DATASET = 'amo_dev'
|
||||
|
||||
ADZERK_TIMEOUT = 5 # seconds
|
||||
ADZERK_NETWORK_ID = env('ADZERK_NETWORK_ID', default=10521)
|
||||
ADZERK_SITE_ID = env('ADZERK_SITE_ID', default=1131244)
|
||||
ADZERK_URL = f'https://e-{ADZERK_NETWORK_ID}.adzerk.net/api/v2'
|
||||
|
|
|
@ -5,8 +5,10 @@ from rest_framework.reverse import reverse as drf_reverse
|
|||
|
||||
from django.conf import settings
|
||||
|
||||
from olympia.addons.serializers import ESAddonSerializer
|
||||
from olympia.addons.views import AddonSearchView
|
||||
from olympia.shelves.models import Shelf
|
||||
|
||||
from .models import Shelf
|
||||
|
||||
|
||||
class ShelfSerializer(serializers.ModelSerializer):
|
||||
|
@ -46,3 +48,19 @@ class ShelfSerializer(serializers.ModelSerializer):
|
|||
return AddonSearchView(request=request).data
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class ESSponsoredAddonSerializer(ESAddonSerializer):
|
||||
click_url = serializers.SerializerMethodField()
|
||||
click_data = serializers.SerializerMethodField()
|
||||
|
||||
class Meta(ESAddonSerializer.Meta):
|
||||
fields = ESAddonSerializer.Meta.fields + ('click_url', 'click_data')
|
||||
|
||||
def get_click_url(self, obj):
|
||||
return drf_reverse(
|
||||
'sponsored-shelf-click',
|
||||
request=self.context.get('request'))
|
||||
|
||||
def get_click_data(self, obj):
|
||||
return None
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"user": {
|
||||
"key": "ue1-ef2c16a5ae7e445085075a3ef40c11a0"
|
||||
},
|
||||
"decisions": {
|
||||
"div0": {
|
||||
"adId": 20683143,
|
||||
"creativeId": 18023678,
|
||||
"flightId": 12435798,
|
||||
"campaignId": 1693466,
|
||||
"priorityId": 204498,
|
||||
"clickUrl": "https://e-10521.adzerk.net/r?e=eyJ2IjoiMS42IiwiYXLCA",
|
||||
"impressionUrl": "https://e-10521.adzerk.net/i.gif?e=eyJ2IjLtgBug",
|
||||
"contents": [{
|
||||
"type": "raw",
|
||||
"data": {
|
||||
"height": 250,
|
||||
"width": 300,
|
||||
"customData": {
|
||||
"id": "415198"
|
||||
}
|
||||
},
|
||||
"body": ";",
|
||||
"customTemplate": ";"
|
||||
}],
|
||||
"height": 250,
|
||||
"width": 300,
|
||||
"events": []
|
||||
},
|
||||
"div1": {
|
||||
"adId": 20682923,
|
||||
"creativeId": 18023458,
|
||||
"flightId": 12435798,
|
||||
"campaignId": 1693466,
|
||||
"priorityId": 204498,
|
||||
"clickUrl": "https://e-10521.adzerk.net/r?e=eyJ2IjoiMS42IiwiYU5Hw",
|
||||
"impressionUrl": "https://e-10521.adzerk.net/i.gif?e=eyJ2IjoNtSz8",
|
||||
"contents": [{
|
||||
"type": "raw",
|
||||
"data": {
|
||||
"height": 250,
|
||||
"width": 300,
|
||||
"customData": {
|
||||
"id": "566314"
|
||||
}
|
||||
},
|
||||
"body": ";",
|
||||
"customTemplate": ";"
|
||||
}],
|
||||
"height": 250,
|
||||
"width": 300,
|
||||
"events": []
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,11 +7,16 @@ from django.conf import settings
|
|||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
from olympia import amo
|
||||
from olympia.addons.models import Addon
|
||||
from olympia.addons.tests.test_serializers import (
|
||||
AddonSerializerOutputTestMixin)
|
||||
from olympia.amo.tests import addon_factory, ESTestCase, reverse_ns
|
||||
from olympia.constants.promoted import RECOMMENDED
|
||||
from olympia.promoted.models import PromotedAddon
|
||||
from olympia.shelves.models import Shelf
|
||||
from olympia.shelves.serializers import ShelfSerializer
|
||||
|
||||
from ..models import Shelf
|
||||
from ..serializers import ESSponsoredAddonSerializer, ShelfSerializer
|
||||
from ..views import SponsoredShelfViewSet
|
||||
|
||||
|
||||
class TestShelvesSerializer(ESTestCase):
|
||||
|
@ -112,3 +117,65 @@ class TestShelvesSerializer(ESTestCase):
|
|||
'footer_pathname': '',
|
||||
'addons': None
|
||||
}
|
||||
|
||||
|
||||
class TestESSponsoredAddonSerializer(AddonSerializerOutputTestMixin,
|
||||
ESTestCase):
|
||||
serializer_class = ESSponsoredAddonSerializer
|
||||
view_class = SponsoredShelfViewSet
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
self.empty_index('default')
|
||||
self.refresh()
|
||||
|
||||
def search(self):
|
||||
self.reindex(Addon)
|
||||
|
||||
view = self.view_class()
|
||||
view.request = self.request
|
||||
qs = view.get_queryset()
|
||||
|
||||
# We don't even filter - there should only be one addon in the index
|
||||
# at this point
|
||||
return qs.execute()[0]
|
||||
|
||||
def serialize(self):
|
||||
view = self.view_class(action='list')
|
||||
view.request = self.request
|
||||
self.serializer = self.serializer_class(context={
|
||||
'request': self.request,
|
||||
'view': view,
|
||||
})
|
||||
|
||||
obj = self.search()
|
||||
|
||||
with self.assertNumQueries(0):
|
||||
result = self.serializer.to_representation(obj)
|
||||
return result
|
||||
|
||||
def _test_author(self, author, data):
|
||||
"""Override because the ES serializer doesn't include picture_url."""
|
||||
assert data == {
|
||||
'id': author.pk,
|
||||
'name': author.name,
|
||||
'url': author.get_absolute_url(),
|
||||
'username': author.username,
|
||||
}
|
||||
|
||||
def get_request(self, path, data=None, **extra):
|
||||
api_version = 'v5' # choose v5 to ignore 'l10n_flat_input_output' gate
|
||||
request = APIRequestFactory().get(
|
||||
f'/api/{api_version}{path}', data, **extra)
|
||||
request.versioning_scheme = (
|
||||
api_settings.DEFAULT_VERSIONING_CLASS()
|
||||
)
|
||||
request.version = api_version
|
||||
return request
|
||||
|
||||
def test_click_url_and_data(self):
|
||||
self.addon = addon_factory()
|
||||
result = self.serialize()
|
||||
assert result['click_url'] == (
|
||||
'http://testserver/api/v5/shelves/sponsored/click/')
|
||||
assert result['click_data'] is None # currently just a empty repr
|
||||
|
|
|
@ -0,0 +1,187 @@
|
|||
import json
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.encoding import force_bytes
|
||||
|
||||
import responses
|
||||
|
||||
from ..utils import (
|
||||
call_adzerk_server, get_addons_from_adzerk, process_adzerk_results)
|
||||
|
||||
|
||||
# This is a copy of a response from adzerk (with click and impressions trimmed)
|
||||
TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
adzerk_response = os.path.join(TESTS_DIR, 'adzerk', 'adzerk_2.json')
|
||||
with open(adzerk_response) as file_object:
|
||||
adzerk_json = json.load(file_object)
|
||||
|
||||
|
||||
def test_get_addons_from_adzerk_full():
|
||||
responses.add(
|
||||
responses.POST,
|
||||
settings.ADZERK_URL,
|
||||
json=adzerk_json)
|
||||
results = get_addons_from_adzerk(4)
|
||||
assert results == {
|
||||
'415198': {
|
||||
'impression': 'e=eyJ2IjLtgBug',
|
||||
'click': 'e=eyJ2IjoiMS42IiwiYXLCA',
|
||||
'addon_id': '415198'},
|
||||
'566314': {
|
||||
'impression': 'e=eyJ2IjoNtSz8',
|
||||
'click': 'e=eyJ2IjoiMS42IiwiYU5Hw',
|
||||
'addon_id': '566314'},
|
||||
}
|
||||
|
||||
|
||||
@mock.patch('olympia.shelves.utils.statsd.incr')
|
||||
def test_call_adzerk_server(statsd_mock):
|
||||
responses.add(
|
||||
responses.POST,
|
||||
settings.ADZERK_URL,
|
||||
json=adzerk_json)
|
||||
placeholders = ['div0', 'div1', 'div2']
|
||||
site_id = settings.ADZERK_SITE_ID
|
||||
network_id = settings.ADZERK_NETWORK_ID
|
||||
results = call_adzerk_server(placeholders)
|
||||
assert responses.calls[0].request.body == force_bytes(json.dumps(
|
||||
{'placements': [
|
||||
{
|
||||
"divName": 'div0',
|
||||
"networkId": network_id,
|
||||
"siteId": site_id,
|
||||
"adTypes": [5]},
|
||||
{
|
||||
"divName": 'div1',
|
||||
"networkId": network_id,
|
||||
"siteId": site_id,
|
||||
"adTypes": [5]},
|
||||
{
|
||||
"divName": 'div2',
|
||||
"networkId": network_id,
|
||||
"siteId": site_id,
|
||||
"adTypes": [5]},
|
||||
]}))
|
||||
assert 'div0' in results['decisions']
|
||||
assert 'div1' in results['decisions']
|
||||
# we're using a real response that only contains two divs
|
||||
statsd_mock.assert_called_with('services.adzerk.success')
|
||||
|
||||
|
||||
@mock.patch('olympia.shelves.utils.statsd.incr')
|
||||
def test_call_adzerk_server_empty_response(statsd_mock):
|
||||
placeholders = ['div1', 'div2', 'div3']
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
settings.ADZERK_URL)
|
||||
results = call_adzerk_server(placeholders)
|
||||
assert results == {}
|
||||
statsd_mock.assert_called_with('services.adzerk.fail')
|
||||
|
||||
statsd_mock.reset_mock()
|
||||
responses.add(
|
||||
responses.POST,
|
||||
settings.ADZERK_URL,
|
||||
status=500,
|
||||
json={})
|
||||
results = call_adzerk_server(placeholders)
|
||||
assert results == {}
|
||||
statsd_mock.assert_called_with('services.adzerk.fail')
|
||||
|
||||
|
||||
def test_process_adzerk_results():
|
||||
placeholders = ['foo1', 'foo2', 'foo3', 'foo4', 'foo5']
|
||||
assert process_adzerk_results(response={}, placeholders=placeholders) == {}
|
||||
|
||||
response = {
|
||||
'decisions': {
|
||||
'foo1': {
|
||||
"clickUrl": "https://e-9999.adzerk.net/r?e=eyJ2IjoiMS42IiwiA",
|
||||
"impressionUrl": "https://e-9999.adzerk.net/i.gif?e=eyJ2IjLg",
|
||||
"contents": [{
|
||||
"data": {
|
||||
"customData": {
|
||||
"id": "1234"
|
||||
}
|
||||
},
|
||||
}],
|
||||
},
|
||||
'foo2': {
|
||||
"clickUrl": "https://e-9999.adzerk.net/r?e=different",
|
||||
"impressionUrl": "https://e-9999.adzerk.net/i.gif?e=values",
|
||||
"contents": [{
|
||||
"data": {
|
||||
"customData": {
|
||||
"id": "1234" # duplicate id with foo1
|
||||
}
|
||||
},
|
||||
}],
|
||||
},
|
||||
'foo3': {
|
||||
"clickUrl": "https://e-9999.adzerk.net/r?e=eyJ2IjoiMS42IiwiA",
|
||||
"impressionUrl": "https://e-9999.adzerk.net/i.gif?e=eyJ2IjLg",
|
||||
"contents": [{
|
||||
"data": {
|
||||
"customData": {
|
||||
"id": "not-a-number"
|
||||
}
|
||||
},
|
||||
}],
|
||||
},
|
||||
# no foo4
|
||||
'extrafoo': {
|
||||
"clickUrl": "https://e-9999.adzerk.net/r?e=eyJ2IjoiMS42IiwiA",
|
||||
"impressionUrl": "https://e-9999.adzerk.net/i.gif?e=eyJ2IjLg",
|
||||
"contents": [{
|
||||
"data": {
|
||||
"customData": {
|
||||
"id": "5565"
|
||||
}
|
||||
},
|
||||
}],
|
||||
},
|
||||
'foo5': {
|
||||
"clickUrl": "https://e-9999.adzerk.net/r?e=ey44545",
|
||||
"impressionUrl": "https://e-9999.adzerk.net/i.gif?e=thesfsg",
|
||||
"contents": [{
|
||||
"data": {
|
||||
"customData": {
|
||||
"id": "1"
|
||||
}
|
||||
},
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
results = process_adzerk_results(response, placeholders)
|
||||
assert results == {
|
||||
'1234': {
|
||||
'impression': 'e=eyJ2IjLg',
|
||||
'click': 'e=eyJ2IjoiMS42IiwiA',
|
||||
'addon_id': '1234',
|
||||
},
|
||||
'1': {
|
||||
'impression': 'e=thesfsg',
|
||||
'click': 'e=ey44545',
|
||||
'addon_id': '1',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@mock.patch('olympia.shelves.utils.process_adzerk_results')
|
||||
@mock.patch('olympia.shelves.utils.call_adzerk_server')
|
||||
def test_get_addons_from_adzerk(call_server_mock, process_mock):
|
||||
call_server_mock.return_value = None
|
||||
process_mock.return_value = {'thing': {}}
|
||||
assert get_addons_from_adzerk(2) == {}
|
||||
call_server_mock.assert_called_with(['div0', 'div1'])
|
||||
process_mock.assert_not_called()
|
||||
|
||||
call_server_mock.return_value = {'something': 'something'}
|
||||
assert get_addons_from_adzerk(3) == {'thing': {}}
|
||||
call_server_mock.assert_called_with(['div0', 'div1', 'div2'])
|
||||
process_mock.assert_called_with(
|
||||
{'something': 'something'}, ['div0', 'div1', 'div2'])
|
|
@ -1,15 +1,20 @@
|
|||
import json
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.encoding import force_text
|
||||
|
||||
from olympia import amo
|
||||
from olympia.amo.tests import addon_factory, ESTestCase, reverse_ns
|
||||
from olympia.constants.promoted import RECOMMENDED
|
||||
from olympia.amo.tests import (
|
||||
addon_factory, APITestClient, ESTestCase, reverse_ns)
|
||||
from olympia.constants.promoted import RECOMMENDED, VERIFIED_ONE, VERIFIED_TWO
|
||||
from olympia.promoted.models import PromotedAddon
|
||||
from olympia.shelves.models import Shelf, ShelfManagement
|
||||
|
||||
|
||||
class TestShelfViewSet(ESTestCase):
|
||||
client_class = APITestClient
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
super().setUpTestData()
|
||||
|
@ -121,3 +126,127 @@ class TestShelfViewSet(ESTestCase):
|
|||
assert result['results'][1]['addons'][0]['promoted']['category'] == (
|
||||
'recommended')
|
||||
assert result['results'][1]['addons'][0]['type'] == 'extension'
|
||||
|
||||
|
||||
class TestSponsoredShelfViewSet(ESTestCase):
|
||||
client_class = APITestClient
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.url = reverse_ns('sponsored-shelf-list')
|
||||
|
||||
self.sponsored_ext = addon_factory(
|
||||
name='test addon test01', type=amo.ADDON_EXTENSION)
|
||||
self.make_addon_promoted(
|
||||
self.sponsored_ext, VERIFIED_ONE, approve_version=True)
|
||||
self.sponsored_theme = addon_factory(
|
||||
name='test addon test02', type=amo.ADDON_STATICTHEME)
|
||||
self.make_addon_promoted(
|
||||
self.sponsored_theme, VERIFIED_ONE, approve_version=True)
|
||||
self.verified_ext = addon_factory(
|
||||
name='test addon test03', type=amo.ADDON_EXTENSION)
|
||||
self.make_addon_promoted(
|
||||
self.verified_ext, VERIFIED_TWO, approve_version=True)
|
||||
self.not_promoted = addon_factory(name='test addon test04')
|
||||
self.refresh()
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
self.empty_index('default')
|
||||
self.refresh()
|
||||
|
||||
def perform_search(self, *, url=None, data=None, expected_status=200,
|
||||
expected_queries=0, page_size=6, **headers):
|
||||
url = url or self.url
|
||||
with self.assertNumQueries(expected_queries):
|
||||
response = self.client.get(url, data, **headers)
|
||||
assert response.status_code == expected_status, response.content
|
||||
data = json.loads(force_text(response.content))
|
||||
assert data['next'] is None
|
||||
assert data['previous'] is None
|
||||
assert data['page_size'] == page_size
|
||||
assert data['page_count'] == 1
|
||||
assert data['impression_url'] == reverse_ns(
|
||||
'sponsored-shelf-impression')
|
||||
assert data['impression_data'] is None
|
||||
return data
|
||||
|
||||
def test_no_adzerk_addons(self):
|
||||
with mock.patch('olympia.shelves.views.get_addons_from_adzerk') as get:
|
||||
get.return_value = {}
|
||||
data = self.perform_search()
|
||||
get.assert_called_with(6)
|
||||
assert data['count'] == 0
|
||||
assert len(data['results']) == 0
|
||||
|
||||
def test_basic(self):
|
||||
with mock.patch('olympia.shelves.views.get_addons_from_adzerk') as get:
|
||||
get.return_value = {
|
||||
str(self.sponsored_ext.id): {
|
||||
'addon_id': str(self.sponsored_ext.id),
|
||||
'impression': '123456',
|
||||
'click': 'abcdef'},
|
||||
str(self.sponsored_theme.id): {
|
||||
'addon_id': str(self.sponsored_theme.id),
|
||||
'impression': '012345',
|
||||
'click': 'bcdefg'},
|
||||
}
|
||||
data = self.perform_search()
|
||||
get.assert_called_with(6), get.call_args
|
||||
assert data['count'] == 2
|
||||
assert len(data['results']) == 2
|
||||
assert {itm['id'] for itm in data['results']} == {
|
||||
self.sponsored_ext.pk, self.sponsored_theme.pk}
|
||||
|
||||
def test_adzerk_returns_none_sponsored(self):
|
||||
with mock.patch('olympia.shelves.views.get_addons_from_adzerk') as get:
|
||||
get.return_value = {
|
||||
str(self.sponsored_ext.id): {
|
||||
'addon_id': str(self.sponsored_ext.id),
|
||||
'impression': '123456',
|
||||
'click': 'abcdef'},
|
||||
str(self.sponsored_theme.id): {
|
||||
'addon_id': str(self.sponsored_theme.id),
|
||||
'impression': '012345',
|
||||
'click': 'bcdefg'},
|
||||
str(self.verified_ext.id): {
|
||||
'addon_id': str(self.verified_ext.id),
|
||||
'impression': '55656',
|
||||
'click': 'efef'},
|
||||
str(self.not_promoted.id): {
|
||||
'addon_id': str(self.not_promoted.id),
|
||||
'impression': '735754',
|
||||
'click': 'jydh'},
|
||||
'0': {
|
||||
'addon_id': '0',
|
||||
'impression': '',
|
||||
'click': ''},
|
||||
}
|
||||
data = self.perform_search()
|
||||
get.assert_called_with(6)
|
||||
# non sponsored are ignored
|
||||
assert data['count'] == 2
|
||||
assert len(data['results']) == 2
|
||||
assert {itm['id'] for itm in data['results']} == {
|
||||
self.sponsored_ext.pk, self.sponsored_theme.pk}
|
||||
|
||||
def test_page_size(self):
|
||||
with mock.patch('olympia.shelves.views.get_addons_from_adzerk') as get:
|
||||
get.return_value = {}
|
||||
data = self.perform_search(
|
||||
url=self.url + '?page_size=4', page_size=4)
|
||||
get.assert_called_with(4)
|
||||
assert data['count'] == 0
|
||||
assert len(data['results']) == 0
|
||||
|
||||
def test_impression_endpoint(self):
|
||||
url = reverse_ns('sponsored-shelf-impression')
|
||||
with self.assertNumQueries(0):
|
||||
response = self.client.post(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_click_endpoint(self):
|
||||
url = reverse_ns('sponsored-shelf-click')
|
||||
with self.assertNumQueries(0):
|
||||
response = self.client.post(url)
|
||||
assert response.status_code == 200
|
||||
|
|
|
@ -8,6 +8,8 @@ from olympia.shelves import views
|
|||
|
||||
router = SimpleRouter()
|
||||
router.register('', views.ShelfViewSet, basename='shelves')
|
||||
router.register(
|
||||
'sponsored', views.SponsoredShelfViewSet, basename='sponsored-shelf')
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'', include(router.urls)),
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
from django_statsd.clients import statsd
|
||||
|
||||
import olympia.core.logger
|
||||
|
||||
|
||||
log = olympia.core.logger.getLogger('z.shelves')
|
||||
|
||||
|
||||
def call_adzerk_server(placeholders):
|
||||
"""Call adzerk server to get sponsored addon results.
|
||||
|
||||
`placeholders` is a list of arbitrary strings that we pass so we can
|
||||
identify the order of the results in the response dict."""
|
||||
site_id = settings.ADZERK_SITE_ID
|
||||
network_id = settings.ADZERK_NETWORK_ID
|
||||
placements = [
|
||||
{"divName": ph,
|
||||
"networkId": network_id,
|
||||
"siteId": site_id,
|
||||
"adTypes": [5]} for ph in placeholders]
|
||||
|
||||
json_response = {}
|
||||
try:
|
||||
log.info('Calling adzerk')
|
||||
with statsd.timer('services.adzerk'):
|
||||
response = requests.post(
|
||||
settings.ADZERK_URL,
|
||||
json={'placements': placements},
|
||||
timeout=settings.ADZERK_TIMEOUT)
|
||||
if response.status_code != 200:
|
||||
raise requests.exceptions.RequestException()
|
||||
json_response = response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
log.exception('Calling adzerk failed: %s', e)
|
||||
statsd.incr('services.adzerk.fail')
|
||||
except ValueError as e:
|
||||
log.exception('Decoding adzerk response failed: %s', e)
|
||||
statsd.incr('services.adzerk.fail')
|
||||
else:
|
||||
statsd.incr('services.adzerk.success')
|
||||
return json_response
|
||||
|
||||
|
||||
def process_adzerk_result(decision):
|
||||
contents = (decision.get('contents') or [{}])[0]
|
||||
return {
|
||||
'impression': urlparse(decision.get('impressionUrl', '')).query,
|
||||
'click': urlparse(decision.get('clickUrl', '')).query,
|
||||
'addon_id':
|
||||
contents.get('data', {}).get('customData', {}).get('id', None)
|
||||
}
|
||||
return
|
||||
|
||||
|
||||
def process_adzerk_results(response, placeholders):
|
||||
response_decisions = response.get('decisions', {})
|
||||
decisions = [response_decisions.get(ph, {}) for ph in placeholders]
|
||||
results_dict = {}
|
||||
for decision in decisions:
|
||||
result = process_adzerk_result(decision)
|
||||
addon_id = str(result['addon_id'])
|
||||
if addon_id in results_dict or not addon_id.isdigit():
|
||||
continue # no duplicates or weird/missing ids
|
||||
results_dict[addon_id] = result
|
||||
return results_dict
|
||||
|
||||
|
||||
def get_addons_from_adzerk(count):
|
||||
placeholders = [f'div{i}' for i in range(count)]
|
||||
response = call_adzerk_server(placeholders)
|
||||
results_dict = (
|
||||
process_adzerk_results(response, placeholders) if response else {})
|
||||
log.debug(f'{results_dict=}')
|
||||
return results_dict
|
|
@ -1,11 +1,63 @@
|
|||
from rest_framework import viewsets
|
||||
from django.db.transaction import non_atomic_requests
|
||||
|
||||
from olympia.shelves.models import Shelf
|
||||
from olympia.shelves.serializers import ShelfSerializer
|
||||
from elasticsearch_dsl import Q, query
|
||||
from rest_framework import mixins, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from olympia.addons.views import AddonSearchView
|
||||
from olympia.api.pagination import ESPageNumberPagination
|
||||
from olympia.constants.promoted import VERIFIED_ONE
|
||||
from olympia.search.filters import ReviewedContentFilter
|
||||
|
||||
from .models import Shelf
|
||||
from .serializers import ESSponsoredAddonSerializer, ShelfSerializer
|
||||
from .utils import get_addons_from_adzerk
|
||||
|
||||
|
||||
class ShelfViewSet(viewsets.ModelViewSet):
|
||||
class ShelfViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
|
||||
queryset = Shelf.objects.filter(
|
||||
shelfmanagement__enabled=True).order_by('shelfmanagement__position')
|
||||
permission_classes = []
|
||||
serializer_class = ShelfSerializer
|
||||
|
||||
|
||||
class SponsoredShelfPagination(ESPageNumberPagination):
|
||||
page_size = 6
|
||||
|
||||
|
||||
class SponsoredShelfViewSet(viewsets.ViewSetMixin, AddonSearchView):
|
||||
filter_backends = [ReviewedContentFilter]
|
||||
pagination_class = SponsoredShelfPagination
|
||||
serializer_class = ESSponsoredAddonSerializer
|
||||
|
||||
@classmethod
|
||||
def as_view(cls, actions, **initkwargs):
|
||||
return non_atomic_requests(super().as_view(actions, **initkwargs))
|
||||
|
||||
def get_impression_data(self):
|
||||
return None
|
||||
|
||||
def get_paginated_response(self, data):
|
||||
response = super().get_paginated_response(data)
|
||||
response.data['impression_url'] = self.reverse_action('impression')
|
||||
response.data['impression_data'] = self.get_impression_data()
|
||||
return response
|
||||
|
||||
def filter_queryset(self, qs):
|
||||
qs = super().filter_queryset(qs)
|
||||
count = self.paginator.get_page_size(self.request)
|
||||
results = get_addons_from_adzerk(count)
|
||||
ids = list(results.keys())
|
||||
results_qs = qs.query(query.Bool(must=[
|
||||
Q('terms', id=ids),
|
||||
Q('term', **{'promoted.group_id': VERIFIED_ONE.id})]))
|
||||
return results_qs
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def impression(self, request):
|
||||
return Response()
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def click(self, request):
|
||||
return Response()
|
||||
|
|
Загрузка…
Ссылка в новой задаче