Skip to content

Commit 57dc909

Browse files
authored
API endpoint to filter assets by internal name (#1998)
* Move serializers to proper file in sponsors app * Minimal config to have the endpoint set up * Introduce custom query set on generic assets to list all specific implementations * Refactor to rely on DRF default behavior to serialize bad requests * Serialize text assets * Serialize image value as URL * Serialize sponsor name within asset information * Limit reportlab version to one that satisfies dependency but doesn't break imports * Also serializes sponsor slug
1 parent 104c6ea commit 57dc909

File tree

8 files changed

+233
-52
lines changed

8 files changed

+233
-52
lines changed

base-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,4 @@ num2words==0.5.10
4747
django-polymorphic==3.0.0
4848
sorl-thumbnail==12.7.0
4949
docxtpl==0.12.0
50+
reportlab==3.6.6

pydotorg/urls_api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from downloads.api import OSViewSet, ReleaseViewSet, ReleaseFileViewSet
88
from pages.api import PageResource
99
from pages.api import PageViewSet
10-
from sponsors.api import LogoPlacementeAPIList
10+
from sponsors.api import LogoPlacementeAPIList, SponsorshipAssetsAPIList
1111

1212
v1_api = Api(api_name='v1')
1313
v1_api.register(PageResource())
@@ -23,4 +23,5 @@
2323

2424
urlpatterns = [
2525
url(r'sponsors/logo-placement/', LogoPlacementeAPIList.as_view(), name="logo_placement_list"),
26+
url(r'sponsors/sponsorship-assets/', SponsorshipAssetsAPIList.as_view(), name="assets_list"),
2627
]

sponsors/admin.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -828,8 +828,7 @@ def get_child_models(self, *args, **kwargs):
828828
return GenericAsset.all_asset_types()
829829

830830
def get_queryset(self, *args, **kwargs):
831-
classes = self.get_child_models(*args, **kwargs)
832-
return self.model.objects.select_related("content_type").instance_of(*classes)
831+
return GenericAsset.objects.all_assets()
833832

834833
def get_actions(self, request):
835834
actions = super().get_actions(request)

sponsors/api.py

Lines changed: 19 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,53 +2,11 @@
22
from django.urls import reverse
33

44
from rest_framework import permissions
5-
from rest_framework import serializers
6-
from rest_framework.authentication import TokenAuthentication
75
from rest_framework.views import APIView
86
from rest_framework.response import Response
9-
from sponsors.models import BenefitFeature, LogoPlacement, Sponsorship
10-
from sponsors.models.enums import PublisherChoices, LogoPlacementChoices
11-
12-
13-
class LogoPlacementSerializer(serializers.Serializer):
14-
publisher = serializers.CharField()
15-
flight = serializers.CharField()
16-
sponsor = serializers.CharField()
17-
sponsor_slug = serializers.CharField()
18-
description = serializers.CharField()
19-
logo = serializers.URLField()
20-
start_date = serializers.DateField()
21-
end_date = serializers.DateField()
22-
sponsor_url = serializers.URLField()
23-
level_name = serializers.CharField()
24-
level_order = serializers.IntegerField()
25-
26-
27-
class FilterLogoPlacementsSerializer(serializers.Serializer):
28-
publisher = serializers.ChoiceField(
29-
choices=[(c.value, c.name.replace("_", " ").title()) for c in PublisherChoices],
30-
required=False,
31-
)
32-
flight = serializers.ChoiceField(
33-
choices=[(c.value, c.name.replace("_", " ").title()) for c in LogoPlacementChoices],
34-
required=False,
35-
)
36-
37-
@property
38-
def by_publisher(self):
39-
return self.validated_data.get("publisher")
40-
41-
@property
42-
def by_flight(self):
43-
return self.validated_data.get("flight")
44-
45-
def skip_logo(self, logo):
46-
if self.by_publisher and self.by_publisher != logo.publisher:
47-
return True
48-
if self.by_flight and self.by_flight != logo.logo_place:
49-
return True
50-
else:
51-
return False
7+
from sponsors.models import BenefitFeature, LogoPlacement, Sponsorship, GenericAsset
8+
from sponsors.serializers import LogoPlacementSerializer, FilterLogoPlacementsSerializer, FilterAssetsSerializer, \
9+
AssetSerializer
5210

5311

5412
class SponsorPublisherPermission(permissions.BasePermission):
@@ -68,8 +26,7 @@ class LogoPlacementeAPIList(APIView):
6826
def get(self, request, *args, **kwargs):
6927
placements = []
7028
logo_filters = FilterLogoPlacementsSerializer(data=request.GET)
71-
if not logo_filters.is_valid():
72-
return Response(logo_filters.errors, status=400)
29+
logo_filters.is_valid(raise_exception=True)
7330

7431
sponsorships = Sponsorship.objects.enabled().with_logo_placement()
7532
for sponsorship in sponsorships.select_related("sponsor").iterator():
@@ -100,3 +57,18 @@ def get(self, request, *args, **kwargs):
10057

10158
serializer = LogoPlacementSerializer(placements, many=True)
10259
return Response(serializer.data)
60+
61+
62+
class SponsorshipAssetsAPIList(APIView):
63+
permission_classes = [SponsorPublisherPermission]
64+
65+
def get(self, request, *args, **kwargs):
66+
assets_filter = FilterAssetsSerializer(data=request.GET)
67+
assets_filter.is_valid(raise_exception=True)
68+
69+
assets = GenericAsset.objects.all_assets().filter(
70+
internal_name=assets_filter.by_internal_name).iterator()
71+
assets = (a for a in assets if assets_filter.accept_empty or a.has_value)
72+
serializer = AssetSerializer(assets, many=True)
73+
74+
return Response(serializer.data)

sponsors/models/assets.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from polymorphic.managers import PolymorphicManager
1414
from polymorphic.models import PolymorphicModel
1515

16+
from sponsors.models.managers import GenericAssetQuerySet
17+
1618

1719
def generic_asset_path(instance, filename):
1820
"""
@@ -28,7 +30,7 @@ class GenericAsset(PolymorphicModel):
2830
"""
2931
Base class used to add required assets to Sponsor or Sponsorship objects
3032
"""
31-
objects = PolymorphicManager()
33+
objects = GenericAssetQuerySet.as_manager()
3234
non_polymorphic = models.Manager()
3335

3436
# UUID can't be the object ID because Polymorphic expects default django integer ID

sponsors/models/managers.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,11 @@ def provided_assets(self):
118118
from sponsors.models.benefits import ProvidedAssetMixin
119119
provided_assets_classes = ProvidedAssetMixin.__subclasses__()
120120
return self.instance_of(*provided_assets_classes).select_related("sponsor_benefit__sponsorship")
121+
122+
123+
class GenericAssetQuerySet(PolymorphicQuerySet):
124+
125+
def all_assets(self):
126+
from sponsors.models import GenericAsset
127+
classes = GenericAsset.all_asset_types()
128+
return self.select_related("content_type").instance_of(*classes)

sponsors/serializers.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
2+
from rest_framework import serializers
3+
4+
from sponsors.models import GenericAsset
5+
from sponsors.models.enums import PublisherChoices, LogoPlacementChoices
6+
7+
class LogoPlacementSerializer(serializers.Serializer):
8+
publisher = serializers.CharField()
9+
flight = serializers.CharField()
10+
sponsor = serializers.CharField()
11+
sponsor_slug = serializers.CharField()
12+
description = serializers.CharField()
13+
logo = serializers.URLField()
14+
start_date = serializers.DateField()
15+
end_date = serializers.DateField()
16+
sponsor_url = serializers.URLField()
17+
level_name = serializers.CharField()
18+
level_order = serializers.IntegerField()
19+
20+
21+
class AssetSerializer(serializers.ModelSerializer):
22+
content_type = serializers.SerializerMethodField()
23+
value = serializers.SerializerMethodField()
24+
sponsor = serializers.SerializerMethodField()
25+
sponsor_slug = serializers.SerializerMethodField()
26+
27+
class Meta:
28+
model = GenericAsset
29+
fields = ["internal_name", "uuid", "value", "content_type", "sponsor", "sponsor_slug"]
30+
31+
def _get_sponsor_object(self, asset):
32+
if asset.from_sponsorship:
33+
return asset.content_object.sponsor
34+
else:
35+
return asset.content_object
36+
37+
def get_content_type(self, asset):
38+
return asset.content_type.name.title()
39+
40+
def get_value(self, asset):
41+
if not asset.has_value:
42+
return ""
43+
return asset.value if not asset.is_file else asset.value.url
44+
45+
def get_sponsor(self, asset):
46+
return self._get_sponsor_object(asset).name
47+
48+
def get_sponsor_slug(self, asset):
49+
return self._get_sponsor_object(asset).slug
50+
51+
52+
class FilterLogoPlacementsSerializer(serializers.Serializer):
53+
publisher = serializers.ChoiceField(
54+
choices=[(c.value, c.name.replace("_", " ").title()) for c in PublisherChoices],
55+
required=False,
56+
)
57+
flight = serializers.ChoiceField(
58+
choices=[(c.value, c.name.replace("_", " ").title()) for c in LogoPlacementChoices],
59+
required=False,
60+
)
61+
62+
@property
63+
def by_publisher(self):
64+
return self.validated_data.get("publisher")
65+
66+
@property
67+
def by_flight(self):
68+
return self.validated_data.get("flight")
69+
70+
def skip_logo(self, logo):
71+
if self.by_publisher and self.by_publisher != logo.publisher:
72+
return True
73+
if self.by_flight and self.by_flight != logo.logo_place:
74+
return True
75+
else:
76+
return False
77+
78+
79+
class FilterAssetsSerializer(serializers.Serializer):
80+
internal_name = serializers.CharField(max_length=128)
81+
list_empty = serializers.BooleanField(required=False, default=False)
82+
83+
@property
84+
def by_internal_name(self):
85+
return self.validated_data["internal_name"]
86+
87+
@property
88+
def accept_empty(self):
89+
return self.validated_data.get("list_empty", False)

sponsors/tests/test_api.py

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1+
import uuid
12
from urllib.parse import urlencode
23

34
from django.contrib.auth.models import Permission
5+
from django.core.files.uploadedfile import SimpleUploadedFile
46
from django.urls import reverse_lazy
57
from django.utils.text import slugify
68
from model_bakery import baker
79
from rest_framework.authtoken.models import Token
810
from rest_framework.test import APITestCase
911

10-
from sponsors.models import Sponsor
12+
from sponsors.models import Sponsor, Sponsorship, TextAsset, ImgAsset
1113
from sponsors.models.enums import LogoPlacementChoices, PublisherChoices
1214

1315

@@ -129,3 +131,110 @@ def test_bad_request_for_invalid_filters(self):
129131
self.assertEqual(400, response.status_code)
130132
self.assertIn("flight", data)
131133
self.assertIn("publisher", data)
134+
135+
136+
class SponsorshipAssetsAPIListTests(APITestCase):
137+
138+
def setUp(self):
139+
self.user = baker.make('users.User')
140+
token = Token.objects.get(user=self.user)
141+
self.permission = Permission.objects.get(name='Can access sponsor placement API')
142+
self.user.user_permissions.add(self.permission)
143+
self.authorization = f'Token {token.key}'
144+
self.internal_name = "txt_assets"
145+
self.url = reverse_lazy("assets_list") + f"?internal_name={self.internal_name}"
146+
self.sponsorship = baker.make(Sponsorship, sponsor__name='Sponsor 1')
147+
self.sponsor = baker.make(Sponsor, name='Sponsor 2')
148+
self.txt_asset = TextAsset.objects.create(
149+
internal_name=self.internal_name,
150+
uuid=uuid.uuid4(),
151+
content_object=self.sponsorship,
152+
)
153+
self.img_asset = ImgAsset.objects.create(
154+
internal_name="img_assets",
155+
uuid=uuid.uuid4(),
156+
content_object=self.sponsor,
157+
)
158+
159+
def tearDown(self):
160+
if self.img_asset.has_value:
161+
self.img_asset.value.delete()
162+
163+
def test_invalid_token(self):
164+
Token.objects.all().delete()
165+
response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization)
166+
self.assertEqual(401, response.status_code)
167+
168+
def test_superuser_user_have_permission_by_default(self):
169+
self.user.user_permissions.remove(self.permission)
170+
self.user.is_superuser = True
171+
self.user.is_staff = True
172+
self.user.save()
173+
response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization)
174+
self.assertEqual(200, response.status_code)
175+
176+
def test_staff_have_permission_by_default(self):
177+
self.user.user_permissions.remove(self.permission)
178+
self.user.is_staff = True
179+
self.user.save()
180+
response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization)
181+
self.assertEqual(200, response.status_code)
182+
183+
def test_user_must_have_required_permission(self):
184+
self.user.user_permissions.remove(self.permission)
185+
response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization)
186+
self.assertEqual(403, response.status_code)
187+
188+
def test_bad_request_if_no_internal_name(self):
189+
url = reverse_lazy("assets_list")
190+
response = self.client.get(url, HTTP_AUTHORIZATION=self.authorization)
191+
self.assertEqual(400, response.status_code)
192+
self.assertIn("internal_name", response.json())
193+
194+
def test_list_assets_by_internal_name(self):
195+
# by default exclude assets with no value
196+
response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization)
197+
data = response.json()
198+
self.assertEqual(200, response.status_code)
199+
self.assertEqual(0, len(data))
200+
201+
# update asset to have a value
202+
self.txt_asset.value = "Text Content"
203+
self.txt_asset.save()
204+
205+
response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization)
206+
data = response.json()
207+
208+
self.assertEqual(1, len(data))
209+
self.assertEqual(data[0]["internal_name"], self.internal_name)
210+
self.assertEqual(data[0]["uuid"], str(self.txt_asset.uuid))
211+
self.assertEqual(data[0]["value"], "Text Content")
212+
self.assertEqual(data[0]["content_type"], "Sponsorship")
213+
self.assertEqual(data[0]["sponsor"], "Sponsor 1")
214+
self.assertEqual(data[0]["sponsor_slug"], "sponsor-1")
215+
216+
def test_enable_to_filter_by_assets_with_no_value_via_querystring(self):
217+
self.url += "&list_empty=true"
218+
219+
response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization)
220+
data = response.json()
221+
222+
self.assertEqual(1, len(data))
223+
self.assertEqual(data[0]["uuid"], str(self.txt_asset.uuid))
224+
self.assertEqual(data[0]["value"], "")
225+
self.assertEqual(data[0]["sponsor"], "Sponsor 1")
226+
self.assertEqual(data[0]["sponsor_slug"], "sponsor-1")
227+
228+
def test_serialize_img_value_as_url_to_image(self):
229+
self.img_asset.value = SimpleUploadedFile(name='test_image.jpg', content=b"content", content_type='image/jpeg')
230+
self.img_asset.save()
231+
232+
url = reverse_lazy("assets_list") + f"?internal_name={self.img_asset.internal_name}"
233+
response = self.client.get(url, HTTP_AUTHORIZATION=self.authorization)
234+
data = response.json()
235+
236+
self.assertEqual(1, len(data))
237+
self.assertEqual(data[0]["uuid"], str(self.img_asset.uuid))
238+
self.assertEqual(data[0]["value"], self.img_asset.value.url)
239+
self.assertEqual(data[0]["sponsor"], "Sponsor 2")
240+
self.assertEqual(data[0]["sponsor_slug"], "sponsor-2")

0 commit comments

Comments
 (0)