mirror of
https://github.com/pretix/pretix.git
synced 2026-08-27 13:24:41 +00:00
API: File upload infrastructure
This commit is contained in:
@@ -183,6 +183,9 @@ Relative date *either* String in ISO 8601 ``"2017-12-27"``,
|
|||||||
constructed from a number of
|
constructed from a number of
|
||||||
days before the base point
|
days before the base point
|
||||||
and the base point.
|
and the base point.
|
||||||
|
File URL in responses, ``file:`` ``"https://…"``, ``"file:…"``
|
||||||
|
specifiers in requests
|
||||||
|
(see below).
|
||||||
===================== ============================ ===================================
|
===================== ============================ ===================================
|
||||||
|
|
||||||
Query parameters
|
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
|
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.
|
``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
|
.. _CSRF policies: https://docs.djangoproject.com/en/1.11/ref/csrf/#ajax
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from pretix.api.views import cart
|
|||||||
|
|
||||||
from .views import (
|
from .views import (
|
||||||
checkin, device, event, exporters, item, oauth, order, organizer, user,
|
checkin, device, event, exporters, item, oauth, order, organizer, user,
|
||||||
version, voucher, waitinglist, webhooks,
|
upload, version, voucher, waitinglist, webhooks,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = routers.DefaultRouter()
|
router = routers.DefaultRouter()
|
||||||
@@ -95,6 +95,7 @@ urlpatterns = [
|
|||||||
url(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"),
|
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/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"),
|
||||||
url(r"^device/eventselection$", device.EventSelectionView.as_view(), name="device.eventselection"),
|
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"^me$", user.MeView.as_view(), name="user.me"),
|
||||||
url(r"^version$", version.VersionView.as_view(), name="version"),
|
url(r"^version$", version.VersionView.as_view(), name="version"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -344,6 +344,11 @@ REST_FRAMEWORK = {
|
|||||||
'DEFAULT_RENDERER_CLASSES': (
|
'DEFAULT_RENDERER_CLASSES': (
|
||||||
'rest_framework.renderers.JSONRenderer',
|
'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',
|
'EXCEPTION_HANDLER': 'pretix.api.exception.custom_exception_handler',
|
||||||
'UNICODE_JSON': False
|
'UNICODE_JSON': False
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user