API: File upload infrastructure

This commit is contained in:
Raphael Michel
2021-01-05 12:49:58 +01:00
parent 9d70fd675c
commit 8b08b43e77
6 changed files with 149 additions and 1 deletions

View File

@@ -183,6 +183,9 @@ Relative date *either* String in ISO 8601 ``"2017-12-27"``,
constructed from a number of
days before the base point
and the base point.
File URL in responses, ``file:`` ``"https://…"``, ``"file:…"``
specifiers in requests
(see below).
===================== ============================ ===================================
Query parameters
@@ -227,4 +230,48 @@ We store idempotency keys for 24 hours, so you should never retry a request afte
All ``POST``, ``PUT``, ``PATCH``, or ``DELETE`` api calls support idempotency keys. Adding an idempotency key to a
``GET``, ``HEAD``, or ``OPTIONS`` request has no effect.
File upload
-----------
In some places, the API supports working with files, for example when setting the picture of a product. In this case,
you will first need to make a separate request to our file upload endpoint:
.. sourcecode:: http
POST /api/v1/upload HTTP/1.1
Host: pretix.eu
Authorization: Token e1l6gq2ye72thbwkacj7jbri7a7tvxe614ojv8ybureain92ocub46t5gab5966k
Content-Type: image/png
Content-Disposition: attachment; filename="logo.png"
Content-Length: 1234
<raw file content>
Note that the ``Content-Type`` and ``Content-Disposition`` headers are required. If the upload was successful, you will
receive a JSON response with the ID of the file:
.. sourcecode:: http
HTTP/1.1 201 Created
Content-Type: application/json
{
"id": "file:1cd99455-1ebd-4cda-b1a2-7a7d2a969ad1"
}
You can then use this file ID in the request you want to use it in. File IDs are currently valid for 24 hours and can only
be used using the same authorization method and user that was used to upload them.
.. sourcecode:: http
PATCH /api/v1/organizers/test/events/test/items/3/ HTTP/1.1
Host: pretix.eu
Content-Type: application/json
{
"picture": "file:1cd99455-1ebd-4cda-b1a2-7a7d2a969ad1"
}
.. _CSRF policies: https://docs.djangoproject.com/en/1.11/ref/csrf/#ajax

View File

@@ -8,7 +8,7 @@ from pretix.api.views import cart
from .views import (
checkin, device, event, exporters, item, oauth, order, organizer, user,
version, voucher, waitinglist, webhooks,
upload, version, voucher, waitinglist, webhooks,
)
router = routers.DefaultRouter()
@@ -95,6 +95,7 @@ urlpatterns = [
url(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"),
url(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"),
url(r"^device/eventselection$", device.EventSelectionView.as_view(), name="device.eventselection"),
url(r"^upload$", upload.UploadView.as_view(), name="user.me"),
url(r"^me$", user.MeView.as_view(), name="user.me"),
url(r"^version$", version.VersionView.as_view(), name="version"),
]

View File

@@ -0,0 +1,53 @@
import datetime
from django.utils.timezone import now
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
from rest_framework.authentication import SessionAuthentication
from rest_framework.exceptions import ValidationError
from rest_framework.parsers import FileUploadParser
from rest_framework.response import Response
from rest_framework.views import APIView
from pretix.api.auth.device import DeviceTokenAuthentication
from pretix.api.auth.token import TeamTokenAuthentication
from pretix.base.models import CachedFile
ALLOWED_TYPES = {
'image/gif': {'.gif'},
'image/jpeg': {'.jpg', '.jpeg'},
'image/png': {'.png'},
'application/pdf': {'.pdf'},
}
class UploadView(APIView):
authentication_classes = (
SessionAuthentication, OAuth2Authentication, DeviceTokenAuthentication, TeamTokenAuthentication
)
parser_classes = [FileUploadParser]
def post(self, request):
if 'file' not in request.data:
raise ValidationError('No file has been submitted.')
file_obj = request.data['file']
content_type = file_obj.content_type.split(";")[0] # ignore e.g. "; charset=…"
if content_type not in ALLOWED_TYPES:
raise ValidationError('Content type "{type}" is not allowed'.format(type=content_type))
if not any(file_obj.name.endswith(ext) for ext in ALLOWED_TYPES[content_type]):
raise ValidationError('File name "{name}" has an invalid extension for type "{type}"'.format(
name=file_obj.name,
type=content_type
))
cf = CachedFile.objects.create(
expires=now() + datetime.timedelta(days=1),
date=now(),
web_download=False,
filename=file_obj.name,
type=content_type,
session_key=f'api-upload-{str(type(request.user or request.auth))}-{(request.user or request.auth).pk}'
)
cf.file.save(file_obj.name, file_obj)
cf.save()
return Response({
'id': f'file:{cf.pk}'
}, status=201)

View File

@@ -344,6 +344,11 @@ REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': (
'rest_framework.renderers.JSONRenderer',
),
'TEST_REQUEST_RENDERER_CLASSES': [
'rest_framework.renderers.MultiPartRenderer',
'rest_framework.renderers.JSONRenderer',
'pretix.testutils.api.UploadRenderer',
],
'EXCEPTION_HANDLER': 'pretix.api.exception.custom_exception_handler',
'UNICODE_JSON': False
}

View File

@@ -0,0 +1,11 @@
from rest_framework.renderers import BaseRenderer
class UploadRenderer(BaseRenderer):
media_type = None
format = 'upload'
charset = 'utf-8'
def render(self, data, accepted_media_type=None, renderer_context=None):
self.media_type = data['media_type']
return data['file']

View File

@@ -0,0 +1,31 @@
import pytest
from django.core.files.base import ContentFile
@pytest.mark.django_db
def test_upload_file(token_client):
r = token_client.post(
'/api/v1/upload',
data={
'media_type': 'application/pdf',
'file': ContentFile('file.pdf', 'invalid pdf content')
},
format='upload',
HTTP_CONTENT_DISPOSITION='attachment; filename="file.pdf"',
)
assert r.status_code == 201
assert r.data['id'].startswith('file:')
@pytest.mark.django_db
def test_upload_file_extension_mismatch(token_client):
r = token_client.post(
'/api/v1/upload',
data={
'media_type': 'application/pdf',
'file': ContentFile('file.png', 'invalid pdf content')
},
format='upload',
HTTP_CONTENT_DISPOSITION='attachment; filename="file.png"',
)
assert r.status_code == 400