REST API: Add organizer-level settings (#1866)

Co-authored-by: Martin Gross <gross@rami.io>
This commit is contained in:
Raphael Michel
2020-12-03 15:19:11 +01:00
committed by GitHub
co-authored by Martin Gross
parent 978130551a
commit 55d8639ecc
14 changed files with 603 additions and 264 deletions
+117
View File
@@ -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.
+9
View File
@@ -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):
+62
View File
@@ -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
+2
View File
@@ -74,6 +74,8 @@ for app in apps.get_app_configs():
urlpatterns = [
url(r'^', include(router.urls)),
url(r'^organizers/(?P<organizer>[^/]+)/', include(orga_router.urls)),
url(r'^organizers/(?P<organizer>[^/]+)/settings/$', organizer.OrganizerSettingsView.as_view(),
name="organizer.settings"),
url(r'^organizers/(?P<organizer>[^/]+)/giftcards/(?P<giftcard>[^/]+)/', include(giftcard_router.urls)),
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/settings/$', event.EventSettingsView.as_view(),
name="event.settings"),
+4
View File
@@ -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)
+42 -3
View File
@@ -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)
+183 -16
View File
@@ -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)
+9 -54
View File
@@ -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):
+19 -126
View File
@@ -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):
+2 -6
View File
@@ -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={
+2 -5
View File
@@ -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(
+68 -54
View File
@@ -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
+82
View File
@@ -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,))
+2
View File
@@ -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),