[extensions-web/wip/api/v1] extensions: rewritten application



commit 0f169be45821a4e22f8b759807d9ed4ad459291a
Author: Yuri Konotopov <ykonotopov gnome org>
Date:   Mon Sep 12 20:40:53 2022 +0400

    extensions: rewritten application
    
    1. Switch to standard drf filtering for /extensions
    2. Fixed generated OpenAPI schema
    3. Restore /extensions/updates compatibility with Shell
    4. Move /extensions-versions to /extensions/{uuid}/versions
    5. Drop PK from .../versions node
    6. Implemented extension version download via renderer and retain
       compatibility with Shell.
    7. Implemented extension upload
    8. Drop old views and templates
    9. Fixed all tests

 requirements.in                                    |   1 +
 requirements.txt                                   |   6 +
 sweettooth/api/v1/urls.py                          |  12 +-
 sweettooth/api/widgets.py                          |   7 +
 sweettooth/extensions/forms.py                     |  46 --
 sweettooth/extensions/models.py                    |   4 +-
 sweettooth/extensions/renderers.py                 |  27 ++
 sweettooth/extensions/serializers.py               |  83 +++-
 .../extensions/templates/extensions/about.html     | 102 ----
 .../extensions/templates/extensions/comments.html  |  26 --
 .../extensions/templates/extensions/detail.html    | 103 -----
 .../templates/extensions/detail_edit.html          |  84 ----
 .../extensions/templates/extensions/list.html      |  20 -
 .../extensions/templates/extensions/local.html     |  13 -
 .../templates/extensions/multiversion_status.html  |   1 -
 .../extensions/templates/extensions/upload.html    |  47 --
 sweettooth/extensions/tests.py                     |  59 +--
 sweettooth/extensions/urls.py                      |  43 +-
 sweettooth/extensions/views.py                     | 514 +++++----------------
 sweettooth/review/views.py                         |  31 +-
 20 files changed, 294 insertions(+), 935 deletions(-)
---
diff --git a/requirements.in b/requirements.in
index 041aff5..b1ef100 100644
--- a/requirements.in
+++ b/requirements.in
@@ -6,6 +6,7 @@ django-opensearch-dsl==0.4.1
 django-filter==21.1
 django-rest-registration==0.7.2
 django-rest-knox==4.2.0
+drf-nested-routers==0.93.4
 drf-spectacular[sidecar]==0.22.1
 Pygments==2.11.2
 Pillow==9.0.1
diff --git a/requirements.txt b/requirements.txt
index 4dd4aba..33a21dd 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -124,6 +124,7 @@ django==3.2.15 \
     #   django-rest-knox
     #   django-rest-registration
     #   djangorestframework
+    #   drf-nested-routers
     #   drf-spectacular
     #   drf-spectacular-sidecar
 django-autoslug==1.9.8 \
@@ -156,7 +157,12 @@ djangorestframework==3.13.1 \
     #   -r requirements.in
     #   django-rest-knox
     #   django-rest-registration
+    #   drf-nested-routers
     #   drf-spectacular
+drf-nested-routers==0.93.4 \
+    --hash=sha256:01aa556b8c08608bb74fb34f6ca065a5183f2cda4dc0478192cc17a2581d71b0 \
+    --hash=sha256:996b77f3f4dfaf64569e7b8f04e3919945f90f95366838ca5b8bed9dd709d6c5
+    # via -r requirements.in
 drf-spectacular[sidecar]==0.22.1 \
     --hash=sha256:17ac5e31e5d6150dd5fa10843b429202f4f38069202acc44394cc5a771de63d9 \
     --hash=sha256:866e16ddaae167a1234c76cd8c351161373551db994ce9665b347b32d5daf38b
diff --git a/sweettooth/api/v1/urls.py b/sweettooth/api/v1/urls.py
index 353a032..4bbbe10 100644
--- a/sweettooth/api/v1/urls.py
+++ b/sweettooth/api/v1/urls.py
@@ -11,10 +11,11 @@
 from django.urls import include, path
 
 from rest_framework.routers import SimpleRouter
+from rest_framework_nested import routers
 from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
 
 from sweettooth.api.v1.views import HelloView
-from sweettooth.extensions.views import ExtensionsViewSet, ExtensionsVersionsViewSet
+from sweettooth.extensions.views import ExtensionsViewSet, ExtensionsVersionsViewSet, ExtensionUploadView
 from sweettooth.users.views import UserProfileDetailView
 
 # Create a router and register our viewsets with it.
@@ -24,16 +25,21 @@ router.register(
     ExtensionsViewSet,
     basename='extension',
 )
-router.register(
-    r'v1/extensions-versions',
+
+extension_router = routers.NestedSimpleRouter(router, r'v1/extensions', lookup='extension')
+extension_router.register(
+    r'versions',
     ExtensionsVersionsViewSet,
     basename='extensions-versions',
 )
 
 urlpatterns = router.urls
+urlpatterns += extension_router.urls
 urlpatterns += [
     path('v1/accounts/', include('rest_registration.api.urls')),
 
+    path('v1/extensions', ExtensionUploadView.as_view(), name='extension-upload'),
+
     path('v1/hello/', HelloView.as_view()),
     path('v1/profile/<int:pk>/',
          UserProfileDetailView.as_view(),
diff --git a/sweettooth/api/widgets.py b/sweettooth/api/widgets.py
new file mode 100644
index 0000000..0c8d464
--- /dev/null
+++ b/sweettooth/api/widgets.py
@@ -0,0 +1,7 @@
+from django_filters.widgets import QueryArrayWidget as MutableQueryArrayWidget
+
+
+# https://github.com/carltongibson/django-filter/issues/1047
+class QueryArrayWidget(MutableQueryArrayWidget):
+    def value_from_datadict(self, data, files, name):
+        return super().value_from_datadict(data.copy(), files, name)
diff --git a/sweettooth/extensions/models.py b/sweettooth/extensions/models.py
index fa3e5bb..fdc54df 100644
--- a/sweettooth/extensions/models.py
+++ b/sweettooth/extensions/models.py
@@ -175,8 +175,8 @@ class Extension(models.Model):
         super().save(*args, **kwargs)
 
     def get_absolute_url(self):
-        return reverse('extensions-detail', kwargs=dict(pk=self.pk,
-                                                        slug=self.slug))
+        # TODO: do we need urlencode here?
+        return f"/extension/{self.uuid}"
 
     def user_can_edit(self, user):
         if user == self.creator:
diff --git a/sweettooth/extensions/renderers.py b/sweettooth/extensions/renderers.py
new file mode 100644
index 0000000..e67d5dc
--- /dev/null
+++ b/sweettooth/extensions/renderers.py
@@ -0,0 +1,27 @@
+from django.http import HttpResponseRedirect
+
+from rest_framework import renderers, status
+from rest_framework.response import Response
+
+from .models import ExtensionVersion
+
+
+class ExtensionVersionZipRenderer(renderers.BaseRenderer):
+    media_type = 'application/zip'
+    format = 'zip'
+    render_style = 'binary'
+
+    def render(self, data, accepted_media_type=None, renderer_context=None):
+        instance = data.serializer.instance if hasattr(data, 'serializer') else None
+        if isinstance(instance, ExtensionVersion):
+            redirect = HttpResponseRedirect(redirect_to=instance.source.url)
+            renderer_context['response'].status_code = redirect.status_code
+            renderer_context['response'].headers = redirect.headers
+            renderer_context['response'].url = redirect.url
+
+            instance.extension.downloads += 1
+            instance.extension.save()
+        else:
+            renderer_context['response'].status_code = status.HTTP_406_NOT_ACCEPTABLE
+
+        return b''
diff --git a/sweettooth/extensions/serializers.py b/sweettooth/extensions/serializers.py
index ff02c01..dd1d0b4 100644
--- a/sweettooth/extensions/serializers.py
+++ b/sweettooth/extensions/serializers.py
@@ -8,11 +8,17 @@
     (at your option) any later version.
 """
 
+from django.core.exceptions import ValidationError
+from django.db import transaction
+from django.utils.translation import gettext_lazy as _
+
 from rest_framework import serializers
 
-from sweettooth.extensions.models import Extension, ExtensionVersion, ShellVersion
+from sweettooth.extensions.models import Extension, ExtensionVersion, InvalidExtensionData, ShellVersion
 from sweettooth.users.serializers import BaseUserProfileSerializer
 
+from . import models
+
 
 class ShellVersionSerializer(serializers.ModelSerializer):
     class Meta:
@@ -64,3 +70,78 @@ class ExtensionVersionSerializer(serializers.ModelSerializer):
             'shell_versions',
             'created',
         ]
+
+
+class InstalledExtensionSerializer(serializers.Serializer):
+    version = serializers.CharField(allow_blank=True, default='0')
+
+
+class ExtensionsUpdatesSerializer(serializers.Serializer):
+    installed = serializers.DictField(child=InstalledExtensionSerializer())
+    shell_version = serializers.CharField()
+    version_validation_enabled = serializers.BooleanField(default=False)
+
+
+class ExtensionUploadSerializer(serializers.Serializer):
+    source = serializers.FileField(required=True)
+    shell_license_compliant = serializers.BooleanField(required=True)
+    tos_compliant = serializers.BooleanField(required=True)
+
+    def validate_source(self, value):
+        try:
+            self.metadata = models.parse_zipfile_metadata(value)
+            if 'uuid' not in self.metadata:
+                raise serializers.ValidationError(_('The `uuid` field is missing in `metadata.json`'))
+            self.uuid = self.metadata['uuid']
+        except InvalidExtensionData as ex:
+            raise serializers.ValidationError(ex.message)
+
+        return value
+
+    def validate_shell_license_compliant(self, value):
+        if not value:
+            raise serializers.ValidationError(_("Extension can not be published without grant to use it with 
GNOME Shell compatible license"))
+
+        return value
+
+    def validate_tos_compliant(self, value):
+        if not value:
+            raise serializers.ValidationError(
+                _("Extension can not be published without grant to change extension maintainer")
+            )
+
+        return value
+
+    def create(self, validated_data):
+        with transaction.atomic():
+            try:
+                extension = models.Extension.objects.get(uuid=self.uuid)
+            except models.Extension.DoesNotExist:
+                extension = models.Extension(creator=validated_data['user'])
+
+            assert validated_data['user'] == extension.creator or validated_data['user'].is_superuser, (
+                _("An extension with that UUID has already been added")
+            )
+
+            extension.parse_metadata_json(self.metadata)
+            extension.save()
+
+            try:
+                extension.full_clean()
+            except ValidationError as e:
+                raise serializers.ValidationError(e.messages)
+
+            version = models.ExtensionVersion.objects.create(extension=extension,
+                                                             source=validated_data['source'],
+                                                             status=models.STATUS_UNREVIEWED)
+            version.parse_metadata_json(self.metadata)
+            version.replace_metadata_json()
+            version.save()
+
+            models.submitted_for_review.send(sender=validated_data['user'], version=version)
+
+            return version
+
+    def to_representation(self, instance):
+        serializer = ExtensionVersionSerializer(instance=instance)
+        return serializer.data
diff --git a/sweettooth/extensions/tests.py b/sweettooth/extensions/tests.py
index 9d704ef..98177f3 100644
--- a/sweettooth/extensions/tests.py
+++ b/sweettooth/extensions/tests.py
@@ -9,6 +9,10 @@ from zipfile import ZipFile
 from django.test import TestCase, TransactionTestCase
 from django.core.files.base import File
 from django.urls import reverse
+
+from rest_framework import status
+from rest_framework.test import APITestCase
+
 from sweettooth.extensions import models, views
 
 from sweettooth.testutils import BasicUserTestCase
@@ -221,17 +225,20 @@ class ReplaceMetadataTest(BasicUserTestCase, TestCase):
         old_zip.close()
         new_zip.close()
 
+
 class UploadTest(BasicUserTestCase, TransactionTestCase):
     def upload_file(self, zipfile):
         with get_test_zipfile(zipfile) as f:
-            return self.client.post(reverse('extensions-upload-file'),
-                                    dict(source=f,
-                                         gplv2_compliant=True,
-                                         tos_compliant=True), follow=True)
-
-    def test_upload_page_works(self):
-        response = self.client.get(reverse('extensions-upload-file'))
-        self.assertEqual(response.status_code, 200)
+            return self.client.post(
+                reverse('extension-upload'),
+                data={
+                    'source': f,
+                    'shell_license_compliant': True,
+                    'tos_compliant': True,
+                },
+                follow=True,
+                format='multipart'
+            )
 
     def test_upload_parsing(self):
         response = self.upload_file('SimpleExtension')
@@ -244,9 +251,7 @@ class UploadTest(BasicUserTestCase, TransactionTestCase):
         self.assertEqual(extension.description, "Simple test metadata")
         self.assertEqual(extension.url, "http://test-metadata.gnome.org";)
 
-        url = reverse('extensions-detail', kwargs=dict(pk=extension.pk,
-                                                       slug=extension.slug))
-        self.assertRedirects(response, url)
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
         version1.status = models.STATUS_ACTIVE
         version1.save()
@@ -483,7 +488,7 @@ class ShellVersionTest(TestCase):
         with self.assertRaises(models.InvalidShellVersion):
             models.parse_version_string("40.teta")
 
-class DownloadExtensionTest(BasicUserTestCase, TestCase):
+class DownloadExtensionTest(BasicUserTestCase, APITestCase):
     def download(self, uuid, shell_version):
         url = reverse('extensions-shell-download', kwargs=dict(uuid=uuid))
         return self.client.get(url, dict(shell_version=shell_version), follow=True)
@@ -584,18 +589,12 @@ class UpdateVersionTest(TestCase):
     def build_response(self, installed):
         return dict((k, dict(version=v)) for k, v in installed.items())
 
-    def grab_response(self, installed):
-        installed = self.build_response(installed)
-        response = self.client.get(reverse('extensions-shell-update'),
-                                   dict(installed=json.dumps(installed), shell_version='3.2.0'))
-
-        return json.loads(response.content.decode(response.charset))
-
     def grab_post_response(self, installed):
         installed = self.build_response(installed)
         response = self.client.post(reverse('extensions-shell-update') + "?shell_version=3.2.0",
                                    data=json.dumps(installed),
-                                   content_type='application/json')
+                                   content_type='application/json',
+                                   follow=True)
 
         return json.loads(response.content.decode(response.charset))
 
@@ -604,14 +603,10 @@ class UpdateVersionTest(TestCase):
 
         # The user has an old version, upgrade him
         expected = {uuid: self.full_expected[self.upgrade_uuid]}
-        response = self.grab_response({ uuid: 1 })
-        self.assertEqual(response, expected)
         response = self.grab_post_response({ uuid: 1 })
         self.assertEqual(response, expected)
 
         # The user has a newer version on his machine.
-        response = self.grab_response({ uuid: 2 })
-        self.assertEqual(response, {})
         response = self.grab_post_response({ uuid: 2 })
         self.assertEqual(response, {})
 
@@ -619,14 +614,10 @@ class UpdateVersionTest(TestCase):
         uuid = self.reject_uuid
 
         expected = {uuid: self.full_expected[self.reject_uuid]}
-        response = self.grab_response({ uuid: 1 })
-        self.assertEqual(response, expected)
         response = self.grab_post_response({ uuid: 1 })
         self.assertEqual(response, expected)
 
         # The user has a newer version than what's on the site.
-        response = self.grab_response({ uuid: 2 })
-        self.assertEqual(response, {})
         response = self.grab_post_response({ uuid: 2 })
         self.assertEqual(response, {})
 
@@ -635,21 +626,15 @@ class UpdateVersionTest(TestCase):
 
         # The user has a rejected version, so downgrade.
         expected = { uuid: self.full_expected[self.downgrade_uuid] }
-        response = self.grab_response({ uuid: 2 })
-        self.assertEqual(response, expected)
         response = self.grab_post_response({ uuid: 2 })
         self.assertEqual(response, expected)
 
         # The user has the appropriate version on his machine.
-        response = self.grab_response({ uuid: 1 })
-        self.assertEqual(response, {})
         response = self.grab_post_response({ uuid: 1 })
         self.assertEqual(response, {})
 
     def test_nonexistent_uuid(self):
         # The user has an extension that's not on the site.
-        response = self.grab_response({ self.nonexistant_uuid: 1 })
-        self.assertEqual(response, {})
         response = self.grab_post_response({ self.nonexistant_uuid: 1 })
         self.assertEqual(response, {})
 
@@ -659,8 +644,6 @@ class UpdateVersionTest(TestCase):
                       self.downgrade_uuid: 2,
                       self.nonexistant_uuid: 2 }
 
-        response = self.grab_response(installed)
-        self.assertEqual(self.full_expected, response)
         response = self.grab_post_response(installed)
         self.assertEqual(self.full_expected, response)
 
@@ -669,14 +652,10 @@ class UpdateVersionTest(TestCase):
 
         # The user provided wrong version, upgrade him if we have version > 1
         expected = {uuid: self.full_expected[self.upgrade_uuid]}
-        response = self.grab_response({uuid: ''})
-        self.assertEqual(response, expected)
         response = self.grab_post_response({uuid: ''})
         self.assertEqual(response, expected)
 
         expected = {uuid: self.full_expected[self.upgrade_uuid]}
-        response = self.grab_response({uuid: '0.8.4'})
-        self.assertEqual(response, expected)
         response = self.grab_post_response({uuid: '0.8.4'})
         self.assertEqual(response, expected)
 
diff --git a/sweettooth/extensions/urls.py b/sweettooth/extensions/urls.py
index e655a47..0636b0f 100644
--- a/sweettooth/extensions/urls.py
+++ b/sweettooth/extensions/urls.py
@@ -1,47 +1,10 @@
-
-from django.conf.urls import include
 from django.urls import re_path
-from django.views.generic.base import RedirectView, TemplateView
-
-from sweettooth.extensions import views, models, feeds
-
-ajax_patterns = [
-    re_path(r'^edit/(?P<pk>\d+)', views.ajax_inline_edit_view, name='extensions-ajax-inline'),
-    re_path(r'^upload/screenshot/(?P<pk>\d+)', views.ajax_upload_screenshot_view, 
name='extensions-ajax-screenshot'),
-    re_path(r'^upload/icon/(?P<pk>\d+)', views.ajax_upload_icon_view, name='extensions-ajax-icon'),
-    re_path(r'^detail/', views.ajax_details_view, name='extensions-ajax-details'),
-
-    re_path(r'^set-status/active/', views.ajax_set_status_view,
-        dict(newstatus=models.STATUS_ACTIVE), name='extensions-ajax-set-status-active'),
-    re_path(r'^set-status/inactive/', views.ajax_set_status_view,
-        dict(newstatus=models.STATUS_INACTIVE), name='extensions-ajax-set-status-inactive'),
-    re_path(r'^adjust-popularity/', views.ajax_adjust_popularity_view),
-]
 
-shell_patterns = [
-    re_path(r'^extension-info/', views.ajax_details_view),
+from sweettooth.extensions import views, feeds
 
+urlpatterns = [
+    re_path(r'^rss/', feeds.LatestExtensionsFeed(), name='extensions-rss-feed'),
     re_path(r'^download-extension/(?P<uuid>.+)\.shell-extension\.zip$',
         views.shell_download, name='extensions-shell-download'),
-
     re_path(r'^update-info/', views.shell_update, name='extensions-shell-update'),
 ]
-
-urlpatterns = [
-    re_path(r'^$', RedirectView.as_view(url='/', permanent=False), name='extensions-index'),
-
-    re_path(r'^about/$', TemplateView.as_view(template_name='extensions/about.html'), 
name='extensions-about'),
-
-    re_path(r'^extension/(?P<pk>\d+)/(?P<slug>.+)/$',
-        views.extension_view, name='extensions-detail'),
-    re_path(r'^extension/(?P<pk>\d+)/$',
-        views.extension_view, dict(slug=None), name='extensions-detail'),
-
-    re_path(r'^local/', TemplateView.as_view(template_name='extensions/local.html'), 
name='extensions-local'),
-
-    re_path(r'^rss/', feeds.LatestExtensionsFeed(), name='extensions-rss-feed'),
-
-    re_path(r'^upload/', views.upload_file, name='extensions-upload-file'),
-    re_path(r'^ajax/', include(ajax_patterns)),
-    re_path(r'', include(shell_patterns)),
-]
diff --git a/sweettooth/extensions/views.py b/sweettooth/extensions/views.py
index e9cf6b9..683bfc9 100644
--- a/sweettooth/extensions/views.py
+++ b/sweettooth/extensions/views.py
@@ -9,44 +9,57 @@
     (at your option) any later version.
 """
 
-import json
 from functools import reduce
+import json
+from urllib.parse import urlencode, urlparse, urlunparse
 
-from django.core.exceptions import ValidationError
-from django.core.paginator import Paginator, InvalidPage
-from django.contrib.auth.decorators import login_required
-from django.contrib import messages
-from django.db import transaction
+from django.core.paginator import Paginator
+from django.forms import Field
 from django.http import (
-    JsonResponse,
-    HttpResponse,
     HttpResponseBadRequest,
-    HttpResponseForbidden,
-    HttpResponseServerError,
     Http404
 )
-from django.shortcuts import get_object_or_404, redirect, render
-from django.template.loader import render_to_string
+from django.shortcuts import get_object_or_404, redirect
 from django.views.decorators.csrf import csrf_exempt
-from django.views.decorators.http import require_POST
 from django.urls import reverse
 
-from django_filters.rest_framework import DjangoFilterBackend
+from django_filters.rest_framework import CharFilter, ChoiceFilter, DjangoFilterBackend, FilterSet, 
MultipleChoiceFilter
+
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema, OpenApiParameter
 
-from rest_framework import filters, mixins, viewsets, status
+from rest_framework import filters, mixins, parsers, permissions, renderers, viewsets, status
+from rest_framework.generics import CreateAPIView
 from rest_framework.decorators import action
 from rest_framework.pagination import PageNumberPagination
 from rest_framework.parsers import JSONParser
 from rest_framework.response import Response
+from rest_framework.test import APIRequestFactory
 
-
-from sweettooth.decorators import ajax_view, model_view
-from sweettooth.exceptions import DatabaseErrorWithMessages
+from sweettooth import settings
+from sweettooth.api.widgets import QueryArrayWidget
+from sweettooth.decorators import ajax_view
 from sweettooth.extensions import models, serializers
 from sweettooth.extensions.documents import ExtensionDocument
-from sweettooth.extensions.forms import ImageUploadForm, UploadForm
-from sweettooth.extensions.templatetags.extension_icon import extension_icon
-from sweettooth import settings
+
+from .renderers import ExtensionVersionZipRenderer
+
+
+class UUIDFilter(MultipleChoiceFilter, CharFilter):
+    field_class = Field
+
+
+class ExtensionsFilter(FilterSet):
+    uuid = UUIDFilter(widget=QueryArrayWidget)
+    status = ChoiceFilter(
+        field_name='versions__status',
+        choices=list(models.STATUSES.items()),
+        distinct=True
+    )
+
+    class Meta:
+        model = models.Extension
+        fields = ('uuid', 'status', 'recommended')
 
 
 class ExtensionsPagination(PageNumberPagination):
@@ -58,48 +71,34 @@ class ExtensionsPagination(PageNumberPagination):
 class ExtensionsViewSet(mixins.ListModelMixin,
                         mixins.RetrieveModelMixin,
                         viewsets.GenericViewSet):
+    queryset = models.Extension.objects.all()
     lookup_field = 'uuid'
     lookup_value_regex = '[-a-zA-Z0-9@._]+'
     serializer_class = serializers.ExtensionSerializer
     pagination_class = ExtensionsPagination
-    filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
-    filterset_fields = ['recommended']
+    filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
+    filterset_class = ExtensionsFilter
     ordering_fields = ['created', 'updated', 'downloads', 'popularity', '?']
     page_size = 25
     page_size_query_param = 'page_size'
     max_page_size = 100
 
-    def get_queryset(self):
-        uuids = self.request.query_params.getlist('uuid[]')
-        status = self.request.query_params.get('status')
-        queryset = models.Extension.objects.all()
-
-        if uuids:
-            queryset = queryset.filter(uuid__in=uuids)
-
-        if status is not None and status.isnumeric():
-            status = int(status)
-
-        if status in models.STATUSES:
-            queryset = queryset.filter(versions__status=status).distinct()
-
-        return queryset
-
+    @extend_schema(request=serializers.ExtensionsUpdatesSerializer)
     @action(methods=['post'], detail=False, parser_classes=[JSONParser])
     def updates(self, request):
-        installed = self.request.data.get('installed')
-        shell_version = self.request.data.get('shell_version')
-        version_validation_enabled = self.request.data.get('version_validation_enabled', False)
+        updates = serializers.ExtensionsUpdatesSerializer(data=request.data)
 
-        if not installed:
-            return Response()
+        if not updates.is_valid():
+            return HttpResponseBadRequest()
 
-        extensions = models.Extension.objects.filter(uuid__in=installed.keys())
+        extensions = models.Extension.objects.filter(uuid__in=updates.validated_data['installed'].keys())
+        if not extensions.exists():
+            return Response({})
 
         result = {}
-        for uuid, version in installed.items():
+        for uuid, update_data in updates.validated_data['installed'].items():
             try:
-                version = int(version)
+                version = int(update_data['version'])
             except (KeyError, TypeError):
                 continue
             except ValueError:
@@ -125,18 +124,28 @@ class ExtensionsViewSet(mixins.ListModelMixin,
 
             proper_version = grab_proper_extension_version(
                 extension,
-                shell_version,
-                version_validation_enabled
+                updates.validated_data['shell_version'],
+                updates.validated_data['version_validation_enabled']
             )
 
-            if proper_version is not None and proper_version.version != version.version:
-                result[uuid] = {
-                    'action': 'change',
-                    'version': proper_version.version,
-                }
+            if proper_version is not None:
+                if version.version < proper_version.version:
+                    result[uuid] = 'upgrade'
+                elif version.status == models.STATUS_REJECTED:
+                    result[uuid] = "downgrade"
+            else:
+                result[uuid] = "blacklist"
 
         return Response(result)
 
+    @extend_schema(
+        parameters=[
+            OpenApiParameter(name=pagination_class.page_query_param, type=OpenApiTypes.INT),
+            OpenApiParameter(name=page_size_query_param, type=OpenApiTypes.INT),
+            OpenApiParameter(name='recommended', type=OpenApiTypes.BOOL),
+            OpenApiParameter(name='ordering', enum=['asc', 'desc']),
+        ]
+    )
     @action(methods=['get'], detail=False, url_path='search/(?P<query>[^/.]+)')
     def search(self, request, query=None):
         try:
@@ -175,27 +184,49 @@ class ExtensionsViewSet(mixins.ListModelMixin,
 
         paginator = Paginator(queryset.to_queryset(), page_size)
 
-        return Response({
-            'count': paginator.count,
-            'results': self.serializer_class(
-                [sr for sr in paginator.page(page).object_list],
-                many=True,
-                context={'request': request}
-            ).data
-        })
+        try:
+            return Response({
+                'count': paginator.count,
+                'results': self.serializer_class(
+                    [sr for sr in paginator.page(page).object_list],
+                    many=True,
+                    context={'request': request}
+                ).data
+            })
+        except Exception:
+            return Response(status=status.HTTP_400_BAD_REQUEST)
 
 
 class ExtensionsVersionsViewSet(mixins.ListModelMixin,
+                                mixins.RetrieveModelMixin,
                                 viewsets.GenericViewSet):
-    queryset = models.ExtensionVersion.objects.order_by('extension', 'version')
+    lookup_field = 'version'
     serializer_class = serializers.ExtensionVersionSerializer
     pagination_class = ExtensionsPagination
+    renderer_classes = [
+        renderers.JSONRenderer,
+        renderers.BrowsableAPIRenderer,
+        ExtensionVersionZipRenderer,
+    ]
     filter_backends = [DjangoFilterBackend]
-    filterset_fields = ['extension__uuid']
     page_size = 25
     page_size_query_param = 'page_size'
     max_page_size = 100
 
+    def get_queryset(self):
+        return models.ExtensionVersion.objects.filter(
+            extension__uuid=self.kwargs['extension_uuid']
+        ).order_by('version')
+
+
+class ExtensionUploadView(CreateAPIView):
+    parser_classes = [parsers.MultiPartParser]
+    permission_classes = [permissions.IsAuthenticated]
+    serializer_class = serializers.ExtensionUploadSerializer
+
+    def perform_create(self, serializer):
+        serializer.save(user=self.request.user)
+
 
 def get_versions_for_version_strings(version_strings):
     def get_version(major, minor, point):
@@ -263,6 +294,7 @@ def grab_proper_extension_version(extension, shell_version, version_validation_e
 
     return versions.order_by('-version')[0]
 
+
 def find_extension_version_from_params(extension, params):
     vpk = params.get('version_tag', '')
     shell_version = params.get('shell_version', '')
@@ -279,6 +311,7 @@ def find_extension_version_from_params(extension, params):
     else:
         return None
 
+
 def shell_download(request, uuid):
     extension = get_object_or_404(models.Extension.objects.visible(), uuid=uuid)
     try:
@@ -289,10 +322,17 @@ def shell_download(request, uuid):
     if version is None:
         raise Http404()
 
-    extension.downloads += 1
-    extension.save()
+    url = list(urlparse(reverse(
+        'extensions-versions-detail',
+        kwargs={
+            'extension_uuid': extension.uuid,
+            'version': version.version
+        }
+    )))
+    url[4] = urlencode({'format': 'zip'})
+
+    return redirect(urlunparse(url))
 
-    return redirect(version.source.url)
 
 @ajax_view
 @csrf_exempt
@@ -300,334 +340,24 @@ def shell_update(request):
     try:
         if request.method == 'POST':
             installed = json.load(request)
-        # TODO: drop GET request support at year after chrome-gnome-shell 11 release
         else:
-            installed = json.loads(request.GET['installed'])
+            return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)
         shell_version = request.GET['shell_version']
         disable_version_validation = request.GET.get('disable_version_validation', False)
     except (KeyError, ValueError):
         return HttpResponseBadRequest()
 
-    operations = {}
-
-    for uuid, meta in installed.items():
-        try:
-            version = int(meta['version'])
-        except (KeyError, TypeError):
-            # XXX - if the user has a locally installed version of
-            # an extension on SweetTooth, what should we do?
-            continue
-        except ValueError:
-            version = 1
-
-        try:
-            extension = models.Extension.objects.get(uuid=uuid)
-        except models.Extension.DoesNotExist:
-            continue
-
-        try:
-            version_obj = extension.versions.get(version=version)
-        except models.ExtensionVersion.DoesNotExist:
-            # The user may have a newer version than what's on the site.
-            continue
-
-        try:
-            proper_version = grab_proper_extension_version(extension, shell_version, not 
disable_version_validation)
-        except models.InvalidShellVersion:
-            return HttpResponseBadRequest()
-
-        if proper_version is not None:
-            if version < proper_version.version:
-                operations[uuid] = "upgrade"
-            elif version_obj.status == models.STATUS_REJECTED:
-                operations[uuid] = "downgrade"
-        else:
-            operations[uuid] = "blacklist"
-
-    return operations
-
-def ajax_query_params_query(request, versions, n_per_page):
-    version_qs = models.ExtensionVersion.objects.visible()
-
-    if versions is not None:
-        version_qs = version_qs.filter(shell_versions__in=versions)
-
-    queryset = models.Extension.objects.distinct().filter(versions__in=version_qs)
-
-    uuids = request.GET.getlist('uuid')
-    if uuids:
-        queryset = queryset.filter(uuid__in=uuids)
-
-    sort = request.GET.get('sort', 'popularity')
-    sort = dict(recent='created').get(sort, sort)
-    if sort not in ('created', 'downloads', 'popularity', 'name'):
-        raise Http404()
-
-    queryset = queryset.order_by(sort)
-
-    # Sort by ASC for name, DESC for everything else.
-    if sort == 'name':
-        default_order = 'asc'
-    else:
-        default_order = 'desc'
-
-    order = request.GET.get('order', default_order)
-    queryset.query.standard_ordering = (order == 'asc')
-
-    if n_per_page == -1:
-        return queryset, 1
-
-    # Paginate the query
-    paginator = Paginator(queryset, n_per_page)
-    page = request.GET.get('page', 1)
-    try:
-        page_number = int(page)
-    except ValueError:
-        raise Http404()
-
-    try:
-        page_obj = paginator.page(page_number)
-    except InvalidPage:
-        raise Http404()
-
-    return page_obj.object_list, paginator.num_pages
-
-@model_view(models.Extension)
-def extension_view(request, obj, **kwargs):
-    extension, versions = obj, obj.visible_versions
-
-    if versions.count() == 0 and not extension.user_can_edit(request.user):
-        raise Http404()
-
-    # Redirect if we don't match the slug.
-    slug = kwargs.get('slug')
-
-    if slug != extension.slug:
-        kwargs.update(dict(slug=extension.slug,
-                           pk=extension.pk))
-        return redirect(extension)
-
-    # If the user can edit the model, let him do so.
-    if extension.user_can_edit(request.user):
-        template_name = "extensions/detail_edit.html"
-    else:
-        template_name = "extensions/detail.html"
-
-    context = dict(shell_version_map = json.dumps(extension.visible_shell_version_map),
-                   extension = extension,
-                   extension_uses_unlock_dialog = extension.uses_session_mode('unlock-dialog'),
-                   all_versions = extension.versions.order_by('-version'),
-                   visible_versions=json.dumps(extension.visible_shell_version_array),
-                   is_visible = extension.latest_version is not None,
-                   next = extension.get_absolute_url())
-    return render(request, template_name, context)
-
-@require_POST
-@ajax_view
-def ajax_adjust_popularity_view(request):
-    uuid = request.POST['uuid']
-    action = request.POST['action']
-
-    try:
-        extension = models.Extension.objects.get(uuid=uuid)
-    except models.Extension.DoesNotExist:
-        raise Http404()
-
-    pop = models.ExtensionPopularityItem(extension=extension)
-
-    if action == 'enable':
-        pop.offset = +1
-    elif action == 'disable':
-        pop.offset = -1
-    else:
-        return HttpResponseServerError()
-
-    pop.save()
-
-@ajax_view
-@require_POST
-@model_view(models.Extension)
-def ajax_inline_edit_view(request, extension):
-    if not extension.user_can_edit(request.user):
-        return HttpResponseForbidden()
-
-    key = request.POST['id']
-    value = request.POST['value']
-    if key.startswith('extension_'):
-        key = key[len('extension_'):]
-
-    if key == 'name':
-        extension.name = value
-    elif key == 'description':
-        extension.description = value
-    elif key == 'url':
-        extension.url = value
-    else:
-        return HttpResponseForbidden()
-
-    models.extension_updated.send(sender=extension, extension=extension)
-
-    extension.full_clean()
-    extension.save()
-
-    return value
-
-def validate_uploaded_image(request, extension):
-    if not extension.user_can_edit(request.user):
-        return HttpResponseForbidden()
-
-    form = ImageUploadForm(request.POST, request.FILES)
-
-    if not form.is_valid():
-        return JsonResponse(form.errors.get_json_data(), status=403)
-
-    if form.cleaned_data['file'].size > 2*1024*1024:
-        return HttpResponseForbidden(content="Too big image")
-
-    return form.cleaned_data['file']
-
-
-@ajax_view
-@require_POST
-@model_view(models.Extension)
-def ajax_upload_screenshot_view(request, extension):
-    data = validate_uploaded_image(request, extension)
-    if isinstance(data, HttpResponse):
-        return data
-
-    extension.screenshot = data
-    extension.full_clean()
-    extension.save()
-    return extension.screenshot.url
-
-@ajax_view
-@require_POST
-@model_view(models.Extension)
-def ajax_upload_icon_view(request, extension):
-    data = validate_uploaded_image(request, extension)
-    if isinstance(data, HttpResponse):
-        return data
-
-    extension.icon = data
-    extension.full_clean()
-    extension.save()
-    return extension.icon.url
-
-def ajax_details(extension, version=None):
-    details = dict(uuid = extension.uuid,
-                   name = extension.name,
-                   creator = extension.creator.username,
-                   creator_url = reverse('auth-profile', kwargs=dict(user=extension.creator.username)),
-                   pk = extension.pk,
-                   description = extension.description,
-                   link = extension.get_absolute_url(),
-                   icon = extension_icon(extension.icon),
-                   screenshot = extension.screenshot.url if extension.screenshot else None,
-                   shell_version_map = extension.visible_shell_version_map)
-
-    if version is not None:
-        download_url = reverse('extensions-shell-download', kwargs=dict(uuid=extension.uuid))
-        details['version'] = version.version
-        details['version_tag'] = version.pk
-        details['download_url'] = "%s?version_tag=%d" % (download_url, version.pk)
-    return details
-
-@ajax_view
-def ajax_details_view(request):
-    uuid = request.GET.get('uuid', None)
-    pk = request.GET.get('pk', None)
-
-    if uuid is not None:
-        extension = get_object_or_404(models.Extension.objects.visible(), uuid=uuid)
-    elif pk is not None:
-        try:
-            extension = get_object_or_404(models.Extension.objects.visible(), pk=pk)
-        except (TypeError, ValueError):
-            raise Http404()
-    else:
-        raise Http404()
-
-    try:
-        version = find_extension_version_from_params(extension, request.GET)
-    except models.InvalidShellVersion:
-        return HttpResponseBadRequest()
-
-    return ajax_details(extension, version)
-
-@ajax_view
-def ajax_set_status_view(request, newstatus):
-    pk = request.GET['pk']
-
-    version = get_object_or_404(models.ExtensionVersion, pk=pk)
-    extension = version.extension
-
-    if not extension.user_can_edit(request.user):
-        return HttpResponseForbidden()
-
-    if version.status not in (models.STATUS_ACTIVE, models.STATUS_INACTIVE):
-        return HttpResponseForbidden()
-
-    version.status = newstatus
-    version.save()
-
-    context = dict(version=version,
-                   extension=extension)
-
-    return dict(svm=json.dumps(extension.visible_shell_version_map),
-                mvs=render_to_string('extensions/multiversion_status.html', context))
-
-
-def create_version(request, file_source):
-    try:
-        with transaction.atomic():
-            try:
-                metadata = models.parse_zipfile_metadata(file_source)
-                uuid = metadata['uuid']
-            except (models.InvalidExtensionData, KeyError) as e:
-                messages.error(request, "Invalid extension data: %s" % (e.message,))
-                raise DatabaseErrorWithMessages
-
-            try:
-                extension = models.Extension.objects.get(uuid=uuid)
-            except models.Extension.DoesNotExist:
-                extension = models.Extension(creator=request.user)
-            else:
-                if request.user != extension.creator and not request.user.is_superuser:
-                    messages.error(request, "An extension with that UUID has already been added.")
-                    raise DatabaseErrorWithMessages
-
-            extension.parse_metadata_json(metadata)
-            extension.save()
-
-            try:
-                extension.full_clean()
-            except ValidationError as e:
-                raise DatabaseErrorWithMessages(e.messages)
-
-            version = models.ExtensionVersion.objects.create(extension=extension,
-                                                             source=file_source,
-                                                             status=models.STATUS_UNREVIEWED)
-            version.parse_metadata_json(metadata)
-            version.replace_metadata_json()
-            version.save()
-
-            return version, []
-    except DatabaseErrorWithMessages as e:
-        return None, e.messages
-
-@login_required
-def upload_file(request):
-    errors = []
-    if request.method == 'POST':
-        form = UploadForm(request.POST, request.FILES)
-        if form.is_valid():
-            file_source = form.cleaned_data['source']
-            version, errors = create_version(request, file_source)
-            if version is not None:
-                models.submitted_for_review.send(sender=request, request=request, version=version)
-                return redirect(version.extension)
-    else:
-        form = UploadForm()
-
-    return render(request, 'extensions/upload.html', dict(form=form,
-                                                          errors=errors))
+    mocked_request = APIRequestFactory().post(
+        reverse('extension-updates'),
+        data={
+            'installed': {
+                uuid: data | {
+                    'uuid': uuid
+                }
+                for uuid, data in installed.items()
+            },
+            'shell_version': shell_version,
+            'version_validation_enabled': not disable_version_validation,
+        }
+    )
+    return ExtensionsViewSet.as_view({'post': 'updates'})(mocked_request)
diff --git a/sweettooth/review/views.py b/sweettooth/review/views.py
index 0068c60..8911b31 100644
--- a/sweettooth/review/views.py
+++ b/sweettooth/review/views.py
@@ -3,12 +3,14 @@ import base64
 from collections import Counter
 import itertools
 import os.path
+from urllib.parse import urljoin
 
 import pygments
 import pygments.util
 import pygments.lexers
 import pygments.formatters
 
+from django.conf import settings
 from django.core.mail import EmailMessage
 from django.http import HttpResponseForbidden, Http404
 from django.shortcuts import redirect, get_object_or_404, render
@@ -307,29 +309,28 @@ def render_mail(version, template, data):
 
     return EmailMessage(subject=subject.strip(), body=body.strip(), headers=headers)
 
-def send_email_submitted(request, version):
+def send_email_submitted(version):
     extension = version.extension
 
-    url = request.build_absolute_uri(reverse('review-version',
-                                             kwargs=dict(pk=version.pk)))
-
-    data = dict(url=url)
-
     recipient_list = list(get_all_reviewers().values_list('email', flat=True))
 
-    message = render_mail(version, 'submitted', data)
+    message = render_mail(version, 'submitted', {
+        'url': urljoin(
+            settings.BASE_URL,
+            reverse('review-version', kwargs=dict(pk=version.pk))
+        ),
+    })
     message.to = recipient_list
     message.extra_headers.update({'X-SweetTooth-Purpose': 'NewExtension',
                                   'X-SweetTooth-ExtensionCreator': extension.creator.username})
 
     message.send()
 
-def send_email_auto_approved(request, version):
+def send_email_auto_approved(version):
     extension = version.extension
 
-    review_url = request.build_absolute_uri(reverse('review-version',
-                                                    kwargs=dict(pk=version.pk)))
-    version_url = request.build_absolute_uri(version.get_absolute_url())
+    review_url = urljoin(settings.BASE_URL, f'/review/{version.pk}')
+    version_url = urljoin(settings.BASE_URL, version.get_absolute_url())
 
     recipient_list = list(get_all_reviewers().values_list('email', flat=True))
     recipient_list.append(extension.creator.email)
@@ -393,18 +394,18 @@ def should_auto_approve(version: models.ExtensionVersion):
     changeset = get_file_changeset(old_zipfile, new_zipfile)
     return should_auto_approve_changeset(changeset)
 
-def extension_submitted(sender, request, version, **kwargs):
+def extension_submitted(sender, version, **kwargs):
     if should_auto_approve(version):
         CodeReview.objects.create(version=version,
-                                  reviewer=request.user,
+                                  reviewer=sender,
                                   comments="",
                                   new_status=models.STATUS_ACTIVE,
                                   auto=True)
         version.status = models.STATUS_ACTIVE
         version.save()
-        send_email_auto_approved(request, version)
+        send_email_auto_approved(version)
     else:
-        send_email_submitted(request, version)
+        send_email_submitted(version)
 
 models.submitted_for_review.connect(extension_submitted)
 


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]