From f03be4f1ea7fc965c512f63426be1349b6b650e5 Mon Sep 17 00:00:00 2001 From: eviljeff Date: Mon, 17 Aug 2026 18:40:49 +0100 Subject: [PATCH] Add an (author only) AddonViewSet list function --- docs/topics/api/addons.rst | 80 +++++++++++--- docs/topics/api/overview.rst | 1 + src/olympia/addons/filters.py | 38 +++++++ src/olympia/addons/tests/test_views.py | 143 +++++++++++++++++++++++++ src/olympia/addons/views.py | 41 ++++++- src/olympia/api/filters.py | 7 +- 6 files changed, 286 insertions(+), 24 deletions(-) create mode 100644 src/olympia/addons/filters.py diff --git a/docs/topics/api/addons.rst b/docs/topics/api/addons.rst index 6fa3bf64130e..5775ca384b37 100644 --- a/docs/topics/api/addons.rst +++ b/docs/topics/api/addons.rst @@ -121,6 +121,54 @@ for autocomplete though, there are a couple key differences: :>json array results: An array of :ref:`add-ons `. Only the ``id``, ``icon_url``, ``icons``, ``name``, ``promoted``, ``type`` and ``url`` fields are supported though. +---- +List +---- + +.. _addon-list: + +This endpoint allows you to list all add-ons you are the author of. It does not allow arbitrary listing of add-ons. + + .. note:: + This API requires :doc:`authentication `. + +.. http:get:: /api/v5/addons/addon/ + + :query string lang: Activate translations in the specific language for that query. (See :ref:`translated fields `) + :query int page: 1-based page number. Defaults to 1. + :query int page size: Maximum number of results to return for the requested page. Defaults to 25. + :query string sort: The sort parameter. The available parameters are documented in the :ref:`table below `. + :query string type: Filter by :ref:`add-on type `. Multiple types can be specified, separated by comma(s), in which case add-ons that are any of the matching types are returned. + :>json int count: The number of results for this query. + :>json string next: The URL of the next page of results. + :>json string previous: The URL of the previous page of results. + :>json array results: An array of :ref:`add-ons `. Unlike the :ref:`search ` endpoint, all fields are returned, including fields for the author of the add-on. + + +.. _addon-list-sort: + + Available sorting parameters: + + ======================== ==================================================== + Parameter Description + ======================== ==================================================== + average_daily_users Average number of daily users. + bayesian_rating Bayesian rating. + created Creation date. + hotness Hotness (average number of users progression). + id Numeric add-on id on AMO. + last_updated Last updated date. + ratings.bayesian_average Alias of bayesian_rating, to match detail structure. + weekly_downloads Number of weekly downloads. + ======================== ==================================================== + +All sort parameters default to ascending order but can be reversed, e.g. ``?sort=-created`` for descending dates. +The default behavior is to sort by ``id``. + +You can combine multiple parameters by separating them with a comma. +For example, to sort search results by downloads and then by creation date, use ``?sort=weekly_downloads,created``. + + ------ Detail ------ @@ -139,7 +187,7 @@ This endpoint allows you to fetch a specific add-on by id, slug or guid. properties: * ``detail``: string containing a message about the error. - * ``is_disabled_by_developer``: boolean set to ``true`` when the add-on has been voluntarily disabled by its developer. + * ``is_disabled_by_developer``: boolean set to ``true`` when the add-on has been voluntarily disabled by its author (developer). * ``is_disabled_by_mozilla``: boolean set to ``true`` when the add-on has been disabled by Mozilla. .. http:get:: /api/v5/addons/addon/(int:id|string:slug|string:guid)/ @@ -151,7 +199,7 @@ This endpoint allows you to fetch a specific add-on by id, slug or guid. :query string lang: Activate translations in the specific language for that query. (See :ref:`Translated Fields `) :query boolean show_grouped_ratings: Whether or not to show ratings aggregates in the ``ratings`` object (Use "true"/"1" as truthy values, "0"/"false" as falsy ones). :>json int id: The add-on id on AMO. - :>json array authors: Array holding information about the authors for the add-on. + :>json array authors: Array holding information about the authors (developers) for the add-on. :>json int authors[].id: The user id for an author. :>json string authors[].name: The name for an author. :>json string authors[].url: The link to the profile page for an author. @@ -165,8 +213,8 @@ This endpoint allows you to fetch a specific add-on by id, slug or guid. :>json object current_version: Object holding the current :ref:`version ` of the add-on. For performance reasons the ``license`` field omits the ``text`` property from both the search and detail endpoints. :>json string default_locale: The add-on default locale for translations. :>json object|null description: The add-on description (See :ref:`translated fields `). This field might contain markdown. - :>json object|null developer_comments: Additional information about the add-on provided by the developer. (See :ref:`translated fields `). - :>json string edit_url: The URL to the developer edit page for the add-on. + :>json object|null developer_comments: Additional information about the add-on provided by the author (developer). (See :ref:`translated fields `). + :>json string edit_url: The URL to the author (developer) edit page for the add-on. :>json string guid: The add-on `extension identifier `_. :>json boolean has_eula: The add-on has an End-User License Agreement that the user needs to agree with before installing (See :ref:`add-on EULA and privacy policy `). :>json boolean has_privacy_policy: The add-on has a Privacy Policy (See :ref:`add-on EULA and privacy policy `). @@ -174,11 +222,11 @@ This endpoint allows you to fetch a specific add-on by id, slug or guid. :>json string icon_url: The URL to icon for the add-on (including a cachebusting query string). :>json object icons: An object holding the URLs to an add-ons icon including a cachebusting query string as values and their size as properties. Currently exposes 32, 64, 128 pixels wide icons. :>json boolean is_disabled: Whether the add-on is disabled or not. - :>json boolean is_experimental: Whether the add-on has been marked by the developer as experimental or not. + :>json boolean is_experimental: Whether the add-on has been marked by the author (developer) as experimental or not. :>json boolean|null is_noindexed: Whether the add-on should be indexed or not indexed for SEO. Note that the search endpoint will always return a ``null`` value. :>json object|null name: The add-on name (See :ref:`translated fields `). - :>json string last_updated: The date of the last time the add-on was updated by its developer(s). - :>json object|null latest_unlisted_version: Object holding the latest unlisted :ref:`version ` of the add-on. This field is only present if the user has unlisted reviewer permissions, or is listed as a developer of the add-on. + :>json string last_updated: The date of the last time the add-on was updated by its author(s) (developers) + :>json object|null latest_unlisted_version: Object holding the latest unlisted :ref:`version ` of the add-on. This field is only present if the user has unlisted reviewer permissions, or is listed as a author (developer) of the add-on. :>json array previews: Array holding information about the previews for the add-on. :>json int previews[].id: The id for a preview. :>json object|null previews[].caption: The caption describing a preview (See :ref:`translated fields `). @@ -467,7 +515,7 @@ This endpoint allows you to list all versions belonging to a specific add-on. Non-public add-ons and add-ons with only unlisted versions require both: * authentication - * reviewer permissions or an account listed as a developer of the add-on + * reviewer permissions or an account listed as an author (developer) of the add-on :query string filter: The :ref:`filter ` to apply. :query string lang: Activate translations in the specific language for that query. (See :ref:`translated fields `) @@ -491,10 +539,10 @@ This endpoint allows you to list all versions belonging to a specific add-on. ==================== ===================================================== all_without_unlisted Show all listed versions attached to this add-on. Requires either reviewer permissions or a user - account listed as a developer of the add-on. + account listed as an author (developer) of the add-on. all_with_unlisted Show all versions (including unlisted) attached to this add-on. Requires either reviewer permissions or - a user account listed as a developer of the add-on. + a user account listed as an author (developer) of the add-on. all_with_deleted Show all versions attached to this add-on, including deleted ones. Requires admin permissions. ==================== ===================================================== @@ -516,7 +564,7 @@ This endpoint allows you to fetch a single version belonging to a specific add-o :query string lang: Activate translations in the specific language for that query. (See :ref:`translated fields `) :>json int id: The version id. - :>json string approval_notes: Information for Mozilla reviewers, for when the add-on is reviewed. These notes are only visible to Mozilla, and this field is only present if the user has reviewer permissions, or is listed as a developer of the add-on. + :>json string approval_notes: Information for Mozilla reviewers, for when the add-on is reviewed. These notes are only visible to Mozilla, and this field is only present if the user has reviewer permissions, or is listed as an author (developer) of the add-on. :>json string channel: The version channel, which determines its visibility on the site. Can be either ``unlisted`` or ``listed``. :>json object compatibility: Object detailing which :ref:`applications ` the version is compatible with. @@ -540,7 +588,7 @@ This endpoint allows you to fetch a single version belonging to a specific add-o :>json string compatibility[app_name].max: Maximum version of the corresponding app the version is compatible with. Should only be enforced by clients if ``is_strict_compatibility_enabled`` is ``true``. :>json string compatibility[app_name].min: Minimum version of the corresponding app the version is compatible with. - :>json string edit_url: The URL to the developer edit page for the version. + :>json string edit_url: The URL to the author (developer) edit page for the version. :>json int file.id: The id for the file. :>json string file.created: The creation date for the file. :>json string file.hash: The hash for the file. @@ -553,9 +601,9 @@ This endpoint allows you to fetch a single version belonging to a specific add-o :>json int file.size: The size for the file, in bytes. :>json int file.status: The :ref:`status ` for the file. :>json string file.url: The (absolute) URL to download the file. - :>json boolean is_disabled: If this version has been disabled by the developer. This field is only present for authenticated users, for their own add-ons. + :>json boolean is_disabled: If this version has been disabled by the author (developer). This field is only present for authenticated users, for their own add-ons. :>json object license: Object holding information about the license for the version. - :>json boolean license.is_custom: Whether the license text has been provided by the developer, or not. (When ``false`` the license is one of the common, predefined, licenses). + :>json boolean license.is_custom: Whether the license text has been provided by the author (developer), or not. (When ``false`` the license is one of the common, predefined, licenses). :>json object|null license.name: The name of the license (See :ref:`translated fields `). :>json object|null license.text: The text of the license (See :ref:`translated fields `). For performance reasons this field is only present in version detail detail endpoint: all other endpoints omit it. :>json string|null license.url: The URL of the full text of license. @@ -727,7 +775,7 @@ This endpoint allows the metadata for an existing version to be edited. :` and versions the version is compatible with; or an array of :ref:`applications `, where default min/max versions will be used if not already defined. See :ref:`examples `. :`. The license must match the add-on type. Either provide ``license`` or ``custom_license``, not both. :`). Custom licenses are not supported for themes. :`). Custom licenses are not supported for themes. @@ -924,7 +972,7 @@ This endpoint allows you to fetch an add-on EULA and privacy policy. Non-public add-ons and add-ons with only unlisted versions require both: * authentication - * reviewer permissions or an account listed as a developer of the add-on + * reviewer permissions or an account listed as an author (developer) of the add-on :>json object|null eula: The text of the EULA, if present (See :ref:`translated fields `). :>json object|null privacy_policy: The text of the Privacy Policy, if present (See :ref:`translated fields `). diff --git a/docs/topics/api/overview.rst b/docs/topics/api/overview.rst index 481172f56353..17a54cfcddd4 100644 --- a/docs/topics/api/overview.rst +++ b/docs/topics/api/overview.rst @@ -486,6 +486,7 @@ These are `v5` specific changes - `v4` changes apply also. * 2026-03-05: removed /scanner/results/ (internal API endpoint). https://github.com/mozilla/addons/issues/16088 * 2026-04-02: added /scanner/results/ endpoint to allow scanners to push results. https://github.com/mozilla/addons/issues/16115 * 2026-08-20: added 'submit_notification_warning' to site status endpoint. https://github.com/mozilla/addons-server/pull/25302 +* 2026-09-03: added list view under addons/addon, to list the users own add-ons. https://github.com/mozilla/addons/issues/16378 .. _`#11380`: https://github.com/mozilla/addons-server/issues/11380/ .. _`#11379`: https://github.com/mozilla/addons-server/issues/11379/ diff --git a/src/olympia/addons/filters.py b/src/olympia/addons/filters.py new file mode 100644 index 000000000000..72ff209a5365 --- /dev/null +++ b/src/olympia/addons/filters.py @@ -0,0 +1,38 @@ +from django.utils.translation import gettext + +from rest_framework import serializers +from rest_framework.filters import BaseFilterBackend + +from olympia.amo import ADDON_TYPE_CHOICES_API + + +class ModelFieldFilter(BaseFilterBackend): + # These properties should be defined by subclasses + reverse_dict = {} + query_param = None + model_field = None + + def filter_queryset(self, request, queryset, view): + def parse_value(value): + if value not in self.reverse_dict: + raise serializers.ValidationError( + gettext('Invalid "%s" parameter.' % self.query_param) + ) + return self.reverse_dict[value] + + if not (self.reverse_dict and self.query_param and self.model_field): + raise NotImplementedError( + 'Subclasses of ModelFieldFilter must define reverse_dict, ' + 'query_param and model_field.' + ) + + if value_string := request.GET.get(self.query_param): + valid_values = [parse_value(value) for value in value_string.split(',')] + return queryset.filter(**{f'{self.model_field}__in': valid_values}) + return queryset + + +class AddonTypeFilter(ModelFieldFilter): + reverse_dict = {v: k for k, v in ADDON_TYPE_CHOICES_API.items()} + query_param = 'type' + model_field = 'type' diff --git a/src/olympia/addons/tests/test_views.py b/src/olympia/addons/tests/test_views.py index e2d65712a2d4..3c6288b0f1ab 100644 --- a/src/olympia/addons/tests/test_views.py +++ b/src/olympia/addons/tests/test_views.py @@ -855,6 +855,149 @@ def test_summary_not_allowed(self): } +class TestAddonViewSetList(TestCase): + client_class = APITestClientSessionID + + def setUp(self): + super().setUp() + self.user = user_factory() + self.url = reverse_ns('addon-list', api_version='v5') + + def _get_results(self): + response = self.client.get(self.url) + assert response.status_code == 200, response.content + return response.json() + + def test_anonymous_sees_nothing(self): + # There are add-ons in the database, but an anonymous user shouldn't + # see any of them through the list endpoint. + addon_factory(users=[self.user]) + addon_factory() + + data = self._get_results() + assert data['count'] == 0 + assert data['results'] == [] + + def test_authenticated_only_sees_own_addons(self): + other_user = user_factory() + my_addon = addon_factory(users=[self.user]) + # An add-on the user co-authors should also be returned. + shared_addon = addon_factory(users=[self.user, other_user]) + # Owned by someone else - should not be returned. + addon_factory(users=[other_user]) + # Nobody owns this one - should not be returned. + addon_factory() + # This add-on _used_ to be owned, but the user's ownership has been deleted + ex_addon = addon_factory(users=[self.user]) + AddonUser.objects.filter(addon=ex_addon, user=self.user).delete() + + self.client.login_api(self.user) + data = self._get_results() + assert data['count'] == 2 + assert [result['id'] for result in data['results']] == [ + my_addon.pk, + shared_addon.pk, + ] + + def test_uses_developer_serializer(self): + addon = addon_factory(users=[self.user]) + unlisted_version = version_factory(addon=addon, channel=amo.CHANNEL_UNLISTED) + self.client.login_api(self.user) + + data = self._get_results() + assert data['count'] == 1 + result = data['results'][0] + # 'latest_unlisted_version' is only serialized by DeveloperAddonSerializer + assert result['latest_unlisted_version'] + assert result['latest_unlisted_version']['id'] == unlisted_version.pk + + def test_all_statuses_returned_except_deleted(self): + approved = addon_factory(users=[self.user], status=amo.STATUS_APPROVED) + nominated = addon_factory(users=[self.user], status=amo.STATUS_NOMINATED) + incomplete = addon_factory(users=[self.user], status=amo.STATUS_NULL) + disabled = addon_factory(users=[self.user], status=amo.STATUS_DISABLED) + # Deleted add-ons must never be returned, even to their author. + deleted = addon_factory(users=[self.user]) + deleted.delete() + assert deleted.reload().status == amo.STATUS_DELETED + + self.client.login_api(self.user) + data = self._get_results() + assert data['count'] == 4 + assert {result['id'] for result in data['results']} == { + approved.pk, + nominated.pk, + incomplete.pk, + disabled.pk, + } + + def test_sort_by_created(self): + older = addon_factory(users=[self.user], created=self.days_ago(10)) + newer = addon_factory(users=[self.user], created=self.days_ago(1)) + self.client.login_api(self.user) + + url = self.url + self.url = url + '?sort=created' + data = self._get_results() + assert [result['id'] for result in data['results']] == [older.pk, newer.pk] + + self.url = url + '?sort=-created' + data = self._get_results() + assert [result['id'] for result in data['results']] == [newer.pk, older.pk] + + def test_sort_by_ratings_alias(self): + # 'ratings.bayesian_rating' is an alias for 'bayesian_rating'. + popular = addon_factory(users=[self.user], bayesian_rating=4.9) + unpopular = addon_factory(users=[self.user], bayesian_rating=2.1) + self.client.login_api(self.user) + + url = self.url + self.url = url + '?sort=ratings.bayesian_average' + data = self._get_results() + assert [result['id'] for result in data['results']] == [ + unpopular.pk, + popular.pk, + ] + + self.url = url + '?sort=-ratings.bayesian_average' + data = self._get_results() + assert [result['id'] for result in data['results']] == [ + popular.pk, + unpopular.pk, + ] + + def test_default_sort_is_oldest_id_first(self): + first = addon_factory(users=[self.user]) + second = addon_factory(users=[self.user]) + self.client.login_api(self.user) + + data = self._get_results() + assert [result['id'] for result in data['results']] == [first.pk, second.pk] + + def test_filter_by_type(self): + extension = addon_factory(users=[self.user], type=amo.ADDON_EXTENSION) + theme = addon_factory(users=[self.user], type=amo.ADDON_STATICTHEME) + self.client.login_api(self.user) + + self.url = reverse_ns('addon-list', api_version='v5') + '?type=extension' + data = self._get_results() + assert [result['id'] for result in data['results']] == [extension.pk] + + self.url = reverse_ns('addon-list', api_version='v5') + '?type=statictheme' + data = self._get_results() + assert [result['id'] for result in data['results']] == [theme.pk] + + def test_filter_by_invalid_type(self): + addon_factory(users=[self.user], type=amo.ADDON_EXTENSION) + self.client.login_api(self.user) + + self.url = self.url + '?type=notatype' + response = self.client.get(self.url) + # An invalid ?type value should be rejected with a 400, not a 500. + assert response.status_code == 400, response.content + assert response.json() == ['Invalid "type" parameter.'] + + class TestAddonViewSetCreate(UploadMixin, AddonViewSetCreateUpdateMixin, TestCase): client_class = APITestClientSessionID client_request_verb = 'post' diff --git a/src/olympia/addons/views.py b/src/olympia/addons/views.py index 627d6aa8ed29..c8765f298bd3 100644 --- a/src/olympia/addons/views.py +++ b/src/olympia/addons/views.py @@ -1,7 +1,7 @@ from collections import OrderedDict from django import http -from django.db.models import F, Max, Prefetch +from django.db.models import F, Max, Prefetch, Q as djangoQ from django.db.transaction import non_atomic_requests from django.shortcuts import redirect from django.utils.cache import patch_cache_control @@ -10,7 +10,7 @@ import waffle from drf_spectacular.utils import extend_schema, extend_schema_view -from elasticsearch.dsl import Q, Search, query +from elasticsearch.dsl import Q as dslQ, Search, query from rest_framework import exceptions, serializers, status from rest_framework.decorators import action from rest_framework.generics import ListAPIView @@ -34,6 +34,7 @@ from olympia.amo.utils import StopWatch from olympia.api.authentication import JWTKeyAuthentication, SessionIDAuthentication from olympia.api.exceptions import UnavailableForLegalReasons +from olympia.api.filters import OrderingAliasFilter from olympia.api.pagination import ESPageNumberPagination, LargePageNumberPagination from olympia.api.permissions import ( AllowAddonAuthor, @@ -75,6 +76,7 @@ from olympia.versions.tasks import duplicate_addon_version_for_rollback from .decorators import addon_view_factory, require_submissions_enabled +from .filters import AddonTypeFilter from .indexers import AddonIndexer from .models import ( Addon, @@ -228,6 +230,7 @@ def find_replacement_addon(request): ) ) class AddonViewSet( + ListModelMixin, CreateModelMixin, RetrieveModelMixin, UpdateModelMixin, @@ -270,6 +273,20 @@ class AddonViewSet( serializer_class_for_developers = DeveloperAddonSerializer lookup_value_regex = r'[^/]+' # Allow '.' for email-like guids. throttle_classes = addon_submission_throttles + filter_backends = [OrderingAliasFilter, AddonTypeFilter] + ordering_fields = ( + 'average_daily_users', + 'bayesian_rating', + 'created', + 'hotness', + 'id', + 'last_updated', + 'weekly_downloads', + ) + ordering_field_aliases = { + 'ratings.bayesian_average': 'bayesian_rating', + } + ordering = ('id',) def get_queryset(self): """Return queryset to be used for the view.""" @@ -300,7 +317,7 @@ def get_serializer_class(self): obj = getattr(self, 'instance', None) request = self.request if request.user.is_authenticated and ( - self.action in ('create', 'update', 'partial_update') + self.action in ('list', 'create', 'update', 'partial_update') or GroupPermission( amo.permissions.ADDONS_API_VIEW_UNLISTED ).has_object_permission(request, self, obj) @@ -437,6 +454,20 @@ def create(self, request, *args, **kwargs): def listingcontentreview(self, request, pk=None): return self.retrieve(request) + def filter_queryset(self, queryset): + # List is limited to a users own add-ons + if self.action == 'list': + # Anonymous users get no results - we could return an error here instead. + if self.request.user.is_anonymous: + return queryset.none() + else: + # filter to only the user's addons; but filter our deleted role. + queryset = queryset.filter( + djangoQ(addonuser__user=self.request.user.id) + & ~djangoQ(addonuser__role=amo.AUTHOR_ROLE_DELETED) + ) + return super().filter_queryset(queryset) + @listingcontentreview.mapping.patch def update_listingcontentreview(self, request, pk=None): addon = self.get_object() @@ -1060,7 +1091,7 @@ def get(self, request, *args, **kwargs): def filter_queryset(self, qs): qs = super().filter_queryset(qs) - qs = qs.query(query.Bool(filter=[Q('term', is_recommended=True)])) + qs = qs.query(query.Bool(filter=[dslQ('term', is_recommended=True)])) return qs.query('function_score', functions=[query.SF('random_score')]).sort( '_score' ) @@ -1298,7 +1329,7 @@ def filter_queryset(self, qs): if not guid_param or not amo.ADDON_GUID_PATTERN.match(guid_param): raise exceptions.ParseError('Invalid guid parameter') guids = get_addon_recommendations(guid_param) - recommended_qs = qs.query(query.Bool(must=[Q('terms', guid=guids)])) + recommended_qs = qs.query(query.Bool(must=[dslQ('terms', guid=guids)])) return recommended_qs.execute() def paginate_queryset(self, queryset): diff --git a/src/olympia/api/filters.py b/src/olympia/api/filters.py index be957eae7e58..ab50f08c466e 100644 --- a/src/olympia/api/filters.py +++ b/src/olympia/api/filters.py @@ -9,15 +9,16 @@ class OrderingAliasFilter(OrderingFilter): your view.""" def remove_invalid_fields(self, queryset, fields, view, request): + def neg(string): + return string[1:] if string.startswith('-') else f'-{string}' + aliases = getattr(view, 'ordering_field_aliases', {}) # Add to view.ordering_fields view.ordering_fields = getattr(view, 'ordering_fields', ()) + tuple( aliases.values() ) # Account for desc and asc sorting - aliases.update( - {'-%s' % alias: '-%s' % field for (alias, field) in aliases.items()} - ) + aliases.update({neg(alias): neg(field) for (alias, field) in aliases.items()}) # Replace field aliases with their actual field names. fields = [aliases.get(field, field) for field in fields] out = super().remove_invalid_fields(queryset, fields, view, request)