From 2f330e6f38bd8aa4ba650d3b06a60a264df0d832 Mon Sep 17 00:00:00 2001 From: Andrew Williamson Date: Fri, 20 Aug 2021 20:32:31 +0100 Subject: [PATCH] implement new uploads to the addon api, for listed too. --- src/olympia/addons/serializers.py | 273 +++++++++++++--- src/olympia/addons/tests/test_serializers.py | 7 + src/olympia/addons/tests/test_views.py | 313 ++++++++++++++++++- src/olympia/addons/views.py | 52 ++- src/olympia/versions/models.py | 5 +- 5 files changed, 582 insertions(+), 68 deletions(-) diff --git a/src/olympia/addons/serializers.py b/src/olympia/addons/serializers.py index 88b8f4e7b7c7..50ae9d7b1991 100644 --- a/src/olympia/addons/serializers.py +++ b/src/olympia/addons/serializers.py @@ -6,7 +6,8 @@ from rest_framework import exceptions, serializers -from olympia import amo +import olympia.core.logger +from olympia import activity, amo from olympia.accounts.serializers import ( BaseUserSerializer, UserProfileBasketSyncSerializer, @@ -18,17 +19,20 @@ OutgoingTranslationField, OutgoingURLField, ReverseChoiceField, + SplitField, TranslationSerializerField, ) from olympia.api.serializers import BaseESSerializer from olympia.api.utils import is_gate_active from olympia.applications.models import AppVersion from olympia.bandwagon.models import Collection -from olympia.constants.applications import APPS_ALL, APP_IDS +from olympia.blocklist.models import Block +from olympia.constants.applications import APPS, APPS_ALL, APP_IDS from olympia.constants.base import ADDON_TYPE_CHOICES_API -from olympia.constants.categories import CATEGORIES_BY_ID +from olympia.constants.categories import CATEGORIES, CATEGORIES_BY_ID from olympia.constants.promoted import PROMOTED_GROUPS, RECOMMENDED -from olympia.files.models import File +from olympia.files.models import File, FileUpload +from olympia.files.utils import parse_addon from olympia.promoted.models import PromotedAddon from olympia.search.filters import AddonAppVersionQueryParam from olympia.ratings.utils import get_grouped_ratings @@ -40,7 +44,7 @@ VersionPreview, ) -from .models import Addon, Preview, ReplacementAddon, attach_tags +from .models import Addon, AddonCategory, Preview, ReplacementAddon, attach_tags class FileSerializer(serializers.ModelSerializer): @@ -233,11 +237,12 @@ class Meta: class MinimalVersionSerializer(serializers.ModelSerializer): - file = FileSerializer() + file = FileSerializer(read_only=True) class Meta: model = Version fields = ('id', 'file', 'reviewed', 'version') + read_only_fields = fields def to_representation(self, instance): repr = super().to_representation(instance) @@ -252,14 +257,43 @@ def to_representation(self, instance): return repr +class VersionCompatabilityField(serializers.Field): + def to_internal_value(self, data): + if isinstance(data, dict): + # if it's a dict, just translate the app name + return {amo.APPS[key]: value for key, value in data.items()} + elif isinstance(data, list): + # if it's a list of apps, normalize into a dict + return {amo.APPS[key]: None for key in data} + else: + # if it's neither it's not a valid input + raise exceptions.ValidationError() + + def to_representation(self, value): + return { + app.short: { + 'min': compat.min.version + if compat + else (amo.D2C_MIN_VERSIONS.get(app.id, '1.0')), + 'max': compat.max.version if compat else amo.FAKE_MAX_VERSION, + } + for app, compat in value.items() + } + + class SimpleVersionSerializer(MinimalVersionSerializer): - compatibility = serializers.SerializerMethodField() + compatibility = VersionCompatabilityField( + # default to just Desktop Firefox; most of the times developers don't develop + # their WebExtensions for Android. See https://bit.ly/2QaMicU + source='compatible_apps', + default={amo.APPS['firefox']: None}, + ) edit_url = serializers.SerializerMethodField() is_strict_compatibility_enabled = serializers.BooleanField( - source='file.strict_compatibility' + source='file.strict_compatibility', read_only=True ) license = CompactLicenseSerializer() - release_notes = TranslationSerializerField() + release_notes = TranslationSerializerField(required=False) class Meta: model = Version @@ -274,25 +308,14 @@ class Meta: 'reviewed', 'version', ) + read_only_fields = fields def to_representation(self, instance): - # Help the LicenseSerializer find the version we're currently - # serializing. + # Help the LicenseSerializer find the version we're currently serializing. if 'license' in self.fields and instance.license: instance.license.version_instance = instance return super().to_representation(instance) - def get_compatibility(self, obj): - return { - app.short: { - 'min': compat.min.version - if compat - else (amo.D2C_MIN_VERSIONS.get(app.id, '1.0')), - 'max': compat.max.version if compat else amo.FAKE_MAX_VERSION, - } - for app, compat in obj.compatible_apps.items() - } - def get_edit_url(self, obj): return absolutify( obj.addon.get_dev_url('versions.edit', args=[obj.pk], prefix_only=True) @@ -300,8 +323,16 @@ def get_edit_url(self, obj): class VersionSerializer(SimpleVersionSerializer): - channel = ReverseChoiceField(choices=list(amo.CHANNEL_CHOICES_API.items())) - license = LicenseSerializer() + channel = ReverseChoiceField( + choices=list(amo.CHANNEL_CHOICES_API.items()), read_only=True + ) + license = SplitField( + serializers.PrimaryKeyRelatedField(queryset=License.objects.builtins()), + LicenseSerializer(), + ) + upload = serializers.SlugRelatedField( + slug_field='uuid', queryset=FileUpload.objects.all(), write_only=True + ) class Meta: model = Version @@ -315,8 +346,74 @@ class Meta: 'license', 'release_notes', 'reviewed', + 'upload', 'version', ) + writeable_fields = ( + 'compatibility', + 'license', + 'release_notes', + 'upload', + ) + read_only_fields = tuple(set(fields) - set(writeable_fields)) + + def __init__(self, *args, **kwargs): + self.addon = kwargs.pop('addon', None) + super().__init__(*args, **kwargs) + + def validate_upload(self, value): + own_upload = (request := self.context.get('request')) and ( + request.user == value.user + ) + if not own_upload or not value.valid or value.validation_timeout: + raise exceptions.ValidationError('Upload is not valid.') + return value + + def _check_blocklist(self, guid, version_string): + # check the guid/version isn't in the addon blocklist + block_qs = Block.objects.filter(guid=guid) if guid else () + if block_qs and block_qs.first().is_version_blocked(version_string): + msg = ( + 'Version {version} matches {block_link} for this add-on. ' + 'You can contact {amo_admins} for additional information.' + ) + raise exceptions.ValidationError( + msg.format( + version=version_string, + block_link=absolutify(reverse('blocklist.block', args=[guid])), + amo_admins='amo-admins@mozilla.com', + ), + ) + + def validate(self, data): + if not self.instance: + # Parse the file to get and validate package data with the addon. + self.parsed_data = parse_addon( + data.get('upload'), addon=self.addon, user=self.context['request'].user + ) + guid = self.addon.guid if self.addon else self.parsed_data.get('guid') + self._check_blocklist(guid, self.parsed_data.get('version')) + else: + data.pop('upload', None) # upload can only be set during create + return data + + def create(self, validated_data): + upload = validated_data.get('upload') + parsed_and_validated_data = { + **self.parsed_data, + **validated_data, + 'license_id': validated_data['license'].id, + } + version = Version.from_upload( + upload=upload, + addon=self.addon or validated_data.get('addon'), + # TODO: change Version.from_upload to take the compat values into account + selected_apps=[app.id for app in validated_data.get('compatible_apps')], + channel=upload.channel, + parsed_data=parsed_and_validated_data, + ) + upload.update(addon=version.addon) + return version class VersionListSerializer(VersionSerializer): @@ -426,6 +523,18 @@ def get_apps(self, obj): return [app.short for app in obj.approved_applications] +class CategoriesSerializerField(serializers.Field): + def to_internal_value(self, data): + # Can't do any transformation/validation here because we don't know addon_type + return data + + def to_representation(self, value): + return { + app_short_name: [cat.slug for cat in categories] + for app_short_name, categories in value.items() + } + + class ContributionSerializerField(OutgoingURLField): def to_representation(self, value): if not value: @@ -448,33 +557,42 @@ def to_representation(self, value): class AddonSerializer(serializers.ModelSerializer): - authors = AddonDeveloperSerializer(many=True, source='listed_authors') - categories = serializers.SerializerMethodField() - contributions_url = ContributionSerializerField(source='contributions') - current_version = CurrentVersionSerializer() - description = TranslationSerializerField() - developer_comments = TranslationSerializerField() + authors = AddonDeveloperSerializer( + many=True, source='listed_authors', read_only=True + ) + categories = CategoriesSerializerField(source='app_categories') + contributions_url = ContributionSerializerField( + source='contributions', read_only=True + ) + current_version = CurrentVersionSerializer(read_only=True) + description = TranslationSerializerField(required=False) + developer_comments = TranslationSerializerField(required=False) edit_url = serializers.SerializerMethodField() has_eula = serializers.SerializerMethodField() has_privacy_policy = serializers.SerializerMethodField() - homepage = OutgoingTranslationField() + homepage = OutgoingTranslationField(required=False) icon_url = serializers.SerializerMethodField() icons = serializers.SerializerMethodField() is_source_public = serializers.SerializerMethodField() is_featured = serializers.SerializerMethodField() - name = TranslationSerializerField() - previews = PreviewSerializer(many=True, source='current_previews') - promoted = PromotedAddonSerializer() + name = TranslationSerializerField(required=False) + previews = PreviewSerializer(many=True, source='current_previews', read_only=True) + promoted = PromotedAddonSerializer(read_only=True) ratings = serializers.SerializerMethodField() ratings_url = serializers.SerializerMethodField() review_url = serializers.SerializerMethodField() - status = ReverseChoiceField(choices=list(amo.STATUS_CHOICES_API.items())) - summary = TranslationSerializerField() - support_email = TranslationSerializerField() - support_url = OutgoingTranslationField() + status = ReverseChoiceField( + choices=list(amo.STATUS_CHOICES_API.items()), read_only=True + ) + summary = TranslationSerializerField(required=False) + support_email = TranslationSerializerField(required=False) + support_url = OutgoingTranslationField(required=False) tags = serializers.SerializerMethodField() - type = ReverseChoiceField(choices=list(amo.ADDON_TYPE_CHOICES_API.items())) + type = ReverseChoiceField( + choices=list(amo.ADDON_TYPE_CHOICES_API.items()), read_only=True + ) url = serializers.SerializerMethodField() + version = VersionSerializer(write_only=True) versions_url = serializers.SerializerMethodField() class Meta: @@ -517,9 +635,23 @@ class Meta: 'tags', 'type', 'url', + 'version', 'versions_url', 'weekly_downloads', ) + writeable_fields = ( + 'categories', + 'description', + 'developer_comments', + 'homepage', + 'name', + 'slug', + 'summary', + 'support_email', + 'support_url', + 'version', + ) + read_only_fields = tuple(set(fields) - set(writeable_fields)) def to_representation(self, obj): data = super().to_representation(obj) @@ -533,12 +665,6 @@ def to_representation(self, obj): data.pop('is_featured', None) return data - def get_categories(self, obj): - return { - app_short_name: [cat.slug for cat in categories] - for app_short_name, categories in obj.app_categories.items() - } - def get_has_eula(self, obj): return bool(getattr(obj, 'has_eula', obj.eula)) @@ -599,13 +725,68 @@ def get_ratings(self, obj): def get_is_source_public(self, obj): return False + def validate(self, data): + if not self.instance: + addon_type = self.fields['version'].parsed_data['type'] + else: + addon_type = self.instance.type + if 'app_categories' in data: + try: + category_ids = [] + for app_name, category_names in data['app_categories'].items(): + app = APPS[app_name] + category_ids.extend( + CATEGORIES[app.id][addon_type][name] for name in category_names + ) + data['app_categories'] = category_ids + except KeyError: + raise exceptions.ValidationError( + {'categories': 'Invalid app or category name.'} + ) + + return data + + def create(self, validated_data): + upload = validated_data.get('version').get('upload') + + addon = Addon.initialize_addon_from_upload( + data={**self.fields['version'].parsed_data, **validated_data}, + upload=upload, + channel=upload.channel, + user=self.context['request'].user, + ) + # Add categories + for category in validated_data.get('app_categories', ()): + AddonCategory.objects.create(addon=addon, category_id=category.id) + + self.fields['version'].create( + {**validated_data.get('version', {}), 'addon': addon} + ) + + activity.log_create(amo.LOG.CREATE_ADDON, addon) + olympia.core.logger.getLogger('z.addons').info( + f'New addon {addon!r} from {upload!r}' + ) + + if ( + addon.status == amo.STATUS_NULL + and addon.has_complete_metadata() + and upload.channel == amo.RELEASE_CHANNEL_LISTED + ): + addon.update(status=amo.STATUS_NOMINATED) + + return addon + class AddonSerializerWithUnlistedData(AddonSerializer): - latest_unlisted_version = SimpleVersionSerializer() + latest_unlisted_version = SimpleVersionSerializer(read_only=True) class Meta: model = Addon fields = AddonSerializer.Meta.fields + ('latest_unlisted_version',) + read_only_fields = tuple( + set(fields) - set(AddonSerializer.Meta.writeable_fields) + ) class SimpleAddonSerializer(AddonSerializer): diff --git a/src/olympia/addons/tests/test_serializers.py b/src/olympia/addons/tests/test_serializers.py index 2da76099f6c3..173153de88f0 100644 --- a/src/olympia/addons/tests/test_serializers.py +++ b/src/olympia/addons/tests/test_serializers.py @@ -959,6 +959,13 @@ def test_latest_unlisted_version_with_right_serializer(self): self.addon.latest_unlisted_version, result['latest_unlisted_version'] ) + def test_readonly_fields(self): + serializer = self.serializer_class() + fields_read_only = { + name for name, field in serializer.get_fields().items() if field.read_only + } + assert fields_read_only == set(serializer.Meta.read_only_fields) + class TestESAddonSerializerOutput(AddonSerializerOutputTestMixin, ESTestCase): serializer_class = ESAddonSerializer diff --git a/src/olympia/addons/tests/test_views.py b/src/olympia/addons/tests/test_views.py index e5bef6914cda..370915ae3793 100644 --- a/src/olympia/addons/tests/test_views.py +++ b/src/olympia/addons/tests/test_views.py @@ -18,22 +18,10 @@ from waffle.testutils import override_switch from olympia import amo -from olympia.addons.models import ( - Addon, - AddonRegionalRestrictions, - AddonUser, - ReplacementAddon, -) -from olympia.addons.utils import generate_addon_guid -from olympia.addons.views import ( - DEFAULT_FIND_REPLACEMENT_PATH, - FIND_REPLACEMENT_SRC, - AddonAutoCompleteSearchView, - AddonSearchView, -) from olympia.amo.tests import ( APITestClient, ESTestCase, + JWTAPITestClient, TestCase, addon_factory, collection_factory, @@ -43,6 +31,7 @@ ) from olympia.amo.urlresolvers import get_outgoing_url from olympia.bandwagon.models import CollectionAddon +from olympia.blocklist.models import Block from olympia.constants.categories import CATEGORIES, CATEGORIES_BY_ID from olympia.constants.promoted import ( LINE, @@ -52,8 +41,24 @@ SPONSORED, VERIFIED, ) +from olympia.files.tests.test_models import UploadMixin from olympia.users.models import UserProfile -from olympia.versions.models import ApplicationsVersions, AppVersion +from olympia.versions.models import ApplicationsVersions, AppVersion, License + +from ..models import ( + Addon, + AddonRegionalRestrictions, + AddonUser, + ReplacementAddon, +) +from ..serializers import AddonSerializer, LicenseSerializer, VersionSerializer +from ..utils import generate_addon_guid +from ..views import ( + DEFAULT_FIND_REPLACEMENT_PATH, + FIND_REPLACEMENT_SRC, + AddonAutoCompleteSearchView, + AddonSearchView, +) class TestStatus(TestCase): @@ -686,6 +691,143 @@ def test_with_grouped_ratings(self): assert data == {'detail': 'show_grouped_ratings parameter should be a boolean'} +class TestAddonViewSetCreate(UploadMixin, TestCase): + client_class = APITestClient + + def setUp(self): + super().setUp() + self.user = user_factory(read_dev_agreement=self.days_ago(0)) + self.upload = self.get_upload( + 'webextension.xpi', user=self.user, source=amo.UPLOAD_SOURCE_ADDON_API + ) + self.url = reverse_ns('addon-list', api_version='v5') + self.client.login_api(self.user) + self.license = License.objects.create(builtin=1) + self.minimal_data = { + 'version': {'upload': self.upload.uuid, 'license': self.license.id}, + 'categories': {'firefox': ['bookmarks']}, + } + + def test_basic(self): + response = self.client.post( + self.url, + data=self.minimal_data, + ) + assert response.status_code == 201, response.content + data = response.data + assert data['name'] == {'en-US': 'My WebExtension Addon'} + assert data['status'] == 'nominated' + addon = Addon.objects.get() + request = APIRequestFactory().get('/') + request.version = 'v5' + request.user = self.user + assert data == AddonSerializer(context={'request': request}).to_representation( + addon + ) + + def test_not_authenticated(self): + self.client.logout_api() + response = self.client.post( + self.url, + data=self.minimal_data, + ) + assert response.status_code == 401 + assert response.data == { + 'detail': 'Authentication credentials were not provided.' + } + assert not Addon.objects.all() + + def test_not_read_agreement(self): + self.user.update(read_dev_agreement=None) + response = self.client.post( + self.url, + data=self.minimal_data, + ) + assert response.status_code in [401, 403] # JWT auth is a 401; web auth is 401 + assert 'agreement' in response.data['detail'].lower() + assert not Addon.objects.all() + + def test_waffle_flag_disabled(self): + gates = { + 'v5': ( + gate + for gate in settings.DRF_API_GATES['v5'] + if gate != 'addon-submission-api' + ) + } + with override_settings(DRF_API_GATES=gates): + response = self.client.post( + self.url, + data=self.minimal_data, + ) + assert response.status_code == 403 + assert response.data == { + 'detail': 'You do not have permission to perform this action.' + } + assert not Addon.objects.all() + + def test_missing_version(self): + response = self.client.post( + self.url, + data={'categories': {'firefox': ['bookmarks']}}, + ) + assert response.status_code == 400, response.content + assert response.data == {'version': ['This field is required.']} + assert not Addon.objects.all() + + def test_invalid_categories(self): + data = {**self.minimal_data, 'categories': {'firefox': ['performance']}} + response = self.client.post( + self.url, + data=data, + ) + assert response.status_code == 400, response.content + assert response.data == {'categories': ['Invalid app or category name.']} + assert not Addon.objects.all() + + def test_set_extra_data(self): + data = { + **self.minimal_data, + 'description': {'en-US': 'new description'}, + 'developer_comments': {'en-US': 'comments'}, + 'homepage': {'en-US': 'https://my.home.page/'}, + # 'name' # don't update - should retain name from the manifest + 'slug': 'addon-slug', + 'summary': {'en-US': 'new summary'}, + 'support_email': {'en-US': 'email@me'}, + 'support_url': {'en-US': 'https://my.home.page/support/'}, + } + response = self.client.post( + self.url, + data=data, + ) + addon = Addon.objects.get() + + assert response.status_code == 201, response.content + data = response.data + assert data['description'] == {'en-US': 'new description'} + assert addon.description == 'new description' + assert data['developer_comments'] == {'en-US': 'comments'} + assert addon.developer_comments == 'comments' + assert data['homepage']['url'] == {'en-US': 'https://my.home.page/'} + assert addon.homepage == 'https://my.home.page/' + assert data['name'] == {'en-US': 'My WebExtension Addon'} + assert addon.name == 'My WebExtension Addon' + assert data['slug'] == 'addon-slug' == addon.slug + assert data['summary'] == {'en-US': 'new summary'} + assert addon.summary == 'new summary' + assert data['support_email'] == {'en-US': 'email@me'} + assert addon.support_email == 'email@me' + assert data['support_url']['url'] == {'en-US': 'https://my.home.page/support/'} + assert addon.support_url == 'https://my.home.page/support/' + assert data['status'] == 'nominated' + assert addon.status == amo.STATUS_NOMINATED + + +class TestAddonViewSetCreateJWTAuth(TestAddonViewSetCreate): + client_class = JWTAPITestClient + + class TestVersionViewSetDetail(AddonAndVersionViewSetDetailMixin, TestCase): client_class = APITestClient @@ -844,6 +986,149 @@ def test_unlisted_version_user_but_not_author(self): assert response.status_code == 403 +class TestVersionViewSetCreate(UploadMixin, TestCase): + client_class = APITestClient + + def setUp(self): + super().setUp() + self.user = user_factory(read_dev_agreement=self.days_ago(0)) + self.upload = self.get_upload( + 'webextension.xpi', user=self.user, source=amo.UPLOAD_SOURCE_ADDON_API + ) + self.addon = addon_factory(users=(self.user,), guid='@webextension-guid') + self.url = reverse_ns( + 'addon-version-list', + kwargs={'addon_pk': self.addon.slug}, + api_version='v5', + ) + self.client.login_api(self.user) + self.license = License.objects.create(builtin=1) + self.minimal_data = {'upload': self.upload.uuid, 'license': self.license.id} + + def test_basic(self): + response = self.client.post( + self.url, + data=self.minimal_data, + ) + assert response.status_code == 201, response.content + data = response.data + assert data['license'] == LicenseSerializer().to_representation(self.license) + assert data['compatibility'] == { + 'firefox': {'max': '*', 'min': '42.0'}, + } + self.addon.reload() + assert self.addon.versions.count() == 2 + version = self.addon.find_latest_version(channel=None) + request = APIRequestFactory().get('/') + request.version = 'v5' + request.user = self.user + assert data == VersionSerializer( + context={'request': request} + ).to_representation(version) + + def test_not_authenticated(self): + self.client.logout_api() + response = self.client.post( + self.url, + data=self.minimal_data, + ) + assert response.status_code == 401 + assert response.data == { + 'detail': 'Authentication credentials were not provided.' + } + assert self.addon.reload().versions.count() == 1 + + def test_not_your_addon(self): + self.addon.addonuser_set.get(user=self.user).update( + role=amo.AUTHOR_ROLE_DELETED + ) + response = self.client.post( + self.url, + data=self.minimal_data, + ) + assert response.status_code == 403 + assert response.data['detail'] == ( + 'You do not have permission to perform this action.' + ) + assert self.addon.reload().versions.count() == 1 + + def test_not_read_agreement(self): + self.user.update(read_dev_agreement=None) + response = self.client.post( + self.url, + data=self.minimal_data, + ) + assert response.status_code in [401, 403] # JWT auth is a 401; web auth is 403 + assert 'agreement' in response.data['detail'].lower() + assert self.addon.reload().versions.count() == 1 + + def test_waffle_flag_disabled(self): + gates = { + 'v5': ( + gate + for gate in settings.DRF_API_GATES['v5'] + if gate != 'addon-submission-api' + ) + } + with override_settings(DRF_API_GATES=gates): + response = self.client.post( + self.url, + data=self.minimal_data, + ) + assert response.status_code == 403 + assert response.data == { + 'detail': 'You do not have permission to perform this action.' + } + assert self.addon.reload().versions.count() == 1 + + def test_missing_license(self): + response = self.client.post( + self.url, + data={'upload': self.upload.uuid}, + ) + assert response.status_code == 400, response.content + assert response.data == {'license': ['This field is required.']} + assert self.addon.reload().versions.count() == 1 + + def test_set_extra_data(self): + data = { + **self.minimal_data, + 'compatibility': ['firefox', 'android'], + 'release_notes': {'en-US': 'dsdsdsd'}, + } + response = self.client.post( + self.url, + data=data, + ) + + assert response.status_code == 201, response.content + data = response.data + self.addon.reload() + assert self.addon.versions.count() == 2 + version = self.addon.find_latest_version(channel=None) + assert data['compatibility'] == { + 'android': {'max': '*', 'min': '48.0'}, + 'firefox': {'max': '*', 'min': '42.0'}, + } + assert list(version.compatible_apps.keys()) == [amo.FIREFOX, amo.ANDROID] + assert data['release_notes'] == {'en-US': 'dsdsdsd'} + assert version.release_notes == 'dsdsdsd' + + def test_check_blocklist(self): + Block.objects.create(guid=self.addon.guid, updated_by=self.user) + response = self.client.post( + self.url, + data=self.minimal_data, + ) + assert response.status_code == 400 + assert 'Version 0.0.1 matches ' in str(response.data['non_field_errors']) + assert self.addon.reload().versions.count() == 1 + + +class TestVersionViewSetCreateJWTAuth(TestVersionViewSetCreate): + client_class = JWTAPITestClient + + class TestVersionViewSetList(AddonAndVersionViewSetDetailMixin, TestCase): client_class = APITestClient diff --git a/src/olympia/addons/views.py b/src/olympia/addons/views.py index 24672e6a4538..72da17b2ca7d 100644 --- a/src/olympia/addons/views.py +++ b/src/olympia/addons/views.py @@ -12,7 +12,8 @@ from rest_framework import exceptions, serializers from rest_framework.decorators import action from rest_framework.generics import ListAPIView -from rest_framework.mixins import ListModelMixin, RetrieveModelMixin +from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.settings import api_settings from rest_framework.views import APIView @@ -23,6 +24,7 @@ from olympia import amo from olympia.access import acl from olympia.amo.urlresolvers import get_outgoing_url +from olympia.api.authentication import JWTKeyAuthentication, WebTokenAuthentication from olympia.api.exceptions import UnavailableForLegalReasons from olympia.api.pagination import ESPageNumberPagination from olympia.api.permissions import ( @@ -32,11 +34,13 @@ AllowListedViewerOrReviewer, AllowUnlistedViewerOrReviewer, AnyOf, + APIGatePermission, GroupPermission, RegionalRestriction, ) from olympia.api.utils import is_gate_active from olympia.constants.categories import CATEGORIES_BY_ID +from olympia.devhub.permissions import IsSubmissionAllowedFor from olympia.search.filters import ( AddonAppQueryParam, AddonAppVersionQueryParam, @@ -174,7 +178,7 @@ def find_replacement_addon(request): return redirect(replace_url, permanent=False) -class AddonViewSet(RetrieveModelMixin, GenericViewSet): +class AddonViewSet(CreateModelMixin, RetrieveModelMixin, GenericViewSet): permission_classes = [ AnyOf( AllowReadOnlyIfPublic, @@ -183,6 +187,7 @@ class AddonViewSet(RetrieveModelMixin, GenericViewSet): AllowUnlistedViewerOrReviewer, ), ] + authentication_classes = [JWTKeyAuthentication, WebTokenAuthentication] georestriction_classes = [ RegionalRestriction | GroupPermission(amo.permissions.ADDONS_EDIT) ] @@ -216,7 +221,7 @@ def get_queryset(self): def get_serializer_class(self): # Override serializer to use serializer_class_with_unlisted_data if # we are allowed to access unlisted data. - obj = getattr(self, 'instance') + obj = getattr(self, 'instance', None) request = self.request if acl.check_unlisted_addons_viewer_or_reviewer(request) or ( obj @@ -240,7 +245,12 @@ def check_permissions(self, request): for restriction in self.get_georestrictions(): if not restriction.has_permission(request, self): raise UnavailableForLegalReasons() - + if self.action == 'create': + self.permission_classes = [ + APIGatePermission('addon-submission-api'), + IsAuthenticated, + IsSubmissionAllowedFor, + ] super().check_permissions(request) def check_object_permissions(self, request, obj): @@ -285,6 +295,13 @@ def eula_policy(self, request, pk=None): ) return Response(serializer.data) + def create(self, request, *args, **kwargs): + from olympia.signing.views import VersionView # circular import + + # TODO: consolidate/replicate this behaviour. + VersionView().check_throttles(request) + return super().create(request, *args, **kwargs) + class AddonChildMixin: """Mixin containing method to retrieve the parent add-on object.""" @@ -322,7 +339,11 @@ def get_addon_object( class AddonVersionViewSet( - AddonChildMixin, RetrieveModelMixin, ListModelMixin, GenericViewSet + AddonChildMixin, + CreateModelMixin, + RetrieveModelMixin, + ListModelMixin, + GenericViewSet, ): # Permissions are always checked against the parent add-on in # get_addon_object() using AddonViewSet.permission_classes so we don't need @@ -330,6 +351,7 @@ class AddonVersionViewSet( # below in check_permissions() and check_object_permissions() depending on # what the client is requesting to see. permission_classes = [] + authentication_classes = [JWTKeyAuthentication, WebTokenAuthentication] def get_serializer_class(self): if ( @@ -342,9 +364,14 @@ def get_serializer_class(self): serializer_class = VersionSerializer return serializer_class + def get_serializer(self, *args, **kwargs): + return super().get_serializer( + *args, **{**kwargs, 'addon': self.get_addon_object()} + ) + def check_permissions(self, request): - requested = self.request.GET.get('filter') if self.action == 'list': + requested = self.request.GET.get('filter') if requested == 'all_with_deleted': # To see deleted versions, you need Addons:ViewDeleted. self.permission_classes = [ @@ -372,6 +399,12 @@ def check_permissions(self, request): # super + check_object_permission() ourselves, passing down the # addon object directly. return super().check_object_permissions(request, self.get_addon_object()) + elif self.action == 'create': + self.permission_classes = [ + APIGatePermission('addon-submission-api'), + IsAuthenticated, + IsSubmissionAllowedFor, + ] super().check_permissions(request) def check_object_permissions(self, request, obj): @@ -452,6 +485,13 @@ def get_queryset(self): queryset = queryset.transform(Version.transformer_license) return queryset + def create(self, request, *args, **kwargs): + from olympia.signing.views import VersionView # circular import + + # TODO: consolidate/replicate this behaviour. + VersionView().check_throttles(request) + return super().create(request, *args, **kwargs) + class AddonSearchView(ListAPIView): authentication_classes = [] diff --git a/src/olympia/versions/models.py b/src/olympia/versions/models.py index 6da6031fd02b..3af500ab3bd4 100644 --- a/src/olympia/versions/models.py +++ b/src/olympia/versions/models.py @@ -256,8 +256,8 @@ def from_upload(cls, upload, addon, selected_apps, channel, parsed_data=None): 'FileUpload user does not have some required fields' ) - license_id = None - if channel == amo.RELEASE_CHANNEL_LISTED: + license_id = parsed_data.get('license_id') + if not license_id and channel == amo.RELEASE_CHANNEL_LISTED: previous_version = addon.find_latest_version(channel=channel, exclude=()) if previous_version and previous_version.license_id: license_id = previous_version.license_id @@ -272,6 +272,7 @@ def from_upload(cls, upload, addon, selected_apps, channel, parsed_data=None): version=parsed_data['version'], license_id=license_id, channel=channel, + release_notes=parsed_data.get('release_notes'), ) email = upload.user.email if upload.user and upload.user.email else '' with core.override_remote_addr(upload.ip_address):