[extensions-web/wip/api/v1] extensions: rewritten application
- From: Yuri Konotopov <ykonotopov src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [extensions-web/wip/api/v1] extensions: rewritten application
- Date: Mon, 12 Sep 2022 16:46:28 +0000 (UTC)
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]