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:
Andrew Williamson 2020-10-08 12:54:29 +01:00 коммит произвёл GitHub
Родитель 024628c237
Коммит 92f3acc5da
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
13 изменённых файлов: 650 добавлений и 27 удалений

Просмотреть файл

@ -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()