From a29722eb0a9141f9b426b7c2cb387000f8f7e70e Mon Sep 17 00:00:00 2001 From: Kevin Meinhardt Date: Tue, 16 Jul 2024 19:12:11 +0200 Subject: [PATCH] TMP: more --- src/olympia/abuse/models.py | 60 +++++++--- src/olympia/abuse/serializers.py | 14 ++- src/olympia/abuse/views.py | 3 +- src/olympia/addons/views.py | 182 ++++++++----------------------- src/olympia/api/fields.py | 2 + src/olympia/api/urls.py | 9 +- src/olympia/api/utils.py | 24 +++- src/olympia/lib/settings_base.py | 8 +- src/olympia/ratings/views.py | 2 + 9 files changed, 138 insertions(+), 166 deletions(-) diff --git a/src/olympia/abuse/models.py b/src/olympia/abuse/models.py index 36d9c5a98537..dfe03dd050fa 100644 --- a/src/olympia/abuse/models.py +++ b/src/olympia/abuse/models.py @@ -491,11 +491,15 @@ class AbuseReport(ModelBase): ('OTHER', 127, 'Other'), ) REPORT_ENTRY_POINTS = APIChoicesWithNone( - ('UNINSTALL', 1, 'Uninstall'), - ('MENU', 2, 'Menu'), - ('TOOLBAR_CONTEXT_MENU', 3, 'Toolbar context menu'), - ('AMO', 4, 'AMO'), - ('UNIFIED_CONTEXT_MENU', 5, 'Unified extensions context menu'), + ('UNINSTALL', 1, 'Report button shown at uninstall time'), + ('MENU', 2, 'Report menu in Add-ons Manager'), + ('TOOLBAR_CONTEXT_MENU', 3, 'Report context menu on add-on toolbar'), + ( + 'AMO', + 4, + 'Report button on an AMO page (using `navigator.mozAddonManager.reportAbuse`)', + ), + ('UNIFIED_CONTEXT_MENU', 5, 'Report unified extensions (context) menu'), ) LOCATION = APIChoicesWithNone( ('AMO', 1, 'Add-on page on AMO'), @@ -571,10 +575,18 @@ class AbuseReport(ModelBase): help_text='The add-on summary in the locale used by the client.', ) addon_version = models.CharField( - default=None, max_length=255, blank=True, null=True, help_text='The add-on version string.' + default=None, + max_length=255, + blank=True, + null=True, + help_text='The add-on version string.', ) addon_signature = models.PositiveSmallIntegerField( - default=None, choices=ADDON_SIGNATURES.choices, blank=True, null=True, help_text=' The add-on signature state.' + default=None, + choices=ADDON_SIGNATURES.choices, + blank=True, + null=True, + help_text=' The add-on signature state.', ) application = models.PositiveSmallIntegerField( default=amo.FIREFOX.id, choices=amo.APPS_CHOICES, blank=True, null=True @@ -586,14 +598,28 @@ class AbuseReport(ModelBase): default=None, max_length=255, blank=True, null=True ) operating_system = models.CharField( - default=None, max_length=255, blank=True, null=True, help_text="The client's operating system." + default=None, + max_length=255, + blank=True, + null=True, + help_text="The client's operating system.", ) operating_system_version = models.CharField( - default=None, max_length=255, blank=True, null=True, help_text="The client's operating system version." + default=None, + max_length=255, + blank=True, + null=True, + help_text="The client's operating system version.", + ) + install_date = models.DateTimeField( + default=None, blank=True, null=True, help_text='The add-on install date.' ) - install_date = models.DateTimeField(default=None, blank=True, null=True, help_text='The add-on install date.') reason = models.PositiveSmallIntegerField( - default=None, choices=REASONS.choices, blank=True, null=True, help_text='The reason for the report.' + default=None, + choices=REASONS.choices, + blank=True, + null=True, + help_text='The reason for the report.', ) addon_install_origin = models.CharField( # Supposed to be an URL, but the scheme could be moz-foo: or something @@ -604,7 +630,7 @@ class AbuseReport(ModelBase): max_length=255, blank=True, null=True, - help_text='The add-on install origin.' + help_text='The add-on install origin.', ) addon_install_method = models.PositiveSmallIntegerField( default=None, @@ -634,10 +660,14 @@ class AbuseReport(ModelBase): max_length=255, blank=True, null=True, - help_text='The add-on install source URL.' + help_text='The add-on install source URL.', ) report_entry_point = models.PositiveSmallIntegerField( - default=None, choices=REPORT_ENTRY_POINTS.choices, blank=True, null=True + default=None, + choices=REPORT_ENTRY_POINTS.choices, + blank=True, + null=True, + help_text='Where and in what context was the report sent from.', ) location = models.PositiveSmallIntegerField( default=None, @@ -661,7 +691,7 @@ class AbuseReport(ModelBase): choices=ILLEGAL_CATEGORIES.choices, blank=True, null=True, - help_text='The type of illegal content - only required when the reason is set to illegal.' + help_text='The type of illegal content - only required when the reason is set to illegal.', ) illegal_subcategory = models.PositiveSmallIntegerField( default=None, diff --git a/src/olympia/abuse/serializers.py b/src/olympia/abuse/serializers.py index c2ba940f0ac0..dec09fab571c 100644 --- a/src/olympia/abuse/serializers.py +++ b/src/olympia/abuse/serializers.py @@ -171,35 +171,37 @@ class AddonAbuseReportSerializer(BaseAbuseReportSerializer): choices=list((v.id, k) for k, v in amo.APPS.items()), required=False, source='application', - help_text='The application used by the client.' + help_text='The application used by the client.', ) appversion = serializers.CharField( - required=False, source='application_version', max_length=255, help_text='The application version used by the client.' + required=False, + source='application_version', + max_length=255, + help_text='The application version used by the client.', ) report_entry_point = ReverseChoiceField( choices=list(AbuseReport.REPORT_ENTRY_POINTS.api_choices), required=False, allow_null=True, - help_text='The report entry point.' ) addon_install_method = ReverseChoiceField( choices=list(AbuseReport.ADDON_INSTALL_METHODS.api_choices), required=False, allow_null=True, - help_text='The add-on install method.' + help_text='The add-on install method.', ) addon_install_source = ReverseChoiceField( choices=list(AbuseReport.ADDON_INSTALL_SOURCES.api_choices), required=False, allow_null=True, - help_text='The add-on install source.' + help_text='The add-on install source.', ) addon_signature = ReverseChoiceField( choices=list(AbuseReport.ADDON_SIGNATURES.api_choices), required=False, allow_null=True, - help_text='The add-on signature state.' + help_text='The add-on signature state.', ) reason = ReverseChoiceField( # For add-ons we use the full list of reasons as choices. diff --git a/src/olympia/abuse/views.py b/src/olympia/abuse/views.py index f61d892aa13f..5e3e646d845c 100644 --- a/src/olympia/abuse/views.py +++ b/src/olympia/abuse/views.py @@ -10,7 +10,7 @@ from django.template.response import TemplateResponse from django.utils.encoding import force_bytes -from drf_spectacular.utils import extend_schema_view, extend_schema +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import status from rest_framework.decorators import ( api_view, @@ -87,6 +87,7 @@ def get_target_object(self): self.target_object = self.get_target_viewset().get_object() return self.target_object + @extend_schema_view( create=extend_schema( description=( diff --git a/src/olympia/addons/views.py b/src/olympia/addons/views.py index 34960f2c06ce..28a86cd61b69 100644 --- a/src/olympia/addons/views.py +++ b/src/olympia/addons/views.py @@ -7,7 +7,9 @@ from django.utils.cache import patch_cache_control from django.utils.translation import gettext -from drf_spectacular.utils import extend_schema_view, extend_schema +import django_filters +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema, extend_schema_view from elasticsearch_dsl import Q, Search, query from rest_framework import exceptions, serializers, status from rest_framework.decorators import action @@ -1020,159 +1022,66 @@ def finalize_response(self, request, response, *args, **kwargs): patch_cache_control(response, max_age=60 * 60 * 6) return response +class AddonFilter(django_filters.FilterSet): + application = django_filters.NumberFilter(method='filter_application') + types = django_filters.CharFilter(method='filter_types') + appversions = django_filters.CharFilter(method='filter_appversions') + authors = django_filters.CharFilter(method='filter_authors') + + class Meta: + model = Addon + fields = [] + + def filter_application(self, queryset, name, value): + try: + application = AddonAppQueryParam(value).get_value() + return queryset.filter(versions__apps__application=application) + except ValueError: + raise exceptions.ParseError('Invalid application parameter') + + def filter_types(self, queryset, name, value): + try: + addon_types = tuple(AddonTypeQueryParam(value).get_values()) + return queryset.filter(type__in=addon_types) + except ValueError: + raise exceptions.ParseError('Invalid type parameter') + + def filter_appversions(self, queryset, name, value): + try: + appversions = AddonAppVersionQueryParam(value).get_values() + appversions_dict = {'min': appversions[1], 'max': appversions[2]} + return queryset.filter( + versions__apps__min__version_int__lte=appversions_dict['min'], + versions__apps__max__version_int__gte=appversions_dict['max'], + ) + except ValueError: + raise exceptions.ParseError('Invalid appversion parameter') + + def filter_authors(self, queryset, name, value): + authors = AddonAuthorQueryParam(value).get_values() + return queryset.filter(addonuser__user__username__in=authors, addonuser__listed=True).distinct() + class LanguageToolsView(ListAPIView): authentication_classes = [] pagination_class = None permission_classes = [] serializer_class = LanguageToolsSerializer + filter_backends = [DjangoFilterBackend] + filterset_class = AddonFilter @classmethod def as_view(cls, **initkwargs): """The API is read-only so we can turn off atomic requests.""" return non_atomic_requests(super().as_view(**initkwargs)) - def get_query_params(self): - """ - Parse query parameters that this API supports: - - app (ignored unless appversion, then mandatory) - - type (optional) - - appversion (optional, makes type mandatory) - - author (optional) - - Can raise ParseError() in case a mandatory parameter is missing or a - parameter is invalid. - - Returns a dict containing application (int), types (tuple or None), - appversions (dict or None) and author (string or None). - """ - # appversion parameter is optional. - if AddonAppVersionQueryParam.query_param in self.request.GET: - # app parameter is mandatory with appversion - try: - application = AddonAppQueryParam(self.request.GET).get_value() - except ValueError: - raise exceptions.ParseError( - 'Invalid or missing app parameter while appversion parameter is ' - 'set.' - ) - try: - value = AddonAppVersionQueryParam(self.request.GET).get_values() - appversions = {'min': value[1], 'max': value[2]} - except ValueError: - raise exceptions.ParseError('Invalid appversion parameter.') - else: - appversions = None - application = None - - # type is optional, unless appversion is set. That's because the way - # dicts and language packs have their compatibility info set in the - # database differs, so to make things simpler for us we force clients - # to filter by type if they want appversion filtering. - if AddonTypeQueryParam.query_param in self.request.GET or appversions: - try: - addon_types = tuple(AddonTypeQueryParam(self.request.GET).get_values()) - except ValueError: - raise exceptions.ParseError( - 'Invalid or missing type parameter while appversion ' - 'parameter is set.' - ) - else: - addon_types = (amo.ADDON_LPAPP, amo.ADDON_DICT) - - # author is optional. It's a string representing the username(s) we're - # filtering on. - if AddonAuthorQueryParam.query_param in self.request.GET: - authors = AddonAuthorQueryParam(self.request.GET).get_values() - else: - authors = None - - return { - 'application': application, - 'types': addon_types, - 'appversions': appversions, - 'authors': authors, - } - def get_queryset(self): """ Return queryset to use for this view, depending on query parameters. """ - # application, addon_types, appversions - params = self.get_query_params() - if params['types'] == (amo.ADDON_LPAPP,) and params['appversions']: - qs = self.get_language_packs_queryset_with_appversions( - params['application'], params['appversions'] - ) - else: - qs = self.get_queryset_base(params['types']) - - if params['authors']: - qs = qs.filter( - addonuser__user__username__in=params['authors'], addonuser__listed=True - ).distinct() + qs = Addon.objects.public().exclude(target_locale='').only_translations().order_by() return qs - def get_queryset_base(self, addon_types): - """ - Return base queryset to be used as the starting point in both - get_queryset() and get_language_packs_queryset_with_appversions(). - """ - return ( - Addon.objects.public() - .filter( - type__in=addon_types, - target_locale__isnull=False, - ) - .exclude(target_locale='') - # Deactivate default transforms which fetch a ton of stuff we - # don't need here like authors, previews or current version. - # It would be nice to avoid translations entirely, because the - # translations transformer is going to fetch a lot of translations - # we don't need, but some language packs or dictionaries have - # custom names, so we can't use a generic one for them... - .only_translations() - # FIXME: we want to prefetch file.webext_permission instances in here - # Since we're fetching everything with no pagination, might as well - # not order it. - .order_by() - ) - - def get_language_packs_queryset_with_appversions(self, application, appversions): - """ - Return queryset to use specifically when requesting language packs - compatible with a given app + versions. - - application is an application id, and appversions is a dict with min - and max keys pointing to application versions expressed as ints. - """ - # Base queryset. - qs = self.get_queryset_base((amo.ADDON_LPAPP,)) - # Version queryset we'll prefetch once for all results. We need to - # find the ones compatible with the app+appversion requested, and we - # can avoid loading translations by removing transforms and then - # re-applying the default one that takes care of the compat info. - versions_qs = ( - Version.objects.latest_public_compatible_with(application, appversions) - .no_transforms() - .transform(Version.transformer) - ) - return ( - qs.prefetch_related( - Prefetch( - 'versions', to_attr='compatible_versions', queryset=versions_qs - ) - ) - .filter( - versions__apps__application=application, - versions__apps__min__version_int__lte=appversions['min'], - versions__apps__max__version_int__gte=appversions['max'], - versions__channel=amo.CHANNEL_LISTED, - versions__file__status=amo.STATUS_APPROVED, - ) - .distinct() - ) - def list(self, request, *args, **kwargs): # Ignore pagination (return everything) but do wrap the data in a # 'results' property to mimic what the default implementation of list() @@ -1184,7 +1093,6 @@ def list(self, request, *args, **kwargs): patch_cache_control(response, max_age=60 * 60 * 24) return response - class ReplacementAddonView(ListAPIView): authentication_classes = [] queryset = ReplacementAddon.objects.all() diff --git a/src/olympia/api/fields.py b/src/olympia/api/fields.py index 532135fe3ae9..99dc6323694a 100644 --- a/src/olympia/api/fields.py +++ b/src/olympia/api/fields.py @@ -7,6 +7,7 @@ from django.utils.translation import get_language, gettext, gettext_lazy as _, override from rest_framework import exceptions, fields, serializers +from drf_spectacular.utils import extend_schema_field from olympia.amo.templatetags.jinja_helpers import absolutify from olympia.amo.urlresolvers import get_outgoing_url @@ -20,6 +21,7 @@ LANGUAGE_CODE_REGEX = r'[\w-]+' +# @extend_schema_field({'type': 'array', 'items': {'type': 'integer'}}) class ReverseChoiceField(fields.ChoiceField): """ A ChoiceField that exposes the "human-readable" values of its choices, diff --git a/src/olympia/api/urls.py b/src/olympia/api/urls.py index 1049a16b2350..9be56fa81287 100644 --- a/src/olympia/api/urls.py +++ b/src/olympia/api/urls.py @@ -1,13 +1,18 @@ from django.conf import settings from django.urls import include, re_path -from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerSplitView +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerSplitView, +) from olympia.accounts.urls import accounts_v3, accounts_v4, auth_urls from olympia.addons.api_urls import addons_v3, addons_v4, addons_v5 from olympia.amo.urls import api_patterns as amo_api_patterns from olympia.ratings.api_urls import ratings_v3, ratings_v4 + def get_versioned_api_routes(version, url_patterns): route_pattern = r'^{}/'.format(version) @@ -24,7 +29,7 @@ def get_versioned_api_routes(version, url_patterns): ), re_path( r'^swagger/$', - SpectacularSwaggerSplitView.as_view(url_name='schema'), + SpectacularSwaggerSplitView.as_view(url_name='schema'), name='schema-swagger-ui', ), re_path( diff --git a/src/olympia/api/utils.py b/src/olympia/api/utils.py index 3e5c0f4217f0..a8e445be94d9 100644 --- a/src/olympia/api/utils.py +++ b/src/olympia/api/utils.py @@ -66,10 +66,28 @@ class APIChoices(Choices): ChoiceEntryClass = APIChoiceEntry convertor = ChoiceEntryClass.convertor + # Define Help text for the field that includes a `description` property if provided. + # This is useful for API documentation and can be picked up automatically by swagger. + def __init__(self, *choices, **kwargs): + super().__init__(*choices, **kwargs) + + self.help_text = ''.join( + [ + f'- `{key}`: {self.for_constant(key).display} \n' + for key in self.constants.keys() + ] + ) + @property def api_choices(self): return tuple( - (entry[1], self.convertor.to_slug(entry[0])) for entry in self.entries + (entry[1], f'{self.convertor.to_slug(entry[0])} - {entry[2]}') for entry in self.entries + ) + + @property + def doc_choices(self): + return tuple( + (entry[2], entry[0]) for entry in self.entries ) def has_api_value(self, value): @@ -96,6 +114,10 @@ class APIChoicesWithNone(APIChoices): def choices(self): return ((None, 'None'),) + super().choices + @property + def doc_choices(self): + return ((None, 'None'),) + super().doc_choices + @property def api_choices(self): return ((None, None),) + super().api_choices diff --git a/src/olympia/lib/settings_base.py b/src/olympia/lib/settings_base.py index 911bfe07e354..6324dab5c8c3 100644 --- a/src/olympia/lib/settings_base.py +++ b/src/olympia/lib/settings_base.py @@ -1562,10 +1562,10 @@ def read_only_mode(env): SOCKET_LABS_SERVER_ID = env('SOCKET_LABS_SERVER_ID', default=None) SPECTACULAR_SETTINGS = { - 'TITLE': 'Your Project API', - 'DESCRIPTION': 'Your project description', - 'VERSION': '1.0.0', - 'SERVE_INCLUDE_SCHEMA': False, + 'TITLE': 'AMO API', + 'DESCRIPTION': 'Addons Server API Documentation', + 'SERVE_INCLUDE_SCHEMA': True, + 'SERVE_PERMISSIONS': ['rest_framework.permissions.AllowAny'], # load swagger/redoc assets via collectstatic assets # rather than via the CDN 'SWAGGER_UI_DIST': 'SIDECAR', diff --git a/src/olympia/ratings/views.py b/src/olympia/ratings/views.py index b9473521c331..ecd6279449f7 100644 --- a/src/olympia/ratings/views.py +++ b/src/olympia/ratings/views.py @@ -106,6 +106,8 @@ class RatingReplyThrottle(RatingBurstUserThrottle): class RatingFlagThrottle(GranularUserRateThrottle): rate = '20/day' scope = 'user_rating_flag_throttle' + + class RatingFilterSet(FilterSet): addon = django_filters.CharFilter( field_name='addon__pk',