extensions-website/extensions/views/api.py

260 lines
9.3 KiB
Python

import logging
from django.core.exceptions import ValidationError
from django.db import transaction
from django.http import JsonResponse
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework import serializers, status
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from common.compare import is_in_version_range, version
from extensions.models import Extension, Platform
from extensions.utils import clean_json_dictionary_from_optional_fields
from files.forms import FileFormSkipAgreed
from utils import absolutify
log = logging.getLogger(__name__)
class ListedExtensionsSerializer(serializers.ModelSerializer):
error_messages = {
"invalid_blender_version": "Invalid blender_version: use full semantic versioning like "
"4.2.0.",
}
class Meta:
model = Extension
fields = ()
UNKNOWN_PLATFORM = 'unknown-platform-value'
def __init__(self, *args, **kwargs):
self.blender_version = kwargs.pop('blender_version', None)
self.platform = kwargs.pop('platform', None)
self.request = kwargs.pop('request', None)
if self.request:
self.scheme_host = "{}://{}".format(self.request.scheme, self.request.get_host())
self._validate()
super().__init__(*args, **kwargs)
def _validate(self):
if self.blender_version:
try:
version(self.blender_version)
except ValidationError:
self.fail('invalid_blender_version')
if self.platform:
try:
Platform.objects.get(slug=self.platform)
except Platform.DoesNotExist:
self.platform = self.UNKNOWN_PLATFORM
def find_matching_files_and_version(self, instance):
# avoid triggering additional db queries, reuse the prefetched queryset
versions = sorted(
[v for v in instance.versions.all() if v.is_listed],
key=lambda v: v.date_created,
reverse=True,
)
if not versions:
return ([], None)
for v in versions:
if self.blender_version and not is_in_version_range(
self.blender_version,
v.blender_version_min,
v.blender_version_max,
):
continue
if self.platform:
if file := v.get_file_for_platform(self.platform):
return ([file], v)
else:
return (v.files.all(), v)
return ([], None)
def to_representation(self, instance):
# avoid triggering additional db queries, reuse the prefetched authors queryset
maintainer = instance.team and instance.team.name or str(instance.authors.all()[0])
matching_files, matching_version = self.find_matching_files_and_version(instance)
type_slug = instance.type_slug
result = []
for file in matching_files:
filename = matching_version.get_download_name(file)
data = {
'id': instance.extension_id,
'schema_version': matching_version.schema_version,
'name': instance.name,
'version': matching_version.version,
'tagline': matching_version.tagline,
'archive_hash': file.original_hash,
'archive_size': file.size_bytes,
'archive_url': f'{self.scheme_host}/download/{file.hash}/{filename}',
'type': instance.type_slug_singular,
'blender_version_min': matching_version.blender_version_min,
'blender_version_max': matching_version.blender_version_max,
'website': f'{self.scheme_host}/{type_slug}/{matching_version.extension.slug}/',
'maintainer': maintainer,
'license': [license_iter.slug for license_iter in matching_version.licenses.all()],
'permissions': file.metadata.get('permissions'),
'platforms': file.get_platforms(),
# TODO: handle copyright
'tags': [str(tag) for tag in matching_version.tags.all()],
}
result.append(clean_json_dictionary_from_optional_fields(data))
return result
class ExtensionsAPIView(APIView):
"""Extension Listing API
https://developer.blender.org/docs/features/extensions/api_listing/
"""
permission_classes = [AllowAny]
serializer_class = ListedExtensionsSerializer
@extend_schema(
parameters=[
OpenApiParameter(
name="blender_version",
description=("Blender version to check for compatibility"),
type=str,
),
OpenApiParameter(
name="platform",
description=("Platform to check for compatibility"),
type=str,
),
]
)
@method_decorator(cache_page(60))
def get(self, request):
blender_version = request.GET.get('blender_version')
platform = request.GET.get('platform')
qs = Extension.objects.listed.prefetch_related(
'authors',
'team',
'versions',
'versions__files',
'versions__licenses',
'versions__permissions',
'versions__platforms',
'versions__tags',
).all()
serializer = self.serializer_class(
qs,
blender_version=blender_version,
platform=platform,
request=request,
many=True,
)
data = []
for entry in serializer.data:
data.extend(entry)
return Response(
{
'blocklist': Extension.objects.blocklisted.values_list('extension_id', flat=True),
'data': data,
'version': 'v1',
}
)
class ExtensionVersionSerializer(serializers.Serializer):
version_file = serializers.FileField()
release_notes = serializers.CharField(max_length=1024, required=False)
class UploadExtensionVersionView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
request=ExtensionVersionSerializer,
responses={201: 'Extension version uploaded successfully!'},
)
def post(self, request, extension_id, *args, **kwargs):
serializer = ExtensionVersionSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
user = request.user
version_file = serializer.validated_data['version_file']
release_notes = serializer.validated_data.get('release_notes', '')
extension = Extension.objects.filter(extension_id=extension_id).first()
if not extension:
return Response(
{
'message': f'Extension "{extension_id}" not found',
},
status=status.HTTP_404_NOT_FOUND,
)
if not extension.has_maintainer(user):
return Response(
{
'message': f'Extension "{extension_id}" not maintained by user "{user}"',
},
status=status.HTTP_403_FORBIDDEN,
)
form = FileFormSkipAgreed(
data={},
extension=extension,
request=request,
)
form.fields['source'].initial = version_file
if not form.is_valid():
if 'source' in form.errors:
form.errors['version_file'] = form.errors.pop('source')
return Response({'message': form.errors}, status=status.HTTP_400_BAD_REQUEST)
with transaction.atomic():
# Create the file instance
file_instance = form.save(commit=False)
file_instance.user = user
file_instance.save()
manifest_version = file_instance.metadata['version']
if version := extension.versions.filter(version=manifest_version).first():
version.add_file(file_instance)
version.release_notes = release_notes
version.save(update_fields={'release_notes'})
else:
version = extension.create_version_from_file(
file=file_instance,
release_notes=release_notes,
)
return Response(
{
'message': 'Extension version uploaded successfully!',
'extension_id': extension_id,
'version_file': version_file.name,
'release_notes': version.release_notes,
},
status=status.HTTP_201_CREATED,
)
def extensions_awaiting_review(request):
response = []
for extension in Extension.objects.filter(status=Extension.STATUSES.AWAITING_REVIEW):
version = extension.latest_version
for file in version.files.all():
response.append(
{
'download_url': absolutify(version.get_download_url(file)),
'extension_id': extension.extension_id,
}
)
return JsonResponse(response, safe=False)