From 8b08b43e77cc8c11b2ddb60fe8d32c68dfb50656 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 5 Jan 2021 12:49:58 +0100 Subject: [PATCH 1/5] API: File upload infrastructure --- doc/api/fundamentals.rst | 47 ++++++++++++++++++++++++++++++ src/pretix/api/urls.py | 3 +- src/pretix/api/views/upload.py | 53 ++++++++++++++++++++++++++++++++++ src/pretix/settings.py | 5 ++++ src/pretix/testutils/api.py | 11 +++++++ src/tests/api/test_upload.py | 31 ++++++++++++++++++++ 6 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/pretix/api/views/upload.py create mode 100644 src/pretix/testutils/api.py create mode 100644 src/tests/api/test_upload.py diff --git a/doc/api/fundamentals.rst b/doc/api/fundamentals.rst index 8cfa55223..9e8cd323b 100644 --- a/doc/api/fundamentals.rst +++ b/doc/api/fundamentals.rst @@ -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 + + + +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 diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index d2d49e69a..6737a90ba 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -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"), ] diff --git a/src/pretix/api/views/upload.py b/src/pretix/api/views/upload.py new file mode 100644 index 000000000..35fe855f7 --- /dev/null +++ b/src/pretix/api/views/upload.py @@ -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) diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 252de9a45..a81a053e3 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -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 } diff --git a/src/pretix/testutils/api.py b/src/pretix/testutils/api.py new file mode 100644 index 000000000..109ba0689 --- /dev/null +++ b/src/pretix/testutils/api.py @@ -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'] diff --git a/src/tests/api/test_upload.py b/src/tests/api/test_upload.py new file mode 100644 index 000000000..a44c2cb75 --- /dev/null +++ b/src/tests/api/test_upload.py @@ -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 From 75e100f108889b414fd99dfe028a84abb507432f Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 5 Jan 2021 16:19:29 +0100 Subject: [PATCH 2/5] API: Allow to set product pictures --- doc/api/resources/items.rst | 4 +-- src/pretix/api/serializers/fields.py | 47 ++++++++++++++++++++++++++++ src/pretix/api/serializers/item.py | 6 +++- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index 1ee80386b..d5a478250 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -36,8 +36,8 @@ admission boolean ``true`` for it (such as primary tickets) and ``false`` for others (such as add-ons or merchandise). position integer An integer, used for sorting -picture string A product picture to be displayed in the shop - (read-only, can be ``null``). +picture file A product picture to be displayed in the shop + (can be ``null``). sales_channels list of strings Sales channels this product is available on, such as ``"web"`` or ``"resellers"``. Defaults to ``["web"]``. available_from datetime The first date time at which this item can be bought diff --git a/src/pretix/api/serializers/fields.py b/src/pretix/api/serializers/fields.py index 3bd949d68..f23a7ad82 100644 --- a/src/pretix/api/serializers/fields.py +++ b/src/pretix/api/serializers/fields.py @@ -27,3 +27,50 @@ class ListMultipleChoiceField(serializers.MultipleChoiceField): ] return remove_duplicates_from_list(representation_data) + + +class UploadedFileField(serializers.Field): + default_error_messages = { + 'required': 'No file was submitted.', + 'not_found': 'The submitted file ID was not found.', + 'invalid_type': 'The submitted file has a file type that is not allowed in this field.', + 'size': 'The submitted file is too large to be used in this field.', + } + + def __init__(self, *args, **kwargs): + self.allowed_types = kwargs.pop('allowed_types', None) + self.max_size = kwargs.pop('max_size', None) + super().__init__(*args, **kwargs) + + def to_internal_value(self, data): + from pretix.base.models import CachedFile + + request = self.context.get('request', None) + try: + cf = CachedFile.objects.get( + session_key=f'api-upload-{str(type(request.user or request.auth))}-{(request.user or request.auth).pk}', + file__isnull=False, + pk=data[len("file:"):], + ) + except CachedFile.DoesNotExist: + self.fail('not_found') + + if self.allowed_types and cf.type not in self.allowed_types: + self.fail('invalid_type') + if self.max_size and cf.file.size > self.max_size: + self.fail('size') + + return cf.file + + def to_representation(self, value): + if not value: + return None + + try: + url = value.url + except AttributeError: + return None + request = self.context.get('request', None) + if request is not None: + return request.build_absolute_uri(url) + return url diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 403601aee..36018d9d6 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from pretix.api.serializers.event import MetaDataField +from pretix.api.serializers.fields import UploadedFileField from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.base.models import ( Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation, @@ -113,6 +114,9 @@ class ItemSerializer(I18nAwareModelSerializer): variations = InlineItemVariationSerializer(many=True, required=False) tax_rate = ItemTaxRateField(source='*', read_only=True) meta_data = MetaDataField(required=False, source='*') + picture = UploadedFileField(required=False, allow_null=True, allowed_types=( + 'image/png', 'image/jpeg', 'image/gif' + ), max_size=10 * 1024 * 1024) class Meta: model = Item @@ -123,7 +127,7 @@ class ItemSerializer(I18nAwareModelSerializer): 'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations', 'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets', 'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data') - read_only_fields = ('has_variations', 'picture') + read_only_fields = ('has_variations',) def validate(self, data): data = super().validate(data) From 5b81507600ddb4fafe405d0c49d4623cb248d3e4 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 7 Jan 2021 09:56:08 +0100 Subject: [PATCH 3/5] API: Allow to use uploaded files in settings fields --- src/pretix/api/serializers/event.py | 53 ++++++-------- src/pretix/api/serializers/fields.py | 9 ++- src/pretix/api/serializers/organizer.py | 49 +++++-------- src/pretix/api/serializers/settings.py | 77 ++++++++++++++++++++ src/pretix/api/urls.py | 4 +- src/pretix/api/views/event.py | 15 +++- src/pretix/api/views/organizer.py | 12 ++- src/pretix/base/settings.py | 97 +++++++++++++++++++++++-- src/pretix/control/forms/event.py | 44 ++--------- src/tests/api/test_events.py | 72 ++++++++++++++++++ src/tests/api/test_organizers.py | 72 ++++++++++++++++++ 11 files changed, 381 insertions(+), 123 deletions(-) create mode 100644 src/pretix/api/serializers/settings.py diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 8dd21497c..a46066bb4 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -1,25 +1,29 @@ +import logging + from django.conf import settings from django.core.exceptions import ValidationError from django.db import transaction +from django.utils.crypto import get_random_string from django.utils.functional import cached_property from django.utils.translation import gettext as _ from django_countries.serializers import CountryFieldMixin -from hierarkey.proxy import HierarkeyProxy from pytz import common_timezones -from rest_framework import serializers from rest_framework.fields import ChoiceField, Field from rest_framework.relations import SlugRelatedField from pretix.api.serializers.i18n import I18nAwareModelSerializer +from pretix.api.serializers.settings import SettingsSerializer from pretix.base.models import Event, TaxRule from pretix.base.models.event import SubEvent from pretix.base.models.items import SubEventItem, SubEventItemVariation from pretix.base.services.seating import ( SeatProtected, generate_seats, validate_plan_change, ) -from pretix.base.settings import DEFAULTS, validate_event_settings +from pretix.base.settings import validate_event_settings from pretix.base.signals import api_event_settings_fields +logger = logging.getLogger(__name__) + class MetaDataField(Field): @@ -558,7 +562,7 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer): fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country') -class EventSettingsSerializer(serializers.Serializer): +class EventSettingsSerializer(SettingsSerializer): default_fields = [ 'imprint_url', 'checkout_email_helptext', @@ -654,6 +658,7 @@ class EventSettingsSerializer(serializers.Serializer): 'invoice_additional_text', 'invoice_footer_text', 'invoice_eu_currencies', + 'invoice_logo_image', 'cancel_allow_user', 'cancel_allow_user_until', 'cancel_allow_user_paid', @@ -674,45 +679,21 @@ class EventSettingsSerializer(serializers.Serializer): 'theme_color_background', 'theme_round_borders', 'primary_font', + 'logo_image', + 'logo_image_large', + 'logo_show_title', + 'og_image', ] def __init__(self, *args, **kwargs): self.event = kwargs.pop('event') - self.changed_data = [] super().__init__(*args, **kwargs) - for fname in self.default_fields: - kwargs = DEFAULTS[fname].get('serializer_kwargs', {}) - if callable(kwargs): - kwargs = kwargs() - kwargs.setdefault('required', False) - kwargs.setdefault('allow_null', True) - form_kwargs = DEFAULTS[fname].get('form_kwargs', {}) - if callable(form_kwargs): - form_kwargs = form_kwargs() - if 'serializer_class' not in DEFAULTS[fname]: - raise ValidationError('{} has no serializer class'.format(fname)) - f = DEFAULTS[fname]['serializer_class']( - **kwargs - ) - f._label = form_kwargs.get('label', fname) - f._help_text = form_kwargs.get('help_text') - self.fields[fname] = f for recv, resp in api_event_settings_fields.send(sender=self.event): for fname, field in resp.items(): field.required = False self.fields[fname] = field - def update(self, instance: HierarkeyProxy, validated_data): - for attr, value in validated_data.items(): - if value is None: - instance.delete(attr) - self.changed_data.append(attr) - elif instance.get(attr, as_type=type(value)) != value: - instance.set(attr, value) - self.changed_data.append(attr) - return instance - def validate(self, data): data = super().validate(data) settings_dict = self.instance.freeze() @@ -720,6 +701,14 @@ class EventSettingsSerializer(serializers.Serializer): validate_event_settings(self.event, settings_dict) return data + def get_new_filename(self, name: str) -> str: + nonce = get_random_string(length=8) + fname = '%s/%s/%s.%s.%s' % ( + self.event.organizer.slug, self.event.slug, name.split('/')[-1], nonce, name.split('.')[-1] + ) + # TODO: make sure pub is always correct + return 'pub/' + fname + class DeviceEventSettingsSerializer(EventSettingsSerializer): default_fields = [ diff --git a/src/pretix/api/serializers/fields.py b/src/pretix/api/serializers/fields.py index f23a7ad82..01440cb9b 100644 --- a/src/pretix/api/serializers/fields.py +++ b/src/pretix/api/serializers/fields.py @@ -1,5 +1,6 @@ from collections import OrderedDict +from django.core.exceptions import ValidationError from rest_framework import serializers @@ -52,6 +53,8 @@ class UploadedFileField(serializers.Field): file__isnull=False, pk=data[len("file:"):], ) + except (ValidationError, IndexError): # invalid uuid + self.fail('not_found') except CachedFile.DoesNotExist: self.fail('not_found') @@ -70,7 +73,5 @@ class UploadedFileField(serializers.Field): url = value.url except AttributeError: return None - request = self.context.get('request', None) - if request is not None: - return request.build_absolute_uri(url) - return url + request = self.context['request'] + return request.build_absolute_uri(url) diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index c4d65c573..b1189bf04 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -1,13 +1,15 @@ +import logging from decimal import Decimal from django.db.models import Q +from django.utils.crypto import get_random_string from django.utils.translation import gettext_lazy as _ -from hierarkey.proxy import HierarkeyProxy from rest_framework import serializers from rest_framework.exceptions import ValidationError from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.order import CompatibleJSONField +from pretix.api.serializers.settings import SettingsSerializer from pretix.base.auth import get_auth_backends from pretix.base.i18n import get_language_without_region from pretix.base.models import ( @@ -16,9 +18,11 @@ from pretix.base.models import ( ) from pretix.base.models.seating import SeatingPlanLayoutValidator from pretix.base.services.mail import SendMailException, mail -from pretix.base.settings import DEFAULTS, validate_organizer_settings +from pretix.base.settings import validate_organizer_settings from pretix.helpers.urls import build_absolute_uri +logger = logging.getLogger(__name__) + class OrganizerSerializer(I18nAwareModelSerializer): class Meta: @@ -207,7 +211,7 @@ class TeamMemberSerializer(serializers.ModelSerializer): ) -class OrganizerSettingsSerializer(serializers.Serializer): +class OrganizerSettingsSerializer(SettingsSerializer): default_fields = [ 'organizer_info_text', 'event_list_type', @@ -225,40 +229,13 @@ class OrganizerSettingsSerializer(serializers.Serializer): 'theme_color_danger', 'theme_color_background', 'theme_round_borders', - 'primary_font' + 'primary_font', + 'organizer_logo_image' ] def __init__(self, *args, **kwargs): self.organizer = kwargs.pop('organizer') - self.changed_data = [] super().__init__(*args, **kwargs) - for fname in self.default_fields: - kwargs = DEFAULTS[fname].get('serializer_kwargs', {}) - if callable(kwargs): - kwargs = kwargs() - kwargs.setdefault('required', False) - kwargs.setdefault('allow_null', True) - form_kwargs = DEFAULTS[fname].get('form_kwargs', {}) - if callable(form_kwargs): - form_kwargs = form_kwargs() - if 'serializer_class' not in DEFAULTS[fname]: - raise ValidationError('{} has no serializer class'.format(fname)) - f = DEFAULTS[fname]['serializer_class']( - **kwargs - ) - f._label = form_kwargs.get('label', fname) - f._help_text = form_kwargs.get('help_text') - self.fields[fname] = f - - def update(self, instance: HierarkeyProxy, validated_data): - for attr, value in validated_data.items(): - if value is None: - instance.delete(attr) - self.changed_data.append(attr) - elif instance.get(attr, as_type=type(value)) != value: - instance.set(attr, value) - self.changed_data.append(attr) - return instance def validate(self, data): data = super().validate(data) @@ -266,3 +243,11 @@ class OrganizerSettingsSerializer(serializers.Serializer): settings_dict.update(data) validate_organizer_settings(self.organizer, settings_dict) return data + + def get_new_filename(self, name: str) -> str: + nonce = get_random_string(length=8) + fname = '%s/%s.%s.%s' % ( + self.organizer.slug, name.split('/')[-1], nonce, name.split('.')[-1] + ) + # TODO: make sure pub is always correct + return 'pub/' + fname diff --git a/src/pretix/api/serializers/settings.py b/src/pretix/api/serializers/settings.py new file mode 100644 index 000000000..a4387798e --- /dev/null +++ b/src/pretix/api/serializers/settings.py @@ -0,0 +1,77 @@ +import logging + +from django.core.files import File +from django.core.files.storage import default_storage +from django.db.models.fields.files import FieldFile +from hierarkey.proxy import HierarkeyProxy +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from pretix.api.serializers.fields import UploadedFileField +from pretix.base.settings import DEFAULTS + +logger = logging.getLogger(__name__) + + +class SettingsSerializer(serializers.Serializer): + default_fields = [] + + def __init__(self, *args, **kwargs): + self.changed_data = [] + super().__init__(*args, **kwargs) + for fname in self.default_fields: + kwargs = DEFAULTS[fname].get('serializer_kwargs', {}) + if callable(kwargs): + kwargs = kwargs() + kwargs.setdefault('required', False) + kwargs.setdefault('allow_null', True) + form_kwargs = DEFAULTS[fname].get('form_kwargs', {}) + if callable(form_kwargs): + form_kwargs = form_kwargs() + if 'serializer_class' not in DEFAULTS[fname]: + raise ValidationError('{} has no serializer class'.format(fname)) + f = DEFAULTS[fname]['serializer_class']( + **kwargs + ) + f._label = form_kwargs.get('label', fname) + f._help_text = form_kwargs.get('help_text') + f.parent = self + self.fields[fname] = f + + def update(self, instance: HierarkeyProxy, validated_data): + for attr, value in validated_data.items(): + if isinstance(value, FieldFile): + # Delete old file + fname = instance.get(attr, as_type=File) + if fname: + try: + default_storage.delete(fname.name) + except OSError: # pragma: no cover + logger.error('Deleting file %s failed.' % fname.name) + + # Create new file + newname = default_storage.save(self.get_new_filename(value.name), value) + instance.set(attr, File(file=value, name=newname)) + self.changed_data.append(attr) + elif isinstance(self.fields[attr], UploadedFileField): + if value is None: + fname = instance.get(attr, as_type=File) + if fname: + try: + default_storage.delete(fname.name) + except OSError: # pragma: no cover + logger.error('Deleting file %s failed.' % fname.name) + instance.delete(attr) + else: + # file is unchanged + continue + elif value is None: + instance.delete(attr) + self.changed_data.append(attr) + elif instance.get(attr, as_type=type(value)) != value: + instance.set(attr, value) + self.changed_data.append(attr) + return instance + + def get_new_filename(self, name: str) -> str: + raise NotImplementedError() diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 6737a90ba..5875323fe 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -7,8 +7,8 @@ from rest_framework import routers from pretix.api.views import cart from .views import ( - checkin, device, event, exporters, item, oauth, order, organizer, user, - upload, version, voucher, waitinglist, webhooks, + checkin, device, event, exporters, item, oauth, order, organizer, upload, + user, version, voucher, waitinglist, webhooks, ) router = routers.DefaultRouter() diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index f6037ca5b..e7f1da7ad 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -365,9 +365,13 @@ class EventSettingsView(views.APIView): def get(self, request, *args, **kwargs): if isinstance(request.auth, Device): - s = DeviceEventSettingsSerializer(instance=request.event.settings, event=request.event) + s = DeviceEventSettingsSerializer(instance=request.event.settings, event=request.event, context={ + 'request': request + }) elif 'can_change_event_settings' in request.eventpermset: - s = EventSettingsSerializer(instance=request.event.settings, event=request.event) + s = EventSettingsSerializer(instance=request.event.settings, event=request.event, context={ + 'request': request + }) else: raise PermissionDenied() if 'explain' in request.GET: @@ -382,7 +386,7 @@ class EventSettingsView(views.APIView): def patch(self, request, *wargs, **kwargs): s = EventSettingsSerializer(instance=request.event.settings, data=request.data, partial=True, - event=request.event) + event=request.event, context={'request': request}) s.is_valid(raise_exception=True) with transaction.atomic(): s.save() @@ -393,5 +397,8 @@ class EventSettingsView(views.APIView): ) if any(p in s.changed_data for p in SETTINGS_AFFECTING_CSS): regenerate_css.apply_async(args=(request.organizer.pk,)) - s = EventSettingsSerializer(instance=request.event.settings, event=request.event) + s = EventSettingsSerializer( + instance=request.event.settings, event=request.event, context={ + 'request': request + }) return Response(s.data) diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index a87fcfa48..bafab9131 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -425,7 +425,9 @@ class OrganizerSettingsView(views.APIView): permission = 'can_change_organizer_settings' def get(self, request, *args, **kwargs): - s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer) + s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={ + 'request': request + }) if 'explain' in request.GET: return Response({ fname: { @@ -439,7 +441,9 @@ class OrganizerSettingsView(views.APIView): def patch(self, request, *wargs, **kwargs): s = OrganizerSettingsSerializer( instance=request.organizer.settings, data=request.data, partial=True, - organizer=request.organizer + organizer=request.organizer, context={ + 'request': request + } ) s.is_valid(raise_exception=True) with transaction.atomic(): @@ -451,5 +455,7 @@ class OrganizerSettingsView(views.APIView): ) if any(p in s.changed_data for p in SETTINGS_AFFECTING_CSS): regenerate_organizer_css.apply_async(args=(request.organizer.pk,)) - s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer) + s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={ + 'request': request + }) return Response(s.data) diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index e3e339ea4..9588a387f 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -21,7 +21,9 @@ from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput from i18nfield.strings import LazyI18nString from rest_framework import serializers -from pretix.api.serializers.fields import ListMultipleChoiceField +from pretix.api.serializers.fields import ( + ListMultipleChoiceField, UploadedFileField, +) from pretix.api.serializers.i18n import I18nField from pretix.base.models.tax import TaxRule from pretix.base.reldate import ( @@ -29,7 +31,7 @@ from pretix.base.reldate import ( SerializerRelativeDateField, SerializerRelativeDateTimeField, ) from pretix.control.forms import ( - FontSelect, MultipleLanguagesWidget, SingleLanguageWidget, + ExtFileField, FontSelect, MultipleLanguagesWidget, SingleLanguageWidget, ) from pretix.helpers.countries import CachedCountries @@ -1784,19 +1786,66 @@ Your {event} team""")) }, 'logo_image': { 'default': None, - 'type': File + 'type': File, + 'form_class': ExtFileField, + 'form_kwargs': dict( + label=_('Header image'), + ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"), + max_size=10 * 1024 * 1024, + help_text=_('If you provide a logo image, we will by default not show your event name and date ' + 'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You ' + 'can increase the size with the setting below. We recommend not using small details on the picture ' + 'as it will be resized on smaller screens.') + ), + 'serializer_class': UploadedFileField, + 'serializer_kwargs': dict( + allowed_types=[ + 'image/png', 'image/jpeg', 'image/gif' + ], + max_size=10 * 1024 * 1024, + ) + }, 'logo_image_large': { 'default': 'False', - 'type': bool + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_('Use header image in its full size'), + help_text=_('We recommend to upload a picture at least 1170 pixels wide.'), + ) }, 'logo_show_title': { 'default': 'True', - 'type': bool + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_('Show event title even if a header image is present'), + help_text=_('The title will only be shown on the event front page.'), + ) }, 'organizer_logo_image': { 'default': None, - 'type': File + 'type': File, + 'form_class': ExtFileField, + 'form_kwargs': dict( + label=_('Header image'), + ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"), + max_size=10 * 1024 * 1024, + help_text=_('If you provide a logo image, we will by default not show your organization name ' + 'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You ' + 'can increase the size with the setting below. We recommend not using small details on the picture ' + 'as it will be resized on smaller screens.') + ), + 'serializer_class': UploadedFileField, + 'serializer_kwargs': dict( + allowed_types=[ + 'image/png', 'image/jpeg', 'image/gif' + ], + max_size=10 * 1024 * 1024, + ) }, 'organizer_logo_image_large': { 'default': 'False', @@ -1810,11 +1859,43 @@ Your {event} team""")) }, 'og_image': { 'default': None, - 'type': File + 'type': File, + 'form_class': ExtFileField, + 'form_kwargs': dict( + label=_('Social media image'), + ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"), + max_size=10 * 1024 * 1024, + help_text=_('This picture will be used as a preview if you post links to your ticket shop on social media. ' + 'Facebook advises to use a picture size of 1200 x 630 pixels, however some platforms like ' + 'WhatsApp and Reddit only show a square preview, so we recommend to make sure it still looks good ' + 'only the center square is shown. If you do not fill this, we will use the logo given above.') + ), + 'serializer_class': UploadedFileField, + 'serializer_kwargs': dict( + allowed_types=[ + 'image/png', 'image/jpeg', 'image/gif' + ], + max_size=10 * 1024 * 1024, + ) }, 'invoice_logo_image': { 'default': None, - 'type': File + 'type': File, + 'form_class': ExtFileField, + 'form_kwargs': dict( + label=_('Logo image'), + ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"), + required=False, + max_size=10 * 1024 * 1024, + help_text=_('We will show your logo with a maximal height and width of 2.5 cm.') + ), + 'serializer_class': UploadedFileField, + 'serializer_kwargs': dict( + allowed_types=[ + 'image/png', 'image/jpeg', 'image/gif' + ], + max_size=10 * 1024 * 1024, + ) }, 'frontpage_text': { 'default': '', diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 8fcf6d90d..540b401de 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -27,7 +27,7 @@ from pretix.base.settings import ( PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings, ) from pretix.control.forms import ( - ExtFileField, MultipleLanguagesWidget, SlugWidget, SplitDateTimeField, + MultipleLanguagesWidget, SlugWidget, SplitDateTimeField, SplitDateTimePickerWidget, ) from pretix.control.forms.widgets import Select2 @@ -416,36 +416,6 @@ class EventSettingsForm(SettingsForm): "restrict the set of selectable titles."), required=False, ) - logo_image = ExtFileField( - label=_('Header image'), - ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"), - required=False, - max_size=10 * 1024 * 1024, - help_text=_('If you provide a logo image, we will by default not show your event name and date ' - 'in the page header. By default, we show your logo with a size of up to 1140x120 pixels. You ' - 'can increase the size with the setting below. We recommend not using small details on the picture ' - 'as it will be resized on smaller screens.') - ) - logo_image_large = forms.BooleanField( - label=_('Use header image in its full size'), - help_text=_('We recommend to upload a picture at least 1170 pixels wide.'), - required=False, - ) - logo_show_title = forms.BooleanField( - label=_('Show event title even if a header image is present'), - help_text=_('The title will only be shown on the event front page.'), - required=False, - ) - og_image = ExtFileField( - label=_('Social media image'), - ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"), - required=False, - max_size=10 * 1024 * 1024, - help_text=_('This picture will be used as a preview if you post links to your ticket shop on social media. ' - 'Facebook advises to use a picture size of 1200 x 630 pixels, however some platforms like ' - 'WhatsApp and Reddit only show a square preview, so we recommend to make sure it still looks good ' - 'only the center square is shown. If you do not fill this, we will use the logo given above.') - ) auto_fields = [ 'imprint_url', @@ -498,6 +468,10 @@ class EventSettingsForm(SettingsForm): 'theme_color_background', 'theme_round_borders', 'primary_font', + 'logo_image', + 'logo_image_large', + 'logo_show_title', + 'og_image', ] def clean(self): @@ -733,6 +707,7 @@ class InvoiceSettingsForm(SettingsForm): 'invoice_additional_text', 'invoice_footer_text', 'invoice_eu_currencies', + 'invoice_logo_image', ] invoice_generate_sales_channels = forms.MultipleChoiceField( @@ -752,13 +727,6 @@ class InvoiceSettingsForm(SettingsForm): label=_("Invoice language"), choices=[('__user__', _('The user\'s language'))] + settings.LANGUAGES, ) - invoice_logo_image = ExtFileField( - label=_('Logo image'), - ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"), - required=False, - max_size=10 * 1024 * 1024, - help_text=_('We will show your logo with a maximal height and width of 2.5 cm.') - ) def __init__(self, *args, **kwargs): event = kwargs.get('obj') diff --git a/src/tests/api/test_events.py b/src/tests/api/test_events.py index 5e61cd280..f3661b3f9 100644 --- a/src/tests/api/test_events.py +++ b/src/tests/api/test_events.py @@ -5,6 +5,7 @@ from unittest import mock import pytest from django.conf import settings +from django.core.files.base import ContentFile from django_countries.fields import Country from django_scopes import scopes_disabled from pytz import UTC @@ -1105,3 +1106,74 @@ def test_patch_event_settings_validation(token_client, organizer, event): assert resp.data == { 'cancel_allow_user_until': ['Invalid relative date'] } + + +@pytest.mark.django_db +def test_patch_event_settings_file(token_client, organizer, event): + r = token_client.post( + '/api/v1/upload', + data={ + 'media_type': 'image/png', + 'file': ContentFile('file.png', 'invalid png content') + }, + format='upload', + HTTP_CONTENT_DISPOSITION='attachment; filename="file.png"', + ) + assert r.status_code == 201 + file_id_png = r.data['id'] + + 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 + file_id_pdf = r.data['id'] + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), + { + 'logo_image': 'invalid' + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.data == { + 'logo_image': ['The submitted file ID was not found.'] + } + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), + { + 'logo_image': file_id_pdf + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.data == { + 'logo_image': ['The submitted file has a file type that is not allowed in this field.'] + } + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), + { + 'logo_image': file_id_png + }, + format='json' + ) + assert resp.status_code == 200 + assert resp.data['logo_image'].startswith('http') + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), + { + 'logo_image': None + }, + format='json' + ) + assert resp.status_code == 200 + assert resp.data['logo_image'] is None diff --git a/src/tests/api/test_organizers.py b/src/tests/api/test_organizers.py index f13f25caf..e8f984bfb 100644 --- a/src/tests/api/test_organizers.py +++ b/src/tests/api/test_organizers.py @@ -1,4 +1,5 @@ import pytest +from django.core.files.base import ContentFile from pretix.testutils.mock import mocker_context @@ -100,3 +101,74 @@ def test_patch_settings(token_client, organizer): ) assert resp.status_code == 200 mocked.assert_any_call(args=(organizer.pk,)) + + +@pytest.mark.django_db +def test_patch_organizer_settings_file(token_client, organizer): + r = token_client.post( + '/api/v1/upload', + data={ + 'media_type': 'image/png', + 'file': ContentFile('file.png', 'invalid png content') + }, + format='upload', + HTTP_CONTENT_DISPOSITION='attachment; filename="file.png"', + ) + assert r.status_code == 201 + file_id_png = r.data['id'] + + 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 + file_id_pdf = r.data['id'] + + resp = token_client.patch( + '/api/v1/organizers/{}/settings/'.format(organizer.slug), + { + 'organizer_logo_image': 'invalid' + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.data == { + 'organizer_logo_image': ['The submitted file ID was not found.'] + } + + resp = token_client.patch( + '/api/v1/organizers/{}/settings/'.format(organizer.slug), + { + 'organizer_logo_image': file_id_pdf + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.data == { + 'organizer_logo_image': ['The submitted file has a file type that is not allowed in this field.'] + } + + resp = token_client.patch( + '/api/v1/organizers/{}/settings/'.format(organizer.slug,), + { + 'organizer_logo_image': file_id_png + }, + format='json' + ) + assert resp.status_code == 200 + assert resp.data['organizer_logo_image'].startswith('http') + + resp = token_client.patch( + '/api/v1/organizers/{}/settings/'.format(organizer.slug), + { + 'organizer_logo_image': None + }, + format='json' + ) + assert resp.status_code == 200 + assert resp.data['organizer_logo_image'] is None From 01c3b085836240c221e5a0b3698c15b300e43f82 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 7 Jan 2021 10:16:47 +0100 Subject: [PATCH 4/5] API: Allow to answer file upload questions during ticket redemption --- src/pretix/api/views/checkin.py | 29 +++++++++++++++++-- src/pretix/base/models/items.py | 3 +- src/pretix/base/services/checkin.py | 9 ++++++ src/tests/api/test_checkin.py | 44 +++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index 20f51e74d..75919b1cb 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -22,7 +22,7 @@ from pretix.api.views import RichOrderingFilter from pretix.api.views.order import OrderPositionFilter from pretix.base.i18n import language from pretix.base.models import ( - Checkin, CheckinList, Event, Order, OrderPosition, + CachedFile, Checkin, CheckinList, Event, Order, OrderPosition, Question, ) from pretix.base.services.checkin import ( CheckInError, RequiredQuestionsError, perform_checkin, @@ -302,7 +302,10 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet): for q in op.item.questions.filter(ask_during_checkin=True): if str(q.pk) in aws: try: - given_answers[q] = q.clean_answer(aws[str(q.pk)]) + if q.type == Question.TYPE_FILE: + given_answers[q] = self._handle_file_upload(aws[str(q.pk)]) + else: + given_answers[q] = q.clean_answer(aws[str(q.pk)]) except ValidationError: pass @@ -352,3 +355,25 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet): 'require_attention': op.item.checkin_attention or op.order.checkin_attention, 'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data }, status=201) + + def _handle_file_upload(self, data): + try: + cf = CachedFile.objects.get( + session_key=f'api-upload-{str(type(self.request.user or self.request.auth))}-{(self.request.user or self.request.auth).pk}', + file__isnull=False, + pk=data[len("file:"):], + ) + except (ValidationError, IndexError): # invalid uuid + raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data)) + except CachedFile.DoesNotExist: + raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data)) + + allowed_types = ( + 'image/png', 'image/jpeg', 'image/gif', 'application/pdf' + ) + if cf.type not in allowed_types: + raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data)) + if cf.file.size > 10 * 1024 * 1024: + raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data)) + + return cf.file diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index cab110df8..dddc76bd6 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -1026,7 +1026,7 @@ class Question(LoggedModel): (TYPE_PHONENUMBER, _("Phone number")), ) UNLOCALIZED_TYPES = [TYPE_DATE, TYPE_TIME, TYPE_DATETIME] - ASK_DURING_CHECKIN_UNSUPPORTED = [TYPE_FILE, TYPE_PHONENUMBER] + ASK_DURING_CHECKIN_UNSUPPORTED = [TYPE_PHONENUMBER] event = models.ForeignKey( Event, @@ -1069,6 +1069,7 @@ class Question(LoggedModel): ) ask_during_checkin = models.BooleanField( verbose_name=_('Ask during check-in instead of in the ticket buying process'), + help_text=_('Not supported by all check-in apps for all question types.'), default=False ) hidden = models.BooleanField( diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index e106e2e07..903f529d2 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -1,6 +1,7 @@ from datetime import timedelta import dateutil +from django.core.files import File from django.db import transaction from django.db.models.functions import TruncDate from django.dispatch import receiver @@ -125,6 +126,14 @@ def _save_answers(op, answers, given_answers): else: qa = op.answers.create(question=q, answer=", ".join([str(o) for o in a])) qa.options.add(*a) + elif isinstance(a, File): + if q in answers: + qa = answers[q] + else: + qa = op.answers.create(question=q, answer=str(a)) + qa.file.save(a.name, a, save=False) + qa.answer = 'file://' + qa.file.name + qa.save() else: if q in answers: qa = answers[q] diff --git a/src/tests/api/test_checkin.py b/src/tests/api/test_checkin.py index 367ef0f02..c6e486459 100644 --- a/src/tests/api/test_checkin.py +++ b/src/tests/api/test_checkin.py @@ -7,6 +7,7 @@ from unittest import mock from urllib.parse import quote as urlquote import pytest +from django.core.files.base import ContentFile from django.utils.timezone import now from django_countries.fields import Country from django_scopes import scopes_disabled @@ -995,3 +996,46 @@ def test_question_multiple_choice(token_client, organizer, clist, event, order, with scopes_disabled(): assert order.positions.first().answers.get(question=question[0]).answer == 'M, L' assert set(order.positions.first().answers.get(question=question[0]).options.all()) == {question[1], question[2]} + + +@pytest.mark.django_db +def test_question_upload(token_client, organizer, clist, event, order, question): + r = token_client.post( + '/api/v1/upload', + data={ + 'media_type': 'image/png', + 'file': ContentFile('file.png', 'invalid png content') + }, + format='upload', + HTTP_CONTENT_DISPOSITION='attachment; filename="file.png"', + ) + assert r.status_code == 201 + file_id_png = r.data['id'] + + with scopes_disabled(): + p = order.positions.first() + question[0].type = 'F' + question[0].save() + + resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format( + organizer.slug, event.slug, clist.pk, p.pk + ), {}, format='json') + assert resp.status_code == 400 + assert resp.data['status'] == 'incomplete' + with scopes_disabled(): + assert resp.data['questions'] == [QuestionSerializer(question[0]).data] + + resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format( + organizer.slug, event.slug, clist.pk, p.pk + ), {'answers': {question[0].pk: "invalid"}}, format='json') + assert resp.status_code == 400 + assert resp.data['status'] == 'incomplete' + + resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format( + organizer.slug, event.slug, clist.pk, p.pk + ), {'answers': {question[0].pk: file_id_png}}, format='json') + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + with scopes_disabled(): + assert order.positions.first().answers.get(question=question[0]).answer.startswith('file://') + assert order.positions.first().answers.get(question=question[0]).file From 2e5861958de351462318488582a9f973ddcee4cc Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 7 Jan 2021 12:41:41 +0100 Subject: [PATCH 5/5] API: Fix CSS generation after change in event settings --- src/pretix/api/views/event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index e7f1da7ad..8c36577c5 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -396,7 +396,7 @@ class EventSettingsView(views.APIView): } ) if any(p in s.changed_data for p in SETTINGS_AFFECTING_CSS): - regenerate_css.apply_async(args=(request.organizer.pk,)) + regenerate_css.apply_async(args=(request.event.pk,)) s = EventSettingsSerializer( instance=request.event.settings, event=request.event, context={ 'request': request