diff --git a/base-requirements.txt b/base-requirements.txt index 66b693491..566263126 100644 --- a/base-requirements.txt +++ b/base-requirements.txt @@ -47,3 +47,4 @@ num2words==0.5.10 django-polymorphic==3.0.0 sorl-thumbnail==12.7.0 docxtpl==0.12.0 +reportlab==3.6.6 diff --git a/pydotorg/urls_api.py b/pydotorg/urls_api.py index 688a3dee5..0c27699b1 100644 --- a/pydotorg/urls_api.py +++ b/pydotorg/urls_api.py @@ -7,7 +7,7 @@ from downloads.api import OSViewSet, ReleaseViewSet, ReleaseFileViewSet from pages.api import PageResource from pages.api import PageViewSet -from sponsors.api import LogoPlacementeAPIList +from sponsors.api import LogoPlacementeAPIList, SponsorshipAssetsAPIList v1_api = Api(api_name='v1') v1_api.register(PageResource()) @@ -23,4 +23,5 @@ urlpatterns = [ url(r'sponsors/logo-placement/', LogoPlacementeAPIList.as_view(), name="logo_placement_list"), + url(r'sponsors/sponsorship-assets/', SponsorshipAssetsAPIList.as_view(), name="assets_list"), ] diff --git a/sponsors/admin.py b/sponsors/admin.py index c2b471e7c..ff92aabac 100644 --- a/sponsors/admin.py +++ b/sponsors/admin.py @@ -828,8 +828,7 @@ def get_child_models(self, *args, **kwargs): return GenericAsset.all_asset_types() def get_queryset(self, *args, **kwargs): - classes = self.get_child_models(*args, **kwargs) - return self.model.objects.select_related("content_type").instance_of(*classes) + return GenericAsset.objects.all_assets() def get_actions(self, request): actions = super().get_actions(request) diff --git a/sponsors/api.py b/sponsors/api.py index 7b99d0e91..0d180be6d 100644 --- a/sponsors/api.py +++ b/sponsors/api.py @@ -2,53 +2,11 @@ from django.urls import reverse from rest_framework import permissions -from rest_framework import serializers -from rest_framework.authentication import TokenAuthentication from rest_framework.views import APIView from rest_framework.response import Response -from sponsors.models import BenefitFeature, LogoPlacement, Sponsorship -from sponsors.models.enums import PublisherChoices, LogoPlacementChoices - - -class LogoPlacementSerializer(serializers.Serializer): - publisher = serializers.CharField() - flight = serializers.CharField() - sponsor = serializers.CharField() - sponsor_slug = serializers.CharField() - description = serializers.CharField() - logo = serializers.URLField() - start_date = serializers.DateField() - end_date = serializers.DateField() - sponsor_url = serializers.URLField() - level_name = serializers.CharField() - level_order = serializers.IntegerField() - - -class FilterLogoPlacementsSerializer(serializers.Serializer): - publisher = serializers.ChoiceField( - choices=[(c.value, c.name.replace("_", " ").title()) for c in PublisherChoices], - required=False, - ) - flight = serializers.ChoiceField( - choices=[(c.value, c.name.replace("_", " ").title()) for c in LogoPlacementChoices], - required=False, - ) - - @property - def by_publisher(self): - return self.validated_data.get("publisher") - - @property - def by_flight(self): - return self.validated_data.get("flight") - - def skip_logo(self, logo): - if self.by_publisher and self.by_publisher != logo.publisher: - return True - if self.by_flight and self.by_flight != logo.logo_place: - return True - else: - return False +from sponsors.models import BenefitFeature, LogoPlacement, Sponsorship, GenericAsset +from sponsors.serializers import LogoPlacementSerializer, FilterLogoPlacementsSerializer, FilterAssetsSerializer, \ + AssetSerializer class SponsorPublisherPermission(permissions.BasePermission): @@ -68,8 +26,7 @@ class LogoPlacementeAPIList(APIView): def get(self, request, *args, **kwargs): placements = [] logo_filters = FilterLogoPlacementsSerializer(data=request.GET) - if not logo_filters.is_valid(): - return Response(logo_filters.errors, status=400) + logo_filters.is_valid(raise_exception=True) sponsorships = Sponsorship.objects.enabled().with_logo_placement() for sponsorship in sponsorships.select_related("sponsor").iterator(): @@ -100,3 +57,18 @@ def get(self, request, *args, **kwargs): serializer = LogoPlacementSerializer(placements, many=True) return Response(serializer.data) + + +class SponsorshipAssetsAPIList(APIView): + permission_classes = [SponsorPublisherPermission] + + def get(self, request, *args, **kwargs): + assets_filter = FilterAssetsSerializer(data=request.GET) + assets_filter.is_valid(raise_exception=True) + + assets = GenericAsset.objects.all_assets().filter( + internal_name=assets_filter.by_internal_name).iterator() + assets = (a for a in assets if assets_filter.accept_empty or a.has_value) + serializer = AssetSerializer(assets, many=True) + + return Response(serializer.data) diff --git a/sponsors/models/assets.py b/sponsors/models/assets.py index e0762188c..4db7c9671 100644 --- a/sponsors/models/assets.py +++ b/sponsors/models/assets.py @@ -13,6 +13,8 @@ from polymorphic.managers import PolymorphicManager from polymorphic.models import PolymorphicModel +from sponsors.models.managers import GenericAssetQuerySet + def generic_asset_path(instance, filename): """ @@ -28,7 +30,7 @@ class GenericAsset(PolymorphicModel): """ Base class used to add required assets to Sponsor or Sponsorship objects """ - objects = PolymorphicManager() + objects = GenericAssetQuerySet.as_manager() non_polymorphic = models.Manager() # UUID can't be the object ID because Polymorphic expects default django integer ID diff --git a/sponsors/models/managers.py b/sponsors/models/managers.py index cc8657aea..3c06f5794 100644 --- a/sponsors/models/managers.py +++ b/sponsors/models/managers.py @@ -118,3 +118,11 @@ def provided_assets(self): from sponsors.models.benefits import ProvidedAssetMixin provided_assets_classes = ProvidedAssetMixin.__subclasses__() return self.instance_of(*provided_assets_classes).select_related("sponsor_benefit__sponsorship") + + +class GenericAssetQuerySet(PolymorphicQuerySet): + + def all_assets(self): + from sponsors.models import GenericAsset + classes = GenericAsset.all_asset_types() + return self.select_related("content_type").instance_of(*classes) diff --git a/sponsors/serializers.py b/sponsors/serializers.py new file mode 100644 index 000000000..c0782c12a --- /dev/null +++ b/sponsors/serializers.py @@ -0,0 +1,89 @@ + +from rest_framework import serializers + +from sponsors.models import GenericAsset +from sponsors.models.enums import PublisherChoices, LogoPlacementChoices + +class LogoPlacementSerializer(serializers.Serializer): + publisher = serializers.CharField() + flight = serializers.CharField() + sponsor = serializers.CharField() + sponsor_slug = serializers.CharField() + description = serializers.CharField() + logo = serializers.URLField() + start_date = serializers.DateField() + end_date = serializers.DateField() + sponsor_url = serializers.URLField() + level_name = serializers.CharField() + level_order = serializers.IntegerField() + + +class AssetSerializer(serializers.ModelSerializer): + content_type = serializers.SerializerMethodField() + value = serializers.SerializerMethodField() + sponsor = serializers.SerializerMethodField() + sponsor_slug = serializers.SerializerMethodField() + + class Meta: + model = GenericAsset + fields = ["internal_name", "uuid", "value", "content_type", "sponsor", "sponsor_slug"] + + def _get_sponsor_object(self, asset): + if asset.from_sponsorship: + return asset.content_object.sponsor + else: + return asset.content_object + + def get_content_type(self, asset): + return asset.content_type.name.title() + + def get_value(self, asset): + if not asset.has_value: + return "" + return asset.value if not asset.is_file else asset.value.url + + def get_sponsor(self, asset): + return self._get_sponsor_object(asset).name + + def get_sponsor_slug(self, asset): + return self._get_sponsor_object(asset).slug + + +class FilterLogoPlacementsSerializer(serializers.Serializer): + publisher = serializers.ChoiceField( + choices=[(c.value, c.name.replace("_", " ").title()) for c in PublisherChoices], + required=False, + ) + flight = serializers.ChoiceField( + choices=[(c.value, c.name.replace("_", " ").title()) for c in LogoPlacementChoices], + required=False, + ) + + @property + def by_publisher(self): + return self.validated_data.get("publisher") + + @property + def by_flight(self): + return self.validated_data.get("flight") + + def skip_logo(self, logo): + if self.by_publisher and self.by_publisher != logo.publisher: + return True + if self.by_flight and self.by_flight != logo.logo_place: + return True + else: + return False + + +class FilterAssetsSerializer(serializers.Serializer): + internal_name = serializers.CharField(max_length=128) + list_empty = serializers.BooleanField(required=False, default=False) + + @property + def by_internal_name(self): + return self.validated_data["internal_name"] + + @property + def accept_empty(self): + return self.validated_data.get("list_empty", False) diff --git a/sponsors/tests/test_api.py b/sponsors/tests/test_api.py index ab0b7d15c..caabd6aa1 100644 --- a/sponsors/tests/test_api.py +++ b/sponsors/tests/test_api.py @@ -1,13 +1,15 @@ +import uuid from urllib.parse import urlencode from django.contrib.auth.models import Permission +from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse_lazy from django.utils.text import slugify from model_bakery import baker from rest_framework.authtoken.models import Token from rest_framework.test import APITestCase -from sponsors.models import Sponsor +from sponsors.models import Sponsor, Sponsorship, TextAsset, ImgAsset from sponsors.models.enums import LogoPlacementChoices, PublisherChoices @@ -129,3 +131,110 @@ def test_bad_request_for_invalid_filters(self): self.assertEqual(400, response.status_code) self.assertIn("flight", data) self.assertIn("publisher", data) + + +class SponsorshipAssetsAPIListTests(APITestCase): + + def setUp(self): + self.user = baker.make('users.User') + token = Token.objects.get(user=self.user) + self.permission = Permission.objects.get(name='Can access sponsor placement API') + self.user.user_permissions.add(self.permission) + self.authorization = f'Token {token.key}' + self.internal_name = "txt_assets" + self.url = reverse_lazy("assets_list") + f"?internal_name={self.internal_name}" + self.sponsorship = baker.make(Sponsorship, sponsor__name='Sponsor 1') + self.sponsor = baker.make(Sponsor, name='Sponsor 2') + self.txt_asset = TextAsset.objects.create( + internal_name=self.internal_name, + uuid=uuid.uuid4(), + content_object=self.sponsorship, + ) + self.img_asset = ImgAsset.objects.create( + internal_name="img_assets", + uuid=uuid.uuid4(), + content_object=self.sponsor, + ) + + def tearDown(self): + if self.img_asset.has_value: + self.img_asset.value.delete() + + def test_invalid_token(self): + Token.objects.all().delete() + response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization) + self.assertEqual(401, response.status_code) + + def test_superuser_user_have_permission_by_default(self): + self.user.user_permissions.remove(self.permission) + self.user.is_superuser = True + self.user.is_staff = True + self.user.save() + response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization) + self.assertEqual(200, response.status_code) + + def test_staff_have_permission_by_default(self): + self.user.user_permissions.remove(self.permission) + self.user.is_staff = True + self.user.save() + response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization) + self.assertEqual(200, response.status_code) + + def test_user_must_have_required_permission(self): + self.user.user_permissions.remove(self.permission) + response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization) + self.assertEqual(403, response.status_code) + + def test_bad_request_if_no_internal_name(self): + url = reverse_lazy("assets_list") + response = self.client.get(url, HTTP_AUTHORIZATION=self.authorization) + self.assertEqual(400, response.status_code) + self.assertIn("internal_name", response.json()) + + def test_list_assets_by_internal_name(self): + # by default exclude assets with no value + response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization) + data = response.json() + self.assertEqual(200, response.status_code) + self.assertEqual(0, len(data)) + + # update asset to have a value + self.txt_asset.value = "Text Content" + self.txt_asset.save() + + response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization) + data = response.json() + + self.assertEqual(1, len(data)) + self.assertEqual(data[0]["internal_name"], self.internal_name) + self.assertEqual(data[0]["uuid"], str(self.txt_asset.uuid)) + self.assertEqual(data[0]["value"], "Text Content") + self.assertEqual(data[0]["content_type"], "Sponsorship") + self.assertEqual(data[0]["sponsor"], "Sponsor 1") + self.assertEqual(data[0]["sponsor_slug"], "sponsor-1") + + def test_enable_to_filter_by_assets_with_no_value_via_querystring(self): + self.url += "&list_empty=true" + + response = self.client.get(self.url, HTTP_AUTHORIZATION=self.authorization) + data = response.json() + + self.assertEqual(1, len(data)) + self.assertEqual(data[0]["uuid"], str(self.txt_asset.uuid)) + self.assertEqual(data[0]["value"], "") + self.assertEqual(data[0]["sponsor"], "Sponsor 1") + self.assertEqual(data[0]["sponsor_slug"], "sponsor-1") + + def test_serialize_img_value_as_url_to_image(self): + self.img_asset.value = SimpleUploadedFile(name='test_image.jpg', content=b"content", content_type='image/jpeg') + self.img_asset.save() + + url = reverse_lazy("assets_list") + f"?internal_name={self.img_asset.internal_name}" + response = self.client.get(url, HTTP_AUTHORIZATION=self.authorization) + data = response.json() + + self.assertEqual(1, len(data)) + self.assertEqual(data[0]["uuid"], str(self.img_asset.uuid)) + self.assertEqual(data[0]["value"], self.img_asset.value.url) + self.assertEqual(data[0]["sponsor"], "Sponsor 2") + self.assertEqual(data[0]["sponsor_slug"], "sponsor-2")