diff --git a/doc/api/resources/organizers.rst b/doc/api/resources/organizers.rst index 40f2a887a5..72cdfc8f0e 100644 --- a/doc/api/resources/organizers.rst +++ b/doc/api/resources/organizers.rst @@ -90,3 +90,120 @@ Endpoints :statuscode 200: no error :statuscode 401: Authentication failure :statuscode 403: The requested organizer does not exist **or** you have no permission to view it. + +Organizer settings +------------------ + +pretix organizers and events have lots and lots of parameters of different types that are stored in a key-value store on our system. +Since many of these settings depend on each other in complex ways, we can not give direct access to all of these +settings through the API. However, we do expose many of the simple and useful flags through the API. + +Please note that the available settings flags change between pretix versions, and we do not give a guarantee on backwards-compatibility like with other parts of the API. +Therefore, we're also not including a list of the options here, but instead recommend to look at the endpoint output +to see available options. The ``explain=true`` flag enables a verbose mode that provides you with human-readable +information about the properties. + +.. note:: Please note that this is not a complete representation of all organizer settings. You will find more settings + in the web interface. + +.. warning:: This API is intended for advanced users. Even though we take care to validate your input, you will be + able to break your shops using this API by creating situations of conflicting settings. Please take care. + +.. versionchanged:: 3.14 + + Initial support for settings has been added to the API. + +.. http:get:: /api/v1/organizers/(organizer)/settings/ + + Get current values of organizer settings. + + Permission required: "Can change organizer settings" + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/settings/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example standard response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "event_list_type": "calendar", + … + } + + **Example verbose response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "event_list_type": + { + "value": "calendar", + "label": "Default overview style", + "help_text": "If your event series has more than 50 dates in the future, only the month or week calendar can be used." + } + }, + … + } + + :param organizer: The ``slug`` field of the organizer to access + :query explain: Set to ``true`` to enable verbose response mode + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + +.. http:patch:: /api/v1/organizers/(organizer)/settings/ + + Updates organizer settings. Note that ``PUT`` is not allowed here, only ``PATCH``. + + .. warning:: + + Settings can be stored at different levels in pretix. If a value is not set on organizer level, a default setting + from a higher level (global) will be returned. If you explicitly set a setting on organizer level, it + will no longer be inherited from the higher levels. Therefore, we recommend you to send only settings that you + explicitly want to set on organizer level. To unset a settings, pass ``null``. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/settings/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "event_list_type": "calendar" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "event_list_type": "calendar", + … + } + + :param organizer: The ``slug`` field of the organizer to update + :statuscode 200: no error + :statuscode 400: The organizer could not be updated due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource. diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index ba71833a88..682fa59edd 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -661,10 +661,17 @@ class EventSettingsSerializer(serializers.Serializer): 'change_allow_user_variation', 'change_allow_user_until', 'change_allow_user_price', + 'primary_color', + 'theme_color_success', + 'theme_color_danger', + 'theme_color_background', + 'theme_round_borders', + 'primary_font', ] 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', {}) @@ -693,8 +700,10 @@ class EventSettingsSerializer(serializers.Serializer): 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): diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index 24692ab824..d023675bd1 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -2,6 +2,7 @@ from decimal import Decimal from django.db.models import Q from django.utils.translation import get_language, gettext_lazy as _ +from hierarkey.proxy import HierarkeyProxy from rest_framework import serializers from rest_framework.exceptions import ValidationError @@ -14,6 +15,7 @@ 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_settings from pretix.helpers.urls import build_absolute_uri @@ -202,3 +204,63 @@ class TeamMemberSerializer(serializers.ModelSerializer): fields = ( 'id', 'email', 'fullname', 'require_2fa' ) + + +class OrganizerSettingsSerializer(serializers.Serializer): + default_fields = [ + 'organizer_info_text', + 'event_list_type', + 'event_list_availability', + 'organizer_homepage_text', + 'organizer_link_back', + 'organizer_logo_image_large', + 'giftcard_length', + 'giftcard_expiry_years', + 'locales', + 'event_team_provisioning', + 'primary_color', + 'theme_color_success', + 'theme_color_danger', + 'theme_color_background', + 'theme_round_borders', + 'primary_font' + ] + + 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) + settings_dict = self.instance.freeze() + settings_dict.update(data) + validate_settings(self.organizer, settings_dict) + return data diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 7cc16669ca..d2d49e69ad 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -74,6 +74,8 @@ for app in apps.get_app_configs(): urlpatterns = [ url(r'^', include(router.urls)), url(r'^organizers/(?P[^/]+)/', include(orga_router.urls)), + url(r'^organizers/(?P[^/]+)/settings/$', organizer.OrganizerSettingsView.as_view(), + name="organizer.settings"), url(r'^organizers/(?P[^/]+)/giftcards/(?P[^/]+)/', include(giftcard_router.urls)), url(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/settings/$', event.EventSettingsView.as_view(), name="event.settings"), diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index 83d4e07857..c04dba2629 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -18,7 +18,9 @@ from pretix.base.models import ( CartPosition, Device, Event, TaxRule, TeamAPIToken, ) from pretix.base.models.event import SubEvent +from pretix.base.settings import SETTINGS_AFFECTING_CSS from pretix.helpers.dicts import merge_dicts +from pretix.presale.style import regenerate_css from pretix.presale.views.organizer import filter_qs_by_attr with scopes_disabled(): @@ -385,5 +387,7 @@ class EventSettingsView(views.APIView): k: v for k, v in s.validated_data.items() } ) + 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) return Response(s.data) diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index d33007cbfb..a87fcfa48a 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -6,7 +6,9 @@ from django.shortcuts import get_object_or_404 from django.utils.functional import cached_property from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_scopes import scopes_disabled -from rest_framework import filters, mixins, serializers, status, viewsets +from rest_framework import ( + filters, mixins, serializers, status, views, viewsets, +) from rest_framework.decorators import action from rest_framework.exceptions import MethodNotAllowed, PermissionDenied from rest_framework.mixins import CreateModelMixin, DestroyModelMixin @@ -16,14 +18,17 @@ from rest_framework.viewsets import GenericViewSet from pretix.api.models import OAuthAccessToken from pretix.api.serializers.organizer import ( DeviceSerializer, GiftCardSerializer, GiftCardTransactionSerializer, - OrganizerSerializer, SeatingPlanSerializer, TeamAPITokenSerializer, - TeamInviteSerializer, TeamMemberSerializer, TeamSerializer, + OrganizerSerializer, OrganizerSettingsSerializer, SeatingPlanSerializer, + TeamAPITokenSerializer, TeamInviteSerializer, TeamMemberSerializer, + TeamSerializer, ) from pretix.base.models import ( Device, GiftCard, GiftCardTransaction, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite, User, ) +from pretix.base.settings import SETTINGS_AFFECTING_CSS from pretix.helpers.dicts import merge_dicts +from pretix.presale.style import regenerate_organizer_css class OrganizerViewSet(viewsets.ReadOnlyModelViewSet): @@ -414,3 +419,37 @@ class DeviceViewSet(mixins.CreateModelMixin, data=self.request.data ) return inst + + +class OrganizerSettingsView(views.APIView): + permission = 'can_change_organizer_settings' + + def get(self, request, *args, **kwargs): + s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer) + if 'explain' in request.GET: + return Response({ + fname: { + 'value': s.data[fname], + 'label': getattr(field, '_label', fname), + 'help_text': getattr(field, '_help_text', None) + } for fname, field in s.fields.items() + }) + return Response(s.data) + + def patch(self, request, *wargs, **kwargs): + s = OrganizerSettingsSerializer( + instance=request.organizer.settings, data=request.data, partial=True, + organizer=request.organizer + ) + s.is_valid(raise_exception=True) + with transaction.atomic(): + s.save() + self.request.organizer.log_action( + 'pretix.organizer.settings', user=self.request.user, auth=self.request.auth, data={ + k: v for k, v in s.validated_data.items() + } + ) + 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) + return Response(s.data) diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 1353dbd73d..254d692f7d 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -9,7 +9,9 @@ from django import forms from django.conf import settings from django.core.exceptions import ValidationError from django.core.files import File -from django.core.validators import MaxValueValidator, MinValueValidator +from django.core.validators import ( + MaxValueValidator, MinValueValidator, RegexValidator, +) from django.db.models import Model from django.utils.translation import ( gettext_lazy as _, gettext_noop, pgettext, pgettext_lazy, @@ -26,7 +28,9 @@ from pretix.base.reldate import ( RelativeDateField, RelativeDateTimeField, RelativeDateWrapper, SerializerRelativeDateField, SerializerRelativeDateTimeField, ) -from pretix.control.forms import MultipleLanguagesWidget, SingleLanguageWidget +from pretix.control.forms import ( + FontSelect, MultipleLanguagesWidget, SingleLanguageWidget, +) from pretix.helpers.countries import CachedCountries @@ -38,6 +42,18 @@ def country_choice_kwargs(): } +def primary_font_kwargs(): + from pretix.presale.style import get_fonts + + choices = [('Open Sans', 'Open Sans')] + choices += [ + (a, {"title": a, "data": v}) for a, v in get_fonts().items() + ] + return { + 'choices': choices, + } + + class LazyI18nStringList(UserList): def __init__(self, init_list=None): super().__init__() @@ -252,7 +268,6 @@ DEFAULTS = { 'form_kwargs': dict( label=_("Ask for beneficiary"), widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}), - required=False ) }, 'invoice_address_custom_field': { @@ -421,7 +436,6 @@ DEFAULTS = { widget_kwargs={'attrs': { 'rows': 3, }}, - required=False, label=_("Guidance text"), help_text=_("This text will be shown above the payment options. You can explain the choices to the user here, " "if you want.") @@ -441,7 +455,6 @@ DEFAULTS = { 'form_kwargs': dict( label=_("Set payment term"), widget=forms.RadioSelect, - required=True, choices=( ('days', _("in days")), ('minutes', _("in minutes")) @@ -989,7 +1002,16 @@ DEFAULTS = { }, 'event_list_availability': { 'default': 'True', - 'type': bool + 'type': bool, + 'serializer_class': serializers.BooleanField, + 'form_class': forms.BooleanField, + 'form_kwargs': dict( + label=_('Show availability in event overviews'), + help_text=_('If checked, the list of events will show if events are sold out. This might ' + 'make for longer page loading times if you have lots of events and the shown status might be out ' + 'of date for up to two minutes.'), + required=False + ) }, 'event_list_type': { 'default': 'list', @@ -1598,26 +1620,106 @@ Your {event} team""")) 'primary_color': { 'default': settings.PRETIX_PRIMARY_COLOR, 'type': str, + 'form_class': forms.CharField, + 'serializer_class': serializers.CharField, + 'serializer_kwargs': dict( + validators=[ + RegexValidator(regex='^#[0-9a-fA-F]{6}$', + message=_('Please enter the hexadecimal code of a color, e.g. #990000.')), + ], + ), + 'form_kwargs': dict( + label=_("Primary color"), + validators=[ + RegexValidator(regex='^#[0-9a-fA-F]{6}$', + message=_('Please enter the hexadecimal code of a color, e.g. #990000.')), + ], + widget=forms.TextInput(attrs={'class': 'colorpickerfield'}) + ), }, 'theme_color_success': { 'default': '#50A167', - 'type': str + 'type': str, + 'form_class': forms.CharField, + 'serializer_class': serializers.CharField, + 'serializer_kwargs': dict( + validators=[ + RegexValidator(regex='^#[0-9a-fA-F]{6}$', + message=_('Please enter the hexadecimal code of a color, e.g. #990000.')), + ], + ), + 'form_kwargs': dict( + label=_("Accent color for success"), + help_text=_("We strongly suggest to use a shade of green."), + validators=[ + RegexValidator(regex='^#[0-9a-fA-F]{6}$', + message=_('Please enter the hexadecimal code of a color, e.g. #990000.')), + ], + widget=forms.TextInput(attrs={'class': 'colorpickerfield'}) + ), }, 'theme_color_danger': { 'default': '#D36060', - 'type': str + 'type': str, + 'form_class': forms.CharField, + 'serializer_class': serializers.CharField, + 'serializer_kwargs': dict( + validators=[ + RegexValidator(regex='^#[0-9a-fA-F]{6}$', + message=_('Please enter the hexadecimal code of a color, e.g. #990000.')), + ], + ), + 'form_kwargs': dict( + label=_("Accent color for errors"), + help_text=_("We strongly suggest to use a shade of red."), + validators=[ + RegexValidator(regex='^#[0-9a-fA-F]{6}$', + message=_('Please enter the hexadecimal code of a color, e.g. #990000.')), + ], + widget=forms.TextInput(attrs={'class': 'colorpickerfield'}) + ), }, 'theme_color_background': { 'default': '#FFFFFF', - 'type': str + 'type': str, + 'form_class': forms.CharField, + 'serializer_class': serializers.CharField, + 'serializer_kwargs': dict( + validators=[ + RegexValidator(regex='^#[0-9a-fA-F]{6}$', + message=_('Please enter the hexadecimal code of a color, e.g. #990000.')), + ], + ), + 'form_kwargs': dict( + label=_("Page background color"), + validators=[ + RegexValidator(regex='^#[0-9a-fA-F]{6}$', + message=_('Please enter the hexadecimal code of a color, e.g. #990000.')), + ], + widget=forms.TextInput(attrs={'class': 'colorpickerfield no-contrast'}) + ), }, 'theme_round_borders': { 'default': 'True', - 'type': bool + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Use round edges"), + ) }, 'primary_font': { 'default': 'Open Sans', - 'type': str + 'type': str, + 'form_class': forms.ChoiceField, + 'serializer_class': serializers.ChoiceField, + 'serializer_kwargs': lambda: dict(**primary_font_kwargs()), + 'form_kwargs': lambda: dict( + label=_('Font'), + help_text=_('Only respected by modern browsers.'), + widget=FontSelect, + **primary_font_kwargs() + ), }, 'presale_css_file': { 'default': None, @@ -1653,7 +1755,13 @@ Your {event} team""")) }, 'organizer_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.'), + ) }, 'og_image': { 'default': None, @@ -1732,11 +1840,26 @@ Your {event} team""")) }, 'organizer_info_text': { 'default': '', - 'type': LazyI18nString + 'type': LazyI18nString, + 'serializer_class': I18nField, + 'form_class': I18nFormField, + 'form_kwargs': dict( + label=_('Info text'), + widget=I18nTextarea, + help_text=_('Not displayed anywhere by default, but if you want to, you can use this e.g. in ticket templates.') + ) }, 'event_team_provisioning': { 'default': 'True', - 'type': bool + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_('Allow creating a new team during event creation'), + help_text=_('Users that do not have access to all events under this organizer, must select one of their teams ' + 'to have access to the created event. This setting allows users to create an event-specified team' + ' on-the-fly, even when they do not have \"Can change teams and permissions\" permission.'), + ) }, 'update_check_ack': { 'default': 'False', @@ -1810,13 +1933,51 @@ Your {event} team""")) # When adding a new ordering, remember to also define it in the event model ) }, + 'organizer_link_back': { + 'default': 'False', + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_('Link back to organizer overview on all event pages'), + ) + }, + 'organizer_homepage_text': { + 'default': '', + 'type': LazyI18nString, + 'serializer_class': I18nField, + 'form_class': I18nFormField, + 'form_kwargs': dict( + label=_('Homepage text'), + widget=I18nTextarea, + help_text=_('This will be displayed on the organizer homepage.') + ) + }, 'name_scheme': { 'default': 'full', 'type': str }, 'giftcard_length': { 'default': settings.ENTROPY['giftcard_secret'], - 'type': int + 'type': int, + 'form_class': forms.IntegerField, + 'serializer_class': serializers.IntegerField, + 'form_kwargs': dict( + label=_('Length of gift card codes'), + help_text=_('The system generates by default {}-character long gift card codes. However, if a different length ' + 'is required, it can be set here.'.format(settings.ENTROPY['giftcard_secret'])), + ) + }, + 'giftcard_expiry_years': { + 'default': None, + 'type': int, + 'form_class': forms.IntegerField, + 'serializer_class': serializers.IntegerField, + 'form_kwargs': dict( + label=_('Validity of gift card codes in years'), + help_text=_('If you set a number here, gift cards will by default expire at the end of the year after this ' + 'many years. If you keep it empty, gift cards do not have an explicit expiry date.'), + ) }, 'seating_choice': { 'default': 'True', @@ -1852,6 +2013,10 @@ Your {event} team""")) ), } } +SETTINGS_AFFECTING_CSS = { + 'primary_color', 'theme_color_success', 'theme_color_danger', 'primary_font', + 'theme_color_background', 'theme_round_borders' +} PERSON_NAME_TITLE_GROUPS = OrderedDict([ ('english_common', (_('Most common English titles'), ( 'Mr', @@ -2167,6 +2332,7 @@ class SettingsSandbox: def validate_settings(event, settings_dict): + from pretix.base.models import Event from pretix.base.signals import validate_event_settings if 'locales' in settings_dict and settings_dict['locale'] not in settings_dict['locales']: @@ -2197,4 +2363,5 @@ def validate_settings(event, settings_dict): 'payment_term_last': _('The last payment date cannot be before the end of presale.') }) - validate_event_settings.send(sender=event, settings_dict=settings_dict) + if isinstance(event, Event): + validate_event_settings.send(sender=event, settings_dict=settings_dict) diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 6ebc28120d..b6085b8388 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -3,7 +3,7 @@ from urllib.parse import urlencode, urlparse from django import forms from django.conf import settings from django.core.exceptions import ValidationError -from django.core.validators import RegexValidator, validate_email +from django.core.validators import validate_email from django.db.models import Q from django.forms import formset_factory from django.urls import reverse @@ -28,8 +28,8 @@ from pretix.base.settings import ( PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_settings, ) from pretix.control.forms import ( - ExtFileField, FontSelect, MultipleLanguagesWidget, SlugWidget, - SplitDateTimeField, SplitDateTimePickerWidget, + ExtFileField, MultipleLanguagesWidget, SlugWidget, SplitDateTimeField, + SplitDateTimePickerWidget, ) from pretix.control.forms.widgets import Select2 from pretix.multidomain.models import KnownDomain @@ -431,57 +431,6 @@ class EventSettingsForm(SettingsForm): '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.') ) - primary_color = forms.CharField( - label=_("Primary color"), - required=False, - validators=[ - RegexValidator(regex='^#[0-9a-fA-F]{6}$', - message=_('Please enter the hexadecimal code of a color, e.g. #990000.')), - ], - widget=forms.TextInput(attrs={'class': 'colorpickerfield'}) - ) - theme_color_success = forms.CharField( - label=_("Accent color for success"), - help_text=_("We strongly suggest to use a shade of green."), - required=False, - validators=[ - RegexValidator(regex='^#[0-9a-fA-F]{6}$', - message=_('Please enter the hexadecimal code of a color, e.g. #990000.')), - ], - widget=forms.TextInput(attrs={'class': 'colorpickerfield'}) - ) - theme_color_danger = forms.CharField( - label=_("Accent color for errors"), - help_text=_("We strongly suggest to use a dark shade of red."), - required=False, - validators=[ - RegexValidator(regex='^#[0-9a-fA-F]{6}$', - message=_('Please enter the hexadecimal code of a color, e.g. #990000.')), - ], - widget=forms.TextInput(attrs={'class': 'colorpickerfield'}) - ) - theme_color_background = forms.CharField( - label=_("Page background color"), - required=False, - validators=[ - RegexValidator(regex='^#[0-9a-fA-F]{6}$', - message=_('Please enter the hexadecimal code of a color, e.g. #990000.')), - - ], - widget=forms.TextInput(attrs={'class': 'colorpickerfield no-contrast'}) - ) - theme_round_borders = forms.BooleanField( - label=_("Use round edges"), - required=False, - ) - primary_font = forms.ChoiceField( - label=_('Font'), - choices=[ - ('Open Sans', 'Open Sans') - ], - widget=FontSelect, - help_text=_('Only respected by modern browsers.') - ) auto_fields = [ 'imprint_url', @@ -523,6 +472,12 @@ class EventSettingsForm(SettingsForm): 'order_email_asked_twice', 'last_order_modification_date', 'checkout_show_copy_answers_button', + 'primary_color', + 'theme_color_success', + 'theme_color_danger', + 'theme_color_background', + 'theme_round_borders', + 'primary_font', ] def clean(self): diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index edc76145a2..1fb80ee865 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -4,24 +4,19 @@ from urllib.parse import urlparse from django import forms from django.conf import settings from django.core.exceptions import ValidationError -from django.core.validators import RegexValidator from django.db.models import Q from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _, pgettext_lazy from django_scopes.forms import SafeModelMultipleChoiceField -from i18nfield.forms import I18nFormField, I18nTextarea from pretix.api.models import WebHook from pretix.api.webhooks import get_all_webhook_events from pretix.base.forms import I18nModelForm, SettingsForm from pretix.base.forms.widgets import SplitDateTimePickerWidget from pretix.base.models import Device, Gate, GiftCard, Organizer, Team -from pretix.control.forms import ( - ExtFileField, FontSelect, MultipleLanguagesWidget, SplitDateTimeField, -) +from pretix.control.forms import ExtFileField, SplitDateTimeField from pretix.control.forms.event import SafeEventMultipleChoiceField from pretix.multidomain.models import KnownDomain -from pretix.presale.style import get_fonts class OrganizerForm(I18nModelForm): @@ -218,72 +213,26 @@ class DeviceForm(forms.ModelForm): class OrganizerSettingsForm(SettingsForm): + auto_fields = [ + 'organizer_info_text', + 'event_list_type', + 'event_list_availability', + 'organizer_homepage_text', + 'organizer_link_back', + 'organizer_logo_image_large', + 'giftcard_length', + 'giftcard_expiry_years', + 'locales', + 'event_team_provisioning', + 'primary_color', + 'theme_color_success', + 'theme_color_danger', + 'theme_color_background', + 'theme_round_borders', + 'primary_font' - organizer_info_text = I18nFormField( - label=_('Info text'), - required=False, - widget=I18nTextarea, - help_text=_('Not displayed anywhere by default, but if you want to, you can use this e.g. in ticket templates.') - ) + ] - event_team_provisioning = forms.BooleanField( - label=_('Allow creating a new team during event creation'), - help_text=_('Users that do not have access to all events under this organizer, must select one of their teams ' - 'to have access to the created event. This setting allows users to create an event-specified team' - ' on-the-fly, even when they do not have \"Can change teams and permissions\" permission.'), - required=False, - ) - - primary_color = forms.CharField( - label=_("Primary color"), - required=False, - validators=[ - RegexValidator(regex='^#[0-9a-fA-F]{6}$', - message=_('Please enter the hexadecimal code of a color, e.g. #990000.')), - ], - widget=forms.TextInput(attrs={'class': 'colorpickerfield'}) - ) - theme_color_success = forms.CharField( - label=_("Accent color for success"), - help_text=_("We strongly suggest to use a shade of green."), - required=False, - validators=[ - RegexValidator(regex='^#[0-9a-fA-F]{6}$', - message=_('Please enter the hexadecimal code of a color, e.g. #990000.')), - ], - widget=forms.TextInput(attrs={'class': 'colorpickerfield'}) - ) - theme_color_danger = forms.CharField( - label=_("Accent color for errors"), - help_text=_("We strongly suggest to use a shade of red."), - required=False, - validators=[ - RegexValidator(regex='^#[0-9a-fA-F]{6}$', - message=_('Please enter the hexadecimal code of a color, e.g. #990000.')), - - ], - widget=forms.TextInput(attrs={'class': 'colorpickerfield'}) - ) - theme_color_background = forms.CharField( - label=_("Page background color"), - required=False, - validators=[ - RegexValidator(regex='^#[0-9a-fA-F]{6}$', - message=_('Please enter the hexadecimal code of a color, e.g. #990000.')), - - ], - widget=forms.TextInput(attrs={'class': 'colorpickerfield no-contrast'}) - ) - theme_round_borders = forms.BooleanField( - label=_("Use round edges"), - required=False, - ) - organizer_homepage_text = I18nFormField( - label=_('Homepage text'), - required=False, - widget=I18nTextarea, - help_text=_('This will be displayed on the organizer homepage.') - ) organizer_logo_image = ExtFileField( label=_('Header image'), ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"), @@ -294,44 +243,6 @@ class OrganizerSettingsForm(SettingsForm): '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.') ) - organizer_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, - ) - event_list_type = forms.ChoiceField( - label=_('Default overview style'), - choices=( - ('list', _('List')), - ('week', _('Week calendar')), - ('calendar', _('Month calendar')), - ) - ) - event_list_availability = forms.BooleanField( - label=_('Show availability in event overviews'), - help_text=_('If checked, the list of events will show if events are sold out. This might ' - 'make for longer page loading times if you have lots of events and the shown status might be out ' - 'of date for up to two minutes.'), - required=False - ) - organizer_link_back = forms.BooleanField( - label=_('Link back to organizer overview on all event pages'), - required=False - ) - locales = forms.MultipleChoiceField( - choices=settings.LANGUAGES, - label=_("Use languages"), - widget=MultipleLanguagesWidget, - help_text=_('Choose all languages that your organizer homepage should be available in.') - ) - primary_font = forms.ChoiceField( - label=_('Font'), - choices=[ - ('Open Sans', 'Open Sans') - ], - widget=FontSelect, - help_text=_('Only respected by modern browsers.') - ) favicon = ExtFileField( label=_('Favicon'), ext_whitelist=(".ico", ".png", ".jpg", ".gif", ".jpeg"), @@ -340,24 +251,6 @@ class OrganizerSettingsForm(SettingsForm): help_text=_('If you provide a favicon, we will show it instead of the default pretix icon. ' 'We recommend a size of at least 200x200px to accommodate most devices.') ) - giftcard_length = forms.IntegerField( - label=_('Length of gift card codes'), - help_text=_('The system generates by default {}-character long gift card codes. However, if a different length ' - 'is required, it can be set here.'.format(settings.ENTROPY['giftcard_secret'])), - required=False - ) - giftcard_expiry_years = forms.IntegerField( - label=_('Validity of gift card codes in years'), - help_text=_('If you set a number here, gift cards will by default expire at the end of the year after this ' - 'many years. If you keep it empty, gift cards do not have an explicit expiry date.'), - required=False - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields['primary_font'].choices += [ - (a, {"title": a, "data": v}) for a, v in get_fonts().items() - ] class WebHookForm(forms.ModelForm): diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index 9b550d5de2..7a1ded8f55 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -56,7 +56,7 @@ from pretix.plugins.stripe.payment import StripeSettingsHolder from pretix.presale.style import regenerate_css from ...base.models.items import ItemMetaProperty -from ...base.settings import LazyI18nStringList +from ...base.settings import SETTINGS_AFFECTING_CSS, LazyI18nStringList from ..logdisplay import OVERVIEW_BANLIST from . import CreateView, PaginationMixin, UpdateView @@ -161,11 +161,7 @@ class EventUpdate(DecoupleMixin, EventSettingsViewMixin, EventPermissionRequired if self.confirm_texts_formset.has_changed(): data.update(confirm_texts=self.confirm_texts_formset.cleaned_data) self.request.event.log_action('pretix.event.settings', user=self.request.user, data=data) - display_properties = ( - 'primary_color', 'theme_color_success', 'theme_color_danger', 'primary_font', - 'theme_color_background', 'theme_round_borders', - ) - if any(p in self.sform.changed_data for p in display_properties): + if any(p in self.sform.changed_data for p in SETTINGS_AFFECTING_CSS): change_css = True if form.has_changed(): self.request.event.log_action('pretix.event.changed', user=self.request.user, data={ diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index aae5327fe1..d7cd7fc444 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -39,6 +39,7 @@ from pretix.base.models.organizer import TeamAPIToken from pretix.base.payment import PaymentException from pretix.base.services.export import multiexport from pretix.base.services.mail import SendMailException, mail +from pretix.base.settings import SETTINGS_AFFECTING_CSS from pretix.base.signals import register_multievent_data_exporters from pretix.base.views.tasks import AsyncAction from pretix.control.forms.filter import ( @@ -287,11 +288,7 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView): for k in self.sform.changed_data } ) - display_properties = ( - 'primary_color', 'theme_color_success', 'theme_color_danger', 'primary_font', - 'theme_color_background', 'theme_round_borders' - ) - if any(p in self.sform.changed_data for p in display_properties): + if any(p in self.sform.changed_data for p in SETTINGS_AFFECTING_CSS): change_css = True if form.has_changed(): self.request.organizer.log_action( diff --git a/src/tests/api/test_events.py b/src/tests/api/test_events.py index 2cb17d2852..bbf0994179 100644 --- a/src/tests/api/test_events.py +++ b/src/tests/api/test_events.py @@ -13,6 +13,7 @@ from pretix.base.models import ( Event, InvoiceAddress, Order, OrderPosition, SeatingPlan, ) from pretix.base.models.orders import OrderFee +from pretix.testutils.mock import mocker_context @pytest.fixture @@ -990,65 +991,78 @@ def test_get_event_settings(token_client, organizer, event): @pytest.mark.django_db def test_patch_event_settings(token_client, organizer, event): - organizer.settings.imprint_url = 'https://example.org' - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), - { - 'imprint_url': 'https://example.com' - }, - format='json' - ) - assert resp.status_code == 200 - assert resp.data['imprint_url'] == "https://example.com" - event.settings.flush() - assert event.settings.imprint_url == 'https://example.com' + with mocker_context() as mocker: + mocked = mocker.patch('pretix.presale.style.regenerate_css.apply_async') + organizer.settings.imprint_url = 'https://example.org' + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), + { + 'imprint_url': 'https://example.com' + }, + format='json' + ) + assert resp.status_code == 200 + assert resp.data['imprint_url'] == "https://example.com" + event.settings.flush() + assert event.settings.imprint_url == 'https://example.com' + mocked.assert_not_called() - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), - { - 'imprint_url': None, - }, - format='json' - ) - assert resp.status_code == 200 - assert resp.data['imprint_url'] == "https://example.org" - event.settings.flush() - assert event.settings.imprint_url == 'https://example.org' + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), + { + 'primary_color': '#ff0000' + }, + format='json' + ) + assert resp.status_code == 200 + mocked.assert_any_call(args=(event.pk,)) - resp = token_client.put( - '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), - { - 'imprint_url': 'invalid' - }, - format='json' - ) - assert resp.status_code == 405 + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), + { + 'imprint_url': None, + }, + format='json' + ) + assert resp.status_code == 200 + assert resp.data['imprint_url'] == "https://example.org" + event.settings.flush() + assert event.settings.imprint_url == 'https://example.org' - locales = event.settings.locales + resp = token_client.put( + '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), + { + 'imprint_url': 'invalid' + }, + format='json' + ) + assert resp.status_code == 405 - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), - { - 'locales': event.settings.locales + ['de', 'de-informal'], - }, - format='json' - ) - assert resp.status_code == 200 - assert set(resp.data['locales']) == set(locales + ['de', 'de-informal']) - event.settings.flush() - assert set(event.settings.locales) == set(locales + ['de', 'de-informal']) + locales = event.settings.locales - resp = token_client.patch( - '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), - { - 'locales': locales, - }, - format='json' - ) - assert resp.status_code == 200 - assert set(resp.data['locales']) == set(locales) - event.settings.flush() - assert set(event.settings.locales) == set(locales) + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), + { + 'locales': event.settings.locales + ['de', 'de-informal'], + }, + format='json' + ) + assert resp.status_code == 200 + assert set(resp.data['locales']) == set(locales + ['de', 'de-informal']) + event.settings.flush() + assert set(event.settings.locales) == set(locales + ['de', 'de-informal']) + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), + { + 'locales': locales, + }, + format='json' + ) + assert resp.status_code == 200 + assert set(resp.data['locales']) == set(locales) + event.settings.flush() + assert set(event.settings.locales) == set(locales) @pytest.mark.django_db diff --git a/src/tests/api/test_organizers.py b/src/tests/api/test_organizers.py index 375e7272d9..f13f25cafb 100644 --- a/src/tests/api/test_organizers.py +++ b/src/tests/api/test_organizers.py @@ -1,5 +1,7 @@ import pytest +from pretix.testutils.mock import mocker_context + TEST_ORGANIZER_RES = { "name": "Dummy", "slug": "dummy" @@ -18,3 +20,83 @@ def test_organizer_detail(token_client, organizer): resp = token_client.get('/api/v1/organizers/{}/'.format(organizer.slug)) assert resp.status_code == 200 assert TEST_ORGANIZER_RES == resp.data + + +@pytest.mark.django_db +def test_get_settings(token_client, organizer): + organizer.settings.event_list_type = "week" + resp = token_client.get( + '/api/v1/organizers/{}/settings/'.format(organizer.slug,), + ) + assert resp.status_code == 200 + assert resp.data['event_list_type'] == "week" + + resp = token_client.get( + '/api/v1/organizers/{}/settings/?explain=true'.format(organizer.slug), + ) + assert resp.status_code == 200 + assert resp.data['event_list_type'] == { + "value": "week", + "label": "Default overview style", + "help_text": "If your event series has more than 50 dates in the future, only the month or week calendar can be used." + } + + +@pytest.mark.django_db +def test_patch_settings(token_client, organizer): + with mocker_context() as mocker: + mocked = mocker.patch('pretix.presale.style.regenerate_organizer_css.apply_async') + + organizer.settings.event_list_type = 'week' + resp = token_client.patch( + '/api/v1/organizers/{}/settings/'.format(organizer.slug), + { + 'event_list_type': 'list' + }, + format='json' + ) + assert resp.status_code == 200 + assert resp.data['event_list_type'] == "list" + organizer.settings.flush() + assert organizer.settings.event_list_type == 'list' + + resp = token_client.patch( + '/api/v1/organizers/{}/settings/'.format(organizer.slug), + { + 'event_list_type': None, + }, + format='json' + ) + assert resp.status_code == 200 + assert resp.data['event_list_type'] == "list" + organizer.settings.flush() + assert organizer.settings.event_list_type == 'list' + mocked.assert_not_called() + + resp = token_client.put( + '/api/v1/organizers/{}/settings/'.format(organizer.slug), + { + 'event_list_type': 'put-not-allowed' + }, + format='json' + ) + assert resp.status_code == 405 + + resp = token_client.patch( + '/api/v1/organizers/{}/settings/'.format(organizer.slug), + { + 'primary_color': 'invalid-color' + }, + format='json' + ) + assert resp.status_code == 400 + + resp = token_client.patch( + '/api/v1/organizers/{}/settings/'.format(organizer.slug), + { + 'primary_color': '#ff0000' + }, + format='json' + ) + assert resp.status_code == 200 + mocked.assert_any_call(args=(organizer.pk,)) diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index f44c49e249..1a6b682192 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -137,6 +137,8 @@ event_permission_sub_urls = [ ] org_permission_sub_urls = [ + ('get', 'can_change_organizer_settings', 'settings/', 200), + ('patch', 'can_change_organizer_settings', 'settings/', 200), ('get', 'can_change_organizer_settings', 'webhooks/', 200), ('post', 'can_change_organizer_settings', 'webhooks/', 400), ('get', 'can_change_organizer_settings', 'webhooks/1/', 404),