diff --git a/doc/api/fundamentals.rst b/doc/api/fundamentals.rst index 40b139cb5..8cfa55223 100644 --- a/doc/api/fundamentals.rst +++ b/doc/api/fundamentals.rst @@ -170,6 +170,19 @@ Date String in ISO 8601 format ``2017-12-27`` Multi-lingual string Object of strings ``{"en": "red", "de": "rot", "de_Informal": "rot"}`` Money String with decimal number ``"23.42"`` Currency String with ISO 4217 code ``"EUR"``, ``"USD"`` +Relative datetime *either* String in ISO 8601 ``"2017-12-27T10:00:00.596934Z"``, + format *or* specification of ``"RELDATE/3/12:00:00/presale_start/"`` + a relative datetime, + constructed from a number of + days before the base point, + a time of day, and the base + point. +Relative date *either* String in ISO 8601 ``"2017-12-27"``, + format *or* specification of ``"RELDATE/3/-/presale_start/"`` + a relative date, + constructed from a number of + days before the base point + and the base point. ===================== ============================ =================================== Query parameters diff --git a/doc/api/resources/events.rst b/doc/api/resources/events.rst index ee13f2e0d..fc5638c9f 100644 --- a/doc/api/resources/events.rst +++ b/doc/api/resources/events.rst @@ -486,3 +486,123 @@ Endpoints :statuscode 204: no error :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource. + +Event settings +-------------- + +pretix 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 also between events, depending on the +installed plugins, 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 event 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 event using this API by creating situations of conflicting settings. Please take care. + +.. versionchanged:: 3.6 + + Initial support for settings has been added to the API. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/settings/ + + Get current values of event settings. + + Permission required: "Can change event settings" + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/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 + + { + "imprint_url": "https://pretix.eu", + … + } + + **Example verbose response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "imprint_url": + { + "value": "https://pretix.eu", + "label": "Imprint URL", + "help_text": "This should point e.g. to a part of your website that has your contact details and legal information." + } + }, + … + } + + :param organizer: The ``slug`` field of the organizer of the event to access + :param event: The ``slug`` field of the event 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/event does not exist **or** you have no permission to view this resource. + +.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/settings/ + + Updates event 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 event level, a default setting + from a higher level (organizer, global) will be returned. If you explicitly set a setting on event 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 event level. To unset a settings, pass ``null``. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/settings/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "imprint_url": "https://example.org/imprint/" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "imprint_url": "https://example.org/imprint/", + … + } + + :param organizer: The ``slug`` field of the organizer of the event to update + :param event: The ``slug`` field of the event to update + :statuscode 200: no error + :statuscode 400: The event could not be updated due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource. diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst index 51c618427..a43b8eb58 100644 --- a/doc/development/api/general.rst +++ b/doc/development/api/general.rst @@ -81,3 +81,9 @@ Ticket designs .. automodule:: pretix.plugins.ticketoutputpdf.signals :members: override_layout + +API +--- + +.. automodule:: pretix.base.signals + :members: validate_event_settings, api_event_settings_fields diff --git a/doc/spelling_wordlist.txt b/doc/spelling_wordlist.txt index 001f1ff0f..35605cc33 100644 --- a/doc/spelling_wordlist.txt +++ b/doc/spelling_wordlist.txt @@ -111,7 +111,7 @@ scss searchable selectable serializable -serializers +serializer serializers sexualized SQL @@ -141,6 +141,7 @@ untrusted uptime username url +validator versa versioning viewable diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index eaf674099..527db52f4 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -4,7 +4,9 @@ from django.db import transaction from django.utils.functional import cached_property from django.utils.translation import ugettext 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 @@ -15,6 +17,8 @@ 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_settings +from pretix.base.signals import api_event_settings_fields class MetaDataField(Field): @@ -469,3 +473,124 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer): class Meta: model = TaxRule fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country') + + +class EventSettingsSerializer(serializers.Serializer): + default_fields = [ + 'imprint_url', + 'checkout_email_helptext', + 'presale_has_ended_text', + 'voucher_explanation_text', + 'show_date_to', + 'show_times', + 'show_items_outside_presale_period', + 'display_net_prices', + 'presale_start_show_date', + 'locales', + 'locale', + 'last_order_modification_date', + 'show_quota_left', + 'waiting_list_enabled', + 'waiting_list_hours', + 'waiting_list_auto', + 'max_items_per_order', + 'reservation_time', + 'contact_mail', + 'show_variations_expanded', + 'hide_sold_out', + 'meta_noindex', + 'redirect_to_checkout_directly', + 'frontpage_subevent_ordering', + 'frontpage_text', + 'attendee_names_asked', + 'attendee_names_required', + 'attendee_emails_asked', + 'attendee_emails_required', + 'confirm_text', + 'order_email_asked_twice', + 'payment_term_days', + 'payment_term_last', + 'payment_term_weekdays', + 'payment_term_expire_automatically', + 'payment_term_accept_late', + 'payment_explanation', + 'ticket_download', + 'ticket_download_date', + 'ticket_download_addons', + 'ticket_download_nonadm', + 'ticket_download_pending', + 'mail_prefix', + 'mail_from', + 'mail_from_name', + 'mail_attach_ical', + 'invoice_address_asked', + 'invoice_address_required', + 'invoice_address_vatid', + 'invoice_address_company_required', + 'invoice_address_beneficiary', + 'invoice_name_required', + 'invoice_address_not_asked_free', + 'invoice_include_free', + 'invoice_generate', + 'invoice_numbers_consecutive', + 'invoice_numbers_prefix', + 'invoice_numbers_prefix_cancellations', + 'invoice_attendee_name', + 'invoice_include_expire_date', + 'invoice_address_explanation_text', + 'invoice_email_attachment', + 'invoice_address_from_name', + 'invoice_address_from', + 'invoice_address_from_zipcode', + 'invoice_address_from_city', + 'invoice_address_from_country', + 'invoice_address_from_tax_id', + 'invoice_address_from_vat_id', + 'invoice_introductory_text', + 'invoice_additional_text', + 'invoice_footer_text', + 'cancel_allow_user', + 'cancel_allow_user_until', + 'cancel_allow_user_paid', + 'cancel_allow_user_paid_until', + 'cancel_allow_user_paid_keep', + 'cancel_allow_user_paid_keep_fees', + 'cancel_allow_user_paid_keep_percentage', + ] + + def __init__(self, *args, **kwargs): + self.event = kwargs.pop('event') + super().__init__(*args, **kwargs) + for fname in self.default_fields: + kwargs = DEFAULTS[fname].get('serializer_kwargs', {}) + kwargs.setdefault('required', False) + kwargs.setdefault('allow_null', True) + form_kwargs = DEFAULTS[fname].get('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) + elif instance.get(attr, as_type=type(value)) != value: + instance.set(attr, value) + return instance + + def validate(self, data): + data = super().validate(data) + settings_dict = self.instance.freeze() + settings_dict.update(data) + validate_settings(self.event, settings_dict) + return data diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 36ec25485..930ed5985 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -67,6 +67,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[^/]+)/events/(?P[^/]+)/settings/$', event.EventSettingsView.as_view(), + name="event.settings"), url(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/', include(event_router.urls)), url(r'^organizers/(?P[^/]+)/teams/(?P[^/]+)/', include(team_router.urls)), url(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/items/(?P[^/]+)/', include(item_router.urls)), diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index f777f19d8..f267fd828 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -4,13 +4,14 @@ from django.db.models import ProtectedError, Q from django.utils.timezone import now from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_scopes import scopes_disabled -from rest_framework import filters, viewsets +from rest_framework import filters, views, viewsets from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response from pretix.api.auth.permission import EventCRUDPermission from pretix.api.serializers.event import ( - CloneEventSerializer, EventSerializer, SubEventSerializer, - TaxRuleSerializer, + CloneEventSerializer, EventSerializer, EventSettingsSerializer, + SubEventSerializer, TaxRuleSerializer, ) from pretix.api.views import ConditionalListView from pretix.base.models import ( @@ -333,3 +334,33 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet): auth=self.request.auth, ) super().perform_destroy(instance) + + +class EventSettingsView(views.APIView): + permission = 'can_change_event_settings' + + def get(self, request, *args, **kwargs): + s = EventSettingsSerializer(instance=request.event.settings, event=request.event) + 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 = EventSettingsSerializer(instance=request.event.settings, data=request.data, partial=True, + event=request.event) + s.is_valid(raise_exception=True) + with transaction.atomic(): + s.save() + self.request.event.log_action( + 'pretix.event.settings', user=self.request.user, auth=self.request.auth, data={ + k: v for k, v in s.validated_data.items() + } + ) + s = EventSettingsSerializer(instance=request.event.settings, event=request.event) + return Response(s.data) diff --git a/src/pretix/base/__init__.py b/src/pretix/base/__init__.py index fc6a8c16a..51fdbbe5b 100644 --- a/src/pretix/base/__init__.py +++ b/src/pretix/base/__init__.py @@ -1,5 +1,4 @@ from django.apps import AppConfig -from django.conf import settings class PretixBaseConfig(AppConfig): @@ -14,6 +13,7 @@ class PretixBaseConfig(AppConfig): from . import notifications # NOQA from . import email # NOQA from .services import auth, checkin, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA + from django.conf import settings try: from .celery_app import app as celery_app # NOQA diff --git a/src/pretix/base/forms/__init__.py b/src/pretix/base/forms/__init__.py index ead6a4bc8..62d5fe107 100644 --- a/src/pretix/base/forms/__init__.py +++ b/src/pretix/base/forms/__init__.py @@ -8,7 +8,6 @@ from django.utils.crypto import get_random_string from formtools.wizard.views import SessionWizardView from hierarkey.forms import HierarkeyForm -from pretix.base.models import Event from pretix.base.reldate import RelativeDateField, RelativeDateTimeField from .validators import PlaceholderValidator # NOQA @@ -51,19 +50,33 @@ class I18nInlineFormSet(i18nfield.forms.I18nInlineFormSet): class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm): + auto_fields = [] def __init__(self, *args, **kwargs): + from pretix.base.settings import DEFAULTS + self.obj = kwargs.get('obj', None) self.locales = self.obj.settings.get('locales') if self.obj else kwargs.pop('locales', None) kwargs['attribute_name'] = 'settings' kwargs['locales'] = self.locales kwargs['initial'] = self.obj.settings.freeze() super().__init__(*args, **kwargs) + for fname in self.auto_fields: + kwargs = DEFAULTS[fname].get('form_kwargs', {}) + kwargs.setdefault('required', False) + field = DEFAULTS[fname]['form_class']( + **kwargs + ) + if isinstance(field, i18nfield.forms.I18nFormField): + field.widget.enabled_locales = self.locales + self.fields[fname] = field for k, f in self.fields.items(): if isinstance(f, (RelativeDateTimeField, RelativeDateField)): f.set_event(self.obj) def get_new_filename(self, name: str) -> str: + from pretix.base.models import Event + nonce = get_random_string(length=8) if isinstance(self.obj, Event): fname = '%s/%s/%s.%s.%s' % ( diff --git a/src/pretix/base/forms/widgets.py b/src/pretix/base/forms/widgets.py index 442ee5355..8abdd5eff 100644 --- a/src/pretix/base/forms/widgets.py +++ b/src/pretix/base/forms/widgets.py @@ -6,9 +6,6 @@ from django.utils.functional import lazy from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ -from pretix.base.models import OrderPosition -from pretix.multidomain.urlreverse import eventreverse - class DatePickerWidget(forms.DateInput): def __init__(self, attrs=None, date_format=None): @@ -71,6 +68,9 @@ class UploadedFileWidget(forms.ClearableFileInput): @property def url(self): + from pretix.base.models import OrderPosition + from pretix.multidomain.urlreverse import eventreverse + if isinstance(self.position, OrderPosition): return eventreverse(self.event, 'presale:event.order.download.answer', kwargs={ 'order': self.position.order.code, diff --git a/src/pretix/base/reldate.py b/src/pretix/base/reldate.py index e53368986..a9a34817e 100644 --- a/src/pretix/base/reldate.py +++ b/src/pretix/base/reldate.py @@ -8,6 +8,7 @@ from django import forms from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers BASE_CHOICES = ( ('date_from', _('Event start')), @@ -115,6 +116,8 @@ class RelativeDateWrapper: base_date_name=parts[3], time=time ) + if data.base_date_name not in [k[0] for k in BASE_CHOICES]: + raise ValueError('{} is not a valid base date'.format(data.base_date_name)) else: data = parser.parse(input) return RelativeDateWrapper(data) @@ -330,3 +333,39 @@ class ModelRelativeDateTimeField(models.CharField): defaults = {'form_class': self.form_class} defaults.update(kwargs) return super().formfield(**defaults) + + +class SerializerRelativeDateField(serializers.CharField): + + def to_internal_value(self, data): + if data is None: + return None + try: + r = RelativeDateWrapper.from_string(data) + if isinstance(r.data, RelativeDate): + if r.data.time is not None: + raise ValidationError("Do not specify a time for a date field") + return r + except: + raise ValidationError("Invalid relative date") + + def to_representation(self, value: RelativeDateWrapper): + if value is None: + return None + return value.to_string() + + +class SerializerRelativeDateTimeField(serializers.CharField): + + def to_internal_value(self, data): + if data is None: + return None + try: + return RelativeDateWrapper.from_string(data) + except: + raise ValidationError("Invalid relative date") + + def to_representation(self, value: RelativeDateWrapper): + if value is None: + return None + return value.to_string() diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 8e8265a2c..8d813d004 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -4,102 +4,263 @@ from datetime import datetime from decimal import Decimal from typing import Any +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.db.models import Model from django.utils.translation import ( - pgettext_lazy, ugettext_lazy as _, ugettext_noop, + pgettext, pgettext_lazy, ugettext_lazy as _, ugettext_noop, ) +from django_countries import countries from hierarkey.models import GlobalSettingsBase, Hierarkey +from i18nfield.forms import I18nFormField, I18nTextarea from i18nfield.strings import LazyI18nString +from rest_framework import serializers +from pretix.api.serializers.i18n import I18nField from pretix.base.models.tax import TaxRule -from pretix.base.reldate import RelativeDateWrapper +from pretix.base.reldate import ( + RelativeDateField, RelativeDateTimeField, RelativeDateWrapper, + SerializerRelativeDateField, SerializerRelativeDateTimeField, +) +from pretix.control.forms import MultipleLanguagesWidget, SingleLanguageWidget + +allcountries = list(countries) +allcountries.insert(0, ('', _('Select country'))) + DEFAULTS = { 'max_items_per_order': { 'default': '10', - 'type': int + 'type': int, + 'form_class': forms.IntegerField, + 'serializer_class': serializers.IntegerField, + 'form_kwargs': dict( + min_value=1, + label=_("Maximum number of items per order"), + help_text=_("Add-on products will not be counted.") + ) }, 'display_net_prices': { 'default': 'False', - 'type': bool + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Show net prices instead of gross prices in the product list (not recommended!)"), + help_text=_("Independent of your choice, the cart will show gross prices as this is the price that needs to be " + "paid"), + + ) }, 'attendee_names_asked': { 'default': 'True', - 'type': bool + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Ask for attendee names"), + help_text=_("Ask for a name for all tickets which include admission to the event."), + ) }, 'attendee_names_required': { 'default': 'False', - 'type': bool + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Require attendee names"), + help_text=_("Require customers to fill in the names of all attendees."), + widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_names_asked'}), + ) }, 'attendee_emails_asked': { 'default': 'False', - 'type': bool + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Ask for email addresses per ticket"), + help_text=_("Normally, pretix asks for one email address per order and the order confirmation will be sent " + "only to that email address. If you enable this option, the system will additionally ask for " + "individual email addresses for every admission ticket. This might be useful if you want to " + "obtain individual addresses for every attendee even in case of group orders. However, " + "pretix will send the order confirmation by default only to the one primary email address, not to " + "the per-attendee addresses. You can however enable this in the E-mail settings."), + ) }, 'attendee_emails_required': { 'default': 'False', - 'type': bool + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Require email addresses per ticket"), + help_text=_("Require customers to fill in individual e-mail addresses for all admission tickets. See the " + "above option for more details. One email address for the order confirmation will always be " + "required regardless of this setting."), + widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_emails_asked'}), + ) }, 'order_email_asked_twice': { 'default': 'False', - 'type': bool + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Ask for the order email address twice"), + help_text=_("Require customers to fill in the primary email address twice to avoid errors."), + ) }, 'invoice_address_asked': { 'default': 'True', 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Ask for invoice address"), + ) }, 'invoice_address_not_asked_free': { 'default': 'False', 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_('Do not ask for invoice address if an order is free'), + ) }, 'invoice_name_required': { 'default': 'False', 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Require customer name"), + ) }, 'invoice_attendee_name': { 'default': 'True', 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Show attendee names on invoices"), + ) }, 'invoice_address_required': { 'default': 'False', + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, 'type': bool, + 'form_kwargs': dict( + label=_("Require invoice address"), + widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}), + ) }, 'invoice_address_company_required': { 'default': 'False', + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, 'type': bool, + 'form_kwargs': dict( + label=_("Require a business addresses"), + help_text=_('This will require users to enter a company name.'), + widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_required'}), + ) }, 'invoice_address_beneficiary': { 'default': 'False', 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Ask for beneficiary"), + widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}), + required=False + ) }, 'invoice_address_vatid': { 'default': 'False', 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Ask for VAT ID"), + help_text=_("Does only work if an invoice address is asked for. VAT ID is not required."), + widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}), + ) }, 'invoice_address_explanation_text': { 'default': '', - 'type': LazyI18nString + 'type': LazyI18nString, + 'form_class': I18nFormField, + 'serializer_class': I18nField, + 'form_kwargs': dict( + label=_("Invoice address explanation"), + widget=I18nTextarea, + widget_kwargs={'attrs': {'rows': '2'}}, + help_text=_("This text will be shown above the invoice address form during checkout.") + ) }, 'invoice_include_free': { 'default': 'True', 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Show free products on invoices"), + help_text=_("Note that invoices will never be generated for orders that contain only free " + "products."), + ) }, 'invoice_include_expire_date': { 'default': 'False', 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Show expiration date of order"), + help_text=_("The expiration date will not be shown if the invoice is generated after the order is paid."), + ) }, 'invoice_numbers_consecutive': { 'default': 'True', 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Generate invoices with consecutive numbers"), + help_text=_("If deactivated, the order code will be used in the invoice number."), + ) }, 'invoice_numbers_prefix': { 'default': '', 'type': str, + 'form_class': forms.CharField, + 'serializer_class': serializers.CharField, + 'form_kwargs': dict( + label=_("Invoice number prefix"), + help_text=_("This will be prepended to invoice numbers. If you leave this field empty, your event slug will " + "be used followed by a dash. Attention: If multiple events within the same organization use the " + "same value in this field, they will share their number range, i.e. every full number will be " + "used at most once over all of your events. This setting only affects future invoices. You can " + "use %Y (with century) %y (without century) to insert the year of the invoice, or %m and %d for " + "the day of month."), + ) }, 'invoice_numbers_prefix_cancellations': { 'default': '', 'type': str, + 'form_class': forms.CharField, + 'serializer_class': serializers.CharField, + 'form_kwargs': dict( + label=_("Invoice number prefix for cancellations"), + help_text=_("This will be prepended to invoice numbers of cancellations. If you leave this field empty, " + "the same numbering scheme will be used that you configured for regular invoices."), + ) }, 'invoice_renderer': { 'default': 'classic', @@ -107,35 +268,107 @@ DEFAULTS = { }, 'reservation_time': { 'default': '30', - 'type': int + 'type': int, + 'form_class': forms.IntegerField, + 'serializer_class': serializers.IntegerField, + 'form_kwags': dict( + min_value=0, + label=_("Reservation period"), + help_text=_("The number of minutes the items in a user's cart are reserved for this user."), + ) }, 'redirect_to_checkout_directly': { 'default': 'False', - 'type': bool + 'type': bool, + 'serializer_class': serializers.BooleanField, + 'form_class': forms.BooleanField, + 'form_kwargs': dict( + label=_('Directly redirect to check-out after a product has been added to the cart.'), + ) }, 'presale_has_ended_text': { 'default': '', - 'type': LazyI18nString + 'type': LazyI18nString, + 'form_class': I18nFormField, + 'serializer_class': I18nField, + 'form_kwargs': dict( + label=_("End of presale text"), + widget=I18nTextarea, + widget_kwargs={'attrs': {'rows': '2'}}, + help_text=_("This text will be shown above the ticket shop once the designated sales timeframe for this event " + "is over. You can use it to describe other options to get a ticket, such as a box office.") + ) }, 'payment_explanation': { 'default': '', - 'type': LazyI18nString + 'type': LazyI18nString, + 'form_class': I18nFormField, + 'serializer_class': I18nField, + 'form_kwargs': dict( + widget=I18nTextarea, + 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.") + ) }, 'payment_term_days': { 'default': '14', - 'type': int + 'type': int, + 'form_class': forms.IntegerField, + 'serializer_class': serializers.IntegerField, + 'form_kwargs': dict( + label=_('Payment term in days'), + help_text=_("The number of days after placing an order the user has to pay to preserve their reservation. If " + "you use slow payment methods like bank transfer, we recommend 14 days. If you only use real-time " + "payment methods, we recommend still setting two or three days to allow people to retry failed " + "payments."), + validators=[MinValueValidator(0), + MaxValueValidator(1000000)] + ), + 'serializer_kwargs': dict( + validators=[MinValueValidator(0), + MaxValueValidator(1000000)] + ) }, 'payment_term_last': { 'default': None, 'type': RelativeDateWrapper, + 'form_class': RelativeDateField, + 'serializer_class': SerializerRelativeDateField, + 'form_kawrgs': dict( + label=_('Last date of payments'), + help_text=_("The last date any payments are accepted. This has precedence over the number of " + "days configured above. If you use the event series feature and an order contains tickets for " + "multiple dates, the earliest date will be used."), + ) }, 'payment_term_weekdays': { 'default': 'True', - 'type': bool + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_('Only end payment terms on weekdays'), + help_text=_("If this is activated and the payment term of any order ends on a Saturday or Sunday, it will be " + "moved to the next Monday instead. This is required in some countries by civil law. This will " + "not effect the last date of payments configured above."), + ) }, 'payment_term_expire_automatically': { 'default': 'True', - 'type': bool + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_('Automatically expire unpaid orders'), + help_text=_("If checked, all unpaid orders will automatically go from 'pending' to 'expired' " + "after the end of their payment deadline. This means that those tickets go back to " + "the pool and can be ordered by other people."), + ) }, 'payment_giftcard__enabled': { 'default': 'True', @@ -147,11 +380,26 @@ DEFAULTS = { }, 'payment_term_accept_late': { 'default': 'True', - 'type': bool + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_('Accept late payments'), + help_text=_("Accept payments for orders even when they are in 'expired' state as long as enough " + "capacity is available. No payments will ever be accepted after the 'Last date of payments' " + "configured above."), + ) }, 'presale_start_show_date': { 'default': 'True', - 'type': bool + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Show start date"), + help_text=_("Show the presale start date before presale has started."), + widget=forms.CheckboxInput, + ) }, 'tax_rate_default': { 'default': None, @@ -159,7 +407,30 @@ DEFAULTS = { }, 'invoice_generate': { 'default': 'False', - 'type': str + 'type': str, + 'form_class': forms.ChoiceField, + 'serializer_class': serializers.ChoiceField, + 'serializer_kwargs': dict( + choices=( + ('False', _('Do not generate invoices')), + ('admin', _('Only manually in admin panel')), + ('user', _('Automatically on user request')), + ('True', _('Automatically for all created orders')), + ('paid', _('Automatically on payment')), + ), + ), + 'form_kwargs': dict( + label=_("Generate invoices"), + widget=forms.RadioSelect, + choices=( + ('False', _('Do not generate invoices')), + ('admin', _('Only manually in admin panel')), + ('user', _('Automatically on user request')), + ('True', _('Automatically for all created orders')), + ('paid', _('Automatically on payment')), + ), + help_text=_("Invoices will never be automatically generated for free orders.") + ) }, 'invoice_generate_sales_channels': { 'default': json.dumps(['web']), @@ -167,19 +438,133 @@ DEFAULTS = { }, 'invoice_address_from': { 'default': '', - 'type': str + 'type': str, + 'form_class': forms.CharField, + 'serializer_class': serializers.CharField, + 'form_kwargs': dict( + label=_("Address line"), + widget=forms.Textarea(attrs={ + 'rows': 2, + 'placeholder': _( + 'Albert Einstein Road 52' + ) + }), + ) + }, + 'invoice_address_from_name': { + 'default': '', + 'type': str, + 'form_class': forms.CharField, + 'serializer_class': serializers.CharField, + 'form_kwargs': dict( + label=_("Company name"), + ) + }, + 'invoice_address_from_zipcode': { + 'default': '', + 'type': str, + 'form_class': forms.CharField, + 'serializer_class': serializers.CharField, + 'form_kwargs': dict( + widget=forms.TextInput(attrs={ + 'placeholder': '12345' + }), + label=_("ZIP code"), + ) + }, + 'invoice_address_from_city': { + 'default': '', + 'type': str, + 'form_class': forms.CharField, + 'serializer_class': serializers.CharField, + 'form_kwargs': dict( + widget=forms.TextInput(attrs={ + 'placeholder': _('Random City') + }), + label=_("City"), + ) + }, + 'invoice_address_from_country': { + 'default': '', + 'type': str, + 'form_class': forms.ChoiceField, + 'serializer_class': serializers.ChoiceField, + 'serializer_kwargs': dict( + choices=allcountries, + ), + 'form_kwargs': dict( + choices=allcountries, + label=_("Country"), + ) + }, + 'invoice_address_from_tax_id': { + 'default': '', + 'type': str, + 'form_class': forms.CharField, + 'serializer_class': serializers.CharField, + 'form_kwargs': dict( + label=_("Domestic tax ID"), + ) + }, + 'invoice_address_from_vat_id': { + 'default': '', + 'type': str, + 'form_class': forms.CharField, + 'serializer_class': serializers.CharField, + 'form_kwargs': dict( + label=_("EU VAT ID"), + ) }, 'invoice_introductory_text': { 'default': '', - 'type': LazyI18nString + 'type': LazyI18nString, + 'form_class': I18nFormField, + 'serializer_class': I18nField, + 'form_kwargs': dict( + widget=I18nTextarea, + widget_kwargs={'attrs': { + 'rows': 3, + 'placeholder': _( + 'e.g. With this document, we sent you the invoice for your ticket order.' + ) + }}, + label=_("Introductory text"), + help_text=_("Will be printed on every invoice above the invoice rows.") + ) }, 'invoice_additional_text': { 'default': '', - 'type': LazyI18nString + 'type': LazyI18nString, + 'form_class': I18nFormField, + 'serializer_class': I18nField, + 'form_kwargs': dict( + widget=I18nTextarea, + widget_kwargs={'attrs': { + 'rows': 3, + 'placeholder': _( + 'e.g. Thank you for your purchase! You can find more information on the event at ...' + ) + }}, + label=_("Additional text"), + help_text=_("Will be printed on every invoice below the invoice total.") + ) }, 'invoice_footer_text': { 'default': '', - 'type': LazyI18nString + 'type': LazyI18nString, + 'form_class': I18nFormField, + 'serializer_class': I18nField, + 'form_kwargs': dict( + widget=I18nTextarea, + widget_kwargs={'attrs': { + 'rows': 5, + 'placeholder': _( + 'e.g. your bank details, legal details like your VAT ID, registration numbers, etc.' + ) + }}, + label=_("Footer"), + help_text=_("Will be printed centered and in a smaller font at the end of every invoice page.") + ) }, 'invoice_language': { 'default': '__user__', @@ -187,11 +572,26 @@ DEFAULTS = { }, 'invoice_email_attachment': { 'default': 'False', - 'type': bool + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Attach invoices to emails"), + help_text=_("If invoices are automatically generated for all orders, they will be attached to the order " + "confirmation mail. If they are automatically generated on payment, they will be attached to the " + "payment confirmation mail. If they are not automatically generated, they will not be attached " + "to emails."), + ) }, 'show_items_outside_presale_period': { 'default': 'True', - 'type': bool + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Show items outside presale period"), + help_text=_("Show item details before presale has started and after presale has ended"), + ) }, 'timezone': { 'default': settings.TIME_ZONE, @@ -199,55 +599,180 @@ DEFAULTS = { }, 'locales': { 'default': json.dumps([settings.LANGUAGE_CODE]), - 'type': list + 'type': list, + 'serializer_class': serializers.MultipleChoiceField, + 'serializer_kwargs': dict( + choices=settings.LANGUAGES, + required=True, + ), + 'form_class': forms.MultipleChoiceField, + 'form_kwargs': dict( + choices=settings.LANGUAGES, + widget=MultipleLanguagesWidget, + required=True, + label=_("Available languages"), + ) }, 'locale': { 'default': settings.LANGUAGE_CODE, - 'type': str + 'type': str, + 'serializer_class': serializers.ChoiceField, + 'serializer_kwargs': dict( + choices=settings.LANGUAGES, + required=True, + ), + 'form_class': forms.ChoiceField, + 'form_kwargs': dict( + choices=settings.LANGUAGES, + widget=SingleLanguageWidget, + required=True, + label=_("Default language"), + ) }, 'show_date_to': { 'default': 'True', - 'type': bool + 'type': bool, + 'serializer_class': serializers.BooleanField, + 'form_class': forms.BooleanField, + 'form_kwargs': dict( + label=_("Show event end date"), + help_text=_("If disabled, only event's start date will be displayed to the public."), + ) }, 'show_times': { 'default': 'True', - 'type': bool + 'type': bool, + 'serializer_class': serializers.BooleanField, + 'form_class': forms.BooleanField, + 'form_kwargs': dict( + label=_("Show dates with time"), + help_text=_("If disabled, the event's start and end date will be displayed without the time of day."), + ) + }, + 'hide_sold_out': { + 'default': 'False', + 'type': bool, + 'serializer_class': serializers.BooleanField, + 'form_class': forms.BooleanField, + 'form_kwargs': dict( + label=_("Hide all products that are sold out"), + ) }, 'show_quota_left': { 'default': 'False', - 'type': bool + 'type': bool, + 'serializer_class': serializers.BooleanField, + 'form_class': forms.BooleanField, + 'form_kwargs': dict( + label=_("Show number of tickets left"), + help_text=_("Publicly show how many tickets of a certain type are still available."), + ) + }, + 'meta_noindex': { + 'default': 'False', + 'type': bool, + 'serializer_class': serializers.BooleanField, + 'form_class': forms.BooleanField, + 'form_kwargs': dict( + label=_('Ask search engines not to index the ticket shop'), + ) }, 'show_variations_expanded': { 'default': 'False', - 'type': bool + 'type': bool, + 'serializer_class': serializers.BooleanField, + 'form_class': forms.BooleanField, + 'form_kwargs': dict( + label=_("Show variations of a product expanded by default"), + ) }, 'waiting_list_enabled': { 'default': 'False', - 'type': bool + 'type': bool, + 'serializer_class': serializers.BooleanField, + 'form_class': forms.BooleanField, + 'form_kwargs': dict( + label=_("Enable waiting list"), + help_text=_("Once a ticket is sold out, people can add themselves to a waiting list. As soon as a ticket " + "becomes available again, it will be reserved for the first person on the waiting list and this " + "person will receive an email notification with a voucher that can be used to buy a ticket."), + ) }, 'waiting_list_auto': { 'default': 'True', - 'type': bool + 'type': bool, + 'serializer_class': serializers.BooleanField, + 'form_class': forms.BooleanField, + 'form_kwargs': dict( + label=_("Automatic waiting list assignments"), + help_text=_("If ticket capacity becomes free, automatically create a voucher and send it to the first person " + "on the waiting list for that product. If this is not active, mails will not be send automatically " + "but you can send them manually via the control panel. If you disable the waiting list but keep " + "this option enabled, tickets will still be sent out."), + widget=forms.CheckboxInput(), + ) }, 'waiting_list_hours': { 'default': '48', - 'type': int + 'type': int, + 'serializer_class': serializers.IntegerField, + 'form_class': forms.IntegerField, + 'form_kwargs': dict( + label=_("Waiting list response time"), + min_value=6, + help_text=_("If a ticket voucher is sent to a person on the waiting list, it has to be redeemed within this " + "number of hours until it expires and can be re-assigned to the next person on the list."), + widget=forms.NumberInput(), + ) }, 'ticket_download': { 'default': 'False', - 'type': bool + 'type': bool, + 'serializer_class': serializers.BooleanField, + 'form_class': forms.BooleanField, + 'form_kwargs': dict( + label=_("Use feature"), + help_text=_("Use pretix to generate tickets for the user to download and print out."), + ) }, 'ticket_download_date': { 'default': None, - 'type': RelativeDateWrapper + 'type': RelativeDateWrapper, + 'form_class': RelativeDateTimeField, + 'serializer_class': SerializerRelativeDateTimeField, + 'form_kwargs': dict( + label=_("Download date"), + help_text=_("Ticket download will be offered after this date. If you use the event series feature and an order " + "contains tickets for multiple event dates, download of all tickets will be available if at least " + "one of the event dates allows it."), + ) }, 'ticket_download_addons': { 'default': 'False', - 'type': bool + 'type': bool, + 'serializer_class': serializers.BooleanField, + 'form_class': forms.BooleanField, + 'form_kwargs': dict( + label=_("Offer to download tickets separately for add-on products"), + ) }, 'ticket_download_nonadm': { 'default': 'True', - 'type': bool + 'type': bool, + 'serializer_class': serializers.BooleanField, + 'form_class': forms.BooleanField, + 'form_kwargs': dict( + label=_("Generate tickets for non-admission products"), + ) + }, + 'ticket_download_pending': { + 'default': 'False', + 'type': bool, + 'serializer_class': serializers.BooleanField, + 'form_class': forms.BooleanField, + 'form_kwargs': dict( + label=_("Offer to download tickets even before an order is paid"), + ) }, 'event_list_availability': { 'default': 'True', @@ -259,47 +784,120 @@ DEFAULTS = { }, 'last_order_modification_date': { 'default': None, - 'type': RelativeDateWrapper + 'type': RelativeDateWrapper, + 'form_class': RelativeDateTimeField, + 'serializer_class': SerializerRelativeDateTimeField, + 'form_kwargs': dict( + label=_('Last date of modifications'), + help_text=_("The last date users can modify details of their orders, such as attendee names or " + "answers to questions. If you use the event series feature and an order contains tickets for " + "multiple event dates, the earliest date will be used."), + ) }, 'cancel_allow_user': { 'default': 'True', - 'type': bool + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Customers can cancel their unpaid orders"), + ) }, 'cancel_allow_user_until': { 'default': None, 'type': RelativeDateWrapper, + 'form_class': RelativeDateTimeField, + 'serializer_class': SerializerRelativeDateTimeField, + 'form_kwargs': dict( + label=_("Do not allow cancellations after"), + ) }, 'cancel_allow_user_paid': { 'default': 'False', 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Customers can cancel their paid orders"), + help_text=_("Paid money will be automatically paid back if the payment method allows it. " + "Otherwise, a manual refund will be created for you to process manually."), + ) }, 'cancel_allow_user_paid_keep': { 'default': '0.00', 'type': Decimal, + 'form_class': forms.DecimalField, + 'serializer_class': serializers.DecimalField, + 'serializer_kwargs': dict( + max_digits=10, decimal_places=2 + ), + 'form_kwargs': dict( + label=_("Keep a fixed cancellation fee"), + ) }, 'cancel_allow_user_paid_keep_fees': { 'default': 'False', 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Keep payment, shipping and service fees"), + ) }, 'cancel_allow_user_paid_keep_percentage': { 'default': '0.00', 'type': Decimal, + 'form_class': forms.DecimalField, + 'serializer_class': serializers.DecimalField, + 'serializer_kwargs': dict( + max_digits=10, decimal_places=2 + ), + 'form_kwargs': dict( + label=_("Keep a percentual cancellation fee"), + ) }, 'cancel_allow_user_paid_until': { 'default': None, 'type': RelativeDateWrapper, + 'form_class': RelativeDateTimeField, + 'serializer_class': SerializerRelativeDateTimeField, + 'form_kwargs': dict( + label=_("Do not allow cancellations after"), + ) }, 'contact_mail': { 'default': None, - 'type': str + 'type': str, + 'serializer_class': serializers.EmailField, + 'form_class': forms.EmailField, + 'form_kwargs': dict( + label=_("Contact address"), + help_text=_("We'll show this publicly to allow attendees to contact you.") + ) }, 'imprint_url': { 'default': None, - 'type': str + 'type': str, + 'form_class': forms.URLField, + 'form_kwargs': dict( + label=_("Imprint URL"), + help_text=_("This should point e.g. to a part of your website that has your contact details and legal " + "information."), + ), + 'serializer_class': serializers.URLField, }, 'confirm_text': { 'default': None, - 'type': LazyI18nString + 'type': LazyI18nString, + 'form_class': I18nFormField, + 'serializer_class': I18nField, + 'form_kwargs': dict( + label=_('Confirmation text'), + help_text=_('This text needs to be confirmed by the user before a purchase is possible. You could for example ' + 'link your terms of service here. If you use the Pages feature to publish your terms of service, ' + 'you don\'t need this setting since you can configure it there.'), + widget=I18nTextarea, + ) }, 'mail_html_renderer': { 'default': 'classic', @@ -307,11 +905,24 @@ DEFAULTS = { }, 'mail_attach_ical': { 'default': 'False', - 'type': bool + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Attach calendar files"), + help_text=_("If enabled, we will attach an .ics calendar file to order confirmation emails."), + ) }, 'mail_prefix': { 'default': None, - 'type': str + 'type': str, + 'form_class': forms.CharField, + 'serializer_class': serializers.CharField, + 'form_kwargs': dict( + label=_("Subject prefix"), + help_text=_("This will be prepended to the subject of all outgoing emails, formatted as [prefix]. " + "Choose, for example, a short form of your event name."), + ) }, 'mail_bcc': { 'default': None, @@ -319,11 +930,24 @@ DEFAULTS = { }, 'mail_from': { 'default': settings.MAIL_FROM, - 'type': str + 'type': str, + 'form_class': forms.EmailField, + 'serializer_class': serializers.EmailField, + 'form_kwargs': dict( + label=_("Sender address"), + help_text=_("Sender address for outgoing emails"), + ) }, 'mail_from_name': { 'default': None, - 'type': str + 'type': str, + 'form_class': forms.EmailField, + 'serializer_class': serializers.EmailField, + 'form_kwargs': dict( + label=_("Sender name"), + help_text=_("Sender name used in conjunction with the sender address for outgoing emails. " + "Defaults to your event name."), + ) }, 'mail_text_signature': { 'type': LazyI18nString, @@ -626,7 +1250,7 @@ Your {event} team""")) }, 'primary_color': { 'default': '#8E44B3', - 'type': str + 'type': str, }, 'theme_color_success': { 'default': '#50A167', @@ -670,18 +1294,40 @@ Your {event} team""")) }, 'frontpage_text': { 'default': '', - 'type': LazyI18nString + 'type': LazyI18nString, + 'serializer_class': I18nField, + 'form_class': I18nFormField, + 'form_kwargs': dict( + label=_("Frontpage text"), + widget=I18nTextarea + ) }, 'voucher_explanation_text': { 'default': '', - 'type': LazyI18nString + 'type': LazyI18nString, + 'serializer_class': I18nField, + 'form_class': I18nFormField, + 'form_kwargs': dict( + label=_("Voucher explanation"), + widget=I18nTextarea, + widget_kwargs={'attrs': {'rows': '2'}}, + help_text=_("This text will be shown next to the input for a voucher code. You can use it e.g. to explain " + "how to obtain a voucher code.") + ) }, 'checkout_email_helptext': { 'default': LazyI18nString.from_gettext(ugettext_noop( 'Make sure to enter a valid email address. We will send you an order ' 'confirmation including a link that you need to access your order later.' )), - 'type': LazyI18nString + 'type': LazyI18nString, + 'serializer_class': I18nField, + 'form_class': I18nFormField, + 'form_kwargs': dict( + label=_("Help text of the email field"), + widget_kwargs={'attrs': {'rows': '2'}}, + widget=I18nTextarea + ) }, 'order_import_settings': { 'default': '{}', @@ -745,7 +1391,27 @@ Your {event} team""")) }, 'frontpage_subevent_ordering': { 'default': 'date_ascending', - 'type': str + 'type': str, + 'serializer_class': serializers.ChoiceField, + 'serializer_kwargs': dict( + choices=[ + ('date_ascending', _('Event start time')), + ('date_descending', _('Event start time (descending)')), + ('name_ascending', _('Name')), + ('name_descending', _('Name (descending)')), + ], + ), + 'form_class': forms.ChoiceField, + 'form_kwargs': dict( + label=pgettext('subevent', 'Date ordering'), + choices=[ + ('date_ascending', _('Event start time')), + ('date_descending', _('Event start time (descending)')), + ('name_ascending', _('Name')), + ('name_descending', _('Name (descending)')), + ], + # When adding a new ordering, remember to also define it in the event model + ) }, 'name_scheme': { 'default': 'full', @@ -1021,3 +1687,37 @@ class SettingsSandbox: def set(self, key: str, value: Any): self._event.settings.set(self._convert_key(key), value) + + +def validate_settings(event, settings_dict): + from pretix.base.signals import validate_event_settings + + if 'locales' in settings_dict and settings_dict['locale'] not in settings_dict['locales']: + raise ValidationError({ + 'locale': _('Your default locale must also be enabled for your event (see box above).') + }) + if settings_dict.get('attendee_names_required') and not settings_dict.get('attendee_names_asked'): + raise ValidationError({ + 'attendee_names_required': _('You cannot require specifying attendee names if you do not ask for them.') + }) + if settings_dict.get('attendee_emails_required') and not settings_dict.get('attendee_emails_asked'): + raise ValidationError({ + 'attendee_emails_required': _('You have to ask for attendee emails if you want to make them required.') + }) + if settings_dict.get('invoice_address_required') and not settings_dict.get('invoice_address_asked'): + raise ValidationError({ + 'invoice_address_required': _('You have to ask for invoice addresses if you want to make them required.') + }) + if settings_dict.get('invoice_address_company_required') and not settings_dict.get('invoice_address_required'): + raise ValidationError({ + 'invoice_address_company_requred': _('You have to require invoice addresses to require for company names.') + }) + + payment_term_last = settings_dict.get('payment_term_last') + if payment_term_last and event.presale_end: + if payment_term_last.date(event) < event.presale_end.date(): + raise ValidationError({ + 'payment_term_last': _('The last payment date cannot be before the end of presale.') + }) + + validate_event_settings.send(sender=event, settings_dict=settings_dict) diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index 3d5bc5155..4108b1b81 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -641,3 +641,27 @@ to define additional columns that can be read during import. You are expected to As with all event-plugin signals, the ``sender`` keyword argument will contain the event. """ + +validate_event_settings = EventPluginSignal( + providing_args=["settings_dict"] +) +""" +This signal is sent out if the user performs an update of event settings through the API or web interface. +You are passed a ``settings_dict`` dictionary with the new state of the event settings object and are expected +to raise a ``django.core.exceptions.ValidationError`` if the new state is not valid. +You can not modify the dictionary. This is only recommended to use if you have multiple settings +that can only be validated together. To validate individual settings, pass a validator to the +serializer field instead. + +As with all event-plugin signals, the ``sender`` keyword argument will contain the event. +""" + +api_event_settings_fields = EventPluginSignal( + providing_args=[] +) +""" +This signal is sent out to collect serializable settings fields for the API. You are expected to +return a dictionary mapping names of attributes in the settings store to DRF serializer field instances. + +As with all event-plugin signals, the ``sender`` keyword argument will contain the event. +""" diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index c9b9d358b..ebd42f470 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -3,19 +3,15 @@ from urllib.parse import urlencode from django import forms from django.conf import settings from django.core.exceptions import ValidationError -from django.core.validators import ( - MaxValueValidator, MinValueValidator, RegexValidator, validate_email, -) +from django.core.validators import RegexValidator, validate_email from django.db.models import Q from django.forms import formset_factory from django.urls import reverse from django.utils.html import escape from django.utils.safestring import mark_safe from django.utils.timezone import get_current_timezone_name -from django.utils.translation import ( - pgettext, pgettext_lazy, ugettext_lazy as _, -) -from django_countries import Countries, countries +from django.utils.translation import pgettext_lazy, ugettext_lazy as _ +from django_countries import Countries from django_countries.fields import LazyTypedChoiceField from i18nfield.forms import ( I18nForm, I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput, @@ -28,10 +24,12 @@ from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm from pretix.base.models import Event, Organizer, TaxRule, Team from pretix.base.models.event import EventMetaValue, SubEvent from pretix.base.reldate import RelativeDateField, RelativeDateTimeField -from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS +from pretix.base.settings import ( + PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_settings, +) from pretix.control.forms import ( - ExtFileField, FontSelect, MultipleLanguagesWidget, SingleLanguageWidget, - SlugWidget, SplitDateTimeField, SplitDateTimePickerWidget, + ExtFileField, FontSelect, MultipleLanguagesWidget, SlugWidget, + SplitDateTimeField, SplitDateTimePickerWidget, ) from pretix.control.forms.widgets import Select2 from pretix.multidomain.urlreverse import build_absolute_uri @@ -340,94 +338,10 @@ class EventUpdateForm(I18nModelForm): class EventSettingsForm(SettingsForm): - show_date_to = forms.BooleanField( - label=_("Show event end date"), - help_text=_("If disabled, only event's start date will be displayed to the public."), - required=False - ) - show_times = forms.BooleanField( - label=_("Show dates with time"), - help_text=_("If disabled, the event's start and end date will be displayed without the time of day."), - required=False - ) - show_items_outside_presale_period = forms.BooleanField( - label=_("Show items outside presale period"), - help_text=_("Show item details before presale has started and after presale has ended"), - required=False - ) - display_net_prices = forms.BooleanField( - label=_("Show net prices instead of gross prices in the product list (not recommended!)"), - help_text=_("Independent of your choice, the cart will show gross prices as this is the price that needs to be " - "paid"), - required=False - ) - presale_start_show_date = forms.BooleanField( - label=_("Show start date"), - help_text=_("Show the presale start date before presale has started."), - widget=forms.CheckboxInput, - required=False - ) - last_order_modification_date = RelativeDateTimeField( - label=_('Last date of modifications'), - help_text=_("The last date users can modify details of their orders, such as attendee names or " - "answers to questions. If you use the event series feature and an order contains tickets for " - "multiple event dates, the earliest date will be used."), - required=False, - ) timezone = forms.ChoiceField( choices=((a, a) for a in common_timezones), label=_("Event timezone"), ) - locales = forms.MultipleChoiceField( - choices=settings.LANGUAGES, - widget=MultipleLanguagesWidget, - label=_("Available languages"), - ) - locale = forms.ChoiceField( - choices=settings.LANGUAGES, - widget=SingleLanguageWidget, - label=_("Default language"), - ) - show_quota_left = forms.BooleanField( - label=_("Show number of tickets left"), - help_text=_("Publicly show how many tickets of a certain type are still available."), - required=False - ) - waiting_list_enabled = forms.BooleanField( - label=_("Enable waiting list"), - help_text=_("Once a ticket is sold out, people can add themselves to a waiting list. As soon as a ticket " - "becomes available again, it will be reserved for the first person on the waiting list and this " - "person will receive an email notification with a voucher that can be used to buy a ticket."), - required=False - ) - waiting_list_hours = forms.IntegerField( - label=_("Waiting list response time"), - min_value=6, - help_text=_("If a ticket voucher is sent to a person on the waiting list, it has to be redeemed within this " - "number of hours until it expires and can be re-assigned to the next person on the list."), - required=False, - widget=forms.NumberInput(), - ) - waiting_list_auto = forms.BooleanField( - label=_("Automatic waiting list assignments"), - help_text=_("If ticket capacity becomes free, automatically create a voucher and send it to the first person " - "on the waiting list for that product. If this is not active, mails will not be send automatically " - "but you can send them manually via the control panel. If you disable the waiting list but keep " - "this option enabled, tickets will still be sent out."), - required=False, - widget=forms.CheckboxInput(), - ) - attendee_names_asked = forms.BooleanField( - label=_("Ask for attendee names"), - help_text=_("Ask for a name for all tickets which include admission to the event."), - required=False, - ) - attendee_names_required = forms.BooleanField( - label=_("Require attendee names"), - help_text=_("Require customers to fill in the names of all attendees."), - required=False, - widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_names_asked'}), - ) name_scheme = forms.ChoiceField( label=_("Name format"), help_text=_("This defines how pretix will ask for human names. Changing this after you already received " @@ -440,83 +354,6 @@ class EventSettingsForm(SettingsForm): "restrict the set of selectable titles."), required=False, ) - attendee_emails_asked = forms.BooleanField( - label=_("Ask for email addresses per ticket"), - help_text=_("Normally, pretix asks for one email address per order and the order confirmation will be sent " - "only to that email address. If you enable this option, the system will additionally ask for " - "individual email addresses for every admission ticket. This might be useful if you want to " - "obtain individual addresses for every attendee even in case of group orders. However, " - "pretix will send the order confirmation by default only to the one primary email address, not to " - "the per-attendee addresses. You can however enable this in the E-mail settings."), - required=False - ) - attendee_emails_required = forms.BooleanField( - label=_("Require email addresses per ticket"), - help_text=_("Require customers to fill in individual e-mail addresses for all admission tickets. See the " - "above option for more details. One email address for the order confirmation will always be " - "required regardless of this setting."), - required=False, - widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_emails_asked'}), - ) - order_email_asked_twice = forms.BooleanField( - label=_("Ask for the order email address twice"), - help_text=_("Require customers to fill in the primary email address twice to avoid errors."), - required=False, - ) - max_items_per_order = forms.IntegerField( - min_value=1, - label=_("Maximum number of items per order"), - help_text=_("Add-on products will not be counted.") - ) - reservation_time = forms.IntegerField( - min_value=0, - label=_("Reservation period"), - help_text=_("The number of minutes the items in a user's cart are reserved for this user."), - ) - imprint_url = forms.URLField( - label=_("Imprint URL"), - help_text=_("This should point e.g. to a part of your website that has your contact details and legal " - "information."), - required=False, - ) - confirm_text = I18nFormField( - label=_('Confirmation text'), - help_text=_('This text needs to be confirmed by the user before a purchase is possible. You could for example ' - 'link your terms of service here. If you use the Pages feature to publish your terms of service, ' - 'you don\'t need this setting since you can configure it there.'), - required=False, - widget=I18nTextarea - ) - contact_mail = forms.EmailField( - label=_("Contact address"), - required=False, - help_text=_("We'll show this publicly to allow attendees to contact you.") - ) - show_variations_expanded = forms.BooleanField( - label=_("Show variations of a product expanded by default"), - required=False - ) - hide_sold_out = forms.BooleanField( - label=_("Hide all products that are sold out"), - required=False - ) - meta_noindex = forms.BooleanField( - label=_('Ask search engines not to index the ticket shop'), - required=False - ) - redirect_to_checkout_directly = forms.BooleanField( - label=_('Directly redirect to check-out after a product has been added to the cart.'), - required=False - ) - frontpage_subevent_ordering = forms.ChoiceField( - label=pgettext('subevent', 'Date ordering'), - choices=[ - ('date_ascending', _('Event start time')), - ('date_descending', _('Event start time (descending)')), - ('name_ascending', _('Name')), - ('name_descending', _('Name (descending)')), - ], # When adding a new ordering, remember to also define it in the event model - ) logo_image = ExtFileField( label=_('Logo image'), ext_whitelist=(".png", ".jpg", ".gif", ".jpeg"), @@ -533,33 +370,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.') ) - frontpage_text = I18nFormField( - label=_("Frontpage text"), - required=False, - widget=I18nTextarea - ) - checkout_email_helptext = I18nFormField( - label=_("Help text of the email field"), - required=False, - widget_kwargs={'attrs': {'rows': '2'}}, - widget=I18nTextarea - ) - presale_has_ended_text = I18nFormField( - label=_("End of presale text"), - required=False, - widget=I18nTextarea, - widget_kwargs={'attrs': {'rows': '2'}}, - help_text=_("This text will be shown above the ticket shop once the designated sales timeframe for this event " - "is over. You can use it to describe other options to get a ticket, such as a box office.") - ) - voucher_explanation_text = I18nFormField( - label=_("Voucher explanation"), - required=False, - widget=I18nTextarea, - widget_kwargs={'attrs': {'rows': '2'}}, - help_text=_("This text will be shown next to the input for a voucher code. You can use it e.g. to explain " - "how to obtain a voucher code.") - ) primary_color = forms.CharField( label=_("Primary color"), required=False, @@ -598,24 +408,49 @@ class EventSettingsForm(SettingsForm): help_text=_('Only respected by modern browsers.') ) + auto_fields = [ + 'imprint_url', + 'checkout_email_helptext', + 'presale_has_ended_text', + 'voucher_explanation_text', + 'show_date_to', + 'show_times', + 'show_items_outside_presale_period', + 'display_net_prices', + 'presale_start_show_date', + 'locales', + 'locale', + 'show_quota_left', + 'waiting_list_enabled', + 'waiting_list_hours', + 'waiting_list_auto', + 'max_items_per_order', + 'reservation_time', + 'contact_mail', + 'show_variations_expanded', + 'hide_sold_out', + 'meta_noindex', + 'redirect_to_checkout_directly', + 'frontpage_subevent_ordering', + 'frontpage_text', + 'attendee_names_asked', + 'attendee_names_required', + 'attendee_emails_asked', + 'attendee_emails_required', + 'confirm_text', + 'order_email_asked_twice', + 'last_order_modification_date', + ] + def clean(self): data = super().clean() - if 'locales' in data and data['locale'] not in data['locales']: - raise ValidationError({ - 'locale': _('Your default locale must also be enabled for your event (see box above).') - }) - if data['attendee_names_required'] and not data['attendee_names_asked']: - raise ValidationError({ - 'attendee_names_required': _('You cannot require specifying attendee names if you do not ask for them.') - }) - if data['attendee_emails_required'] and not data['attendee_emails_asked']: - raise ValidationError({ - 'attendee_emails_required': _('You have to ask for attendee emails if you want to make them required.') - }) + settings_dict = self.event.settings.freeze() + settings_dict.update(data) + validate_settings(self.event, data) return data def __init__(self, *args, **kwargs): - event = kwargs['obj'] + self.event = kwargs['obj'] super().__init__(*args, **kwargs) self.fields['confirm_text'].widget.attrs['rows'] = '3' self.fields['confirm_text'].widget.attrs['placeholder'] = _( @@ -636,7 +471,7 @@ class EventSettingsForm(SettingsForm): )) for k, v in PERSON_NAME_TITLE_GROUPS.items() ] - if not event.has_subevents: + if not self.event.has_subevents: del self.fields['frontpage_subevent_ordering'] self.fields['primary_font'].choices += [ (a, {"title": a, "data": v}) for a, v in get_fonts().items() @@ -644,77 +479,26 @@ class EventSettingsForm(SettingsForm): class CancelSettingsForm(SettingsForm): - cancel_allow_user = forms.BooleanField( - label=_("Customers can cancel their unpaid orders"), - required=False - ) - cancel_allow_user_until = RelativeDateTimeField( - label=_("Do not allow cancellations after"), - required=False - ) - cancel_allow_user_paid = forms.BooleanField( - label=_("Customers can cancel their paid orders"), - help_text=_("Paid money will be automatically paid back if the payment method allows it. " - "Otherwise, a manual refund will be created for you to process manually."), - required=False - ) - cancel_allow_user_paid_keep = forms.DecimalField( - label=_("Keep a fixed cancellation fee"), - required=False - ) - cancel_allow_user_paid_keep_fees = forms.BooleanField( - label=_("Keep payment, shipping and service fees"), - required=False - ) - cancel_allow_user_paid_keep_percentage = forms.DecimalField( - label=_("Keep a percentual cancellation fee"), - required=False - ) - cancel_allow_user_paid_until = RelativeDateTimeField( - label=_("Do not allow cancellations after"), - required=False - ) + auto_fields = [ + 'cancel_allow_user', + 'cancel_allow_user_until', + 'cancel_allow_user_paid', + 'cancel_allow_user_paid_until', + 'cancel_allow_user_paid_keep', + 'cancel_allow_user_paid_keep_fees', + 'cancel_allow_user_paid_keep_percentage', + ] class PaymentSettingsForm(SettingsForm): - payment_term_days = forms.IntegerField( - label=_('Payment term in days'), - help_text=_("The number of days after placing an order the user has to pay to preserve their reservation. If " - "you use slow payment methods like bank transfer, we recommend 14 days. If you only use real-time " - "payment methods, we recommend still setting two or three days to allow people to retry failed " - "payments."), - validators=[MinValueValidator(0), - MaxValueValidator(1000000)] - - ) - payment_term_last = RelativeDateField( - label=_('Last date of payments'), - help_text=_("The last date any payments are accepted. This has precedence over the number of " - "days configured above. If you use the event series feature and an order contains tickets for " - "multiple dates, the earliest date will be used."), - required=False, - ) - payment_term_weekdays = forms.BooleanField( - label=_('Only end payment terms on weekdays'), - help_text=_("If this is activated and the payment term of any order ends on a Saturday or Sunday, it will be " - "moved to the next Monday instead. This is required in some countries by civil law. This will " - "not effect the last date of payments configured above."), - required=False, - ) - payment_term_expire_automatically = forms.BooleanField( - label=_('Automatically expire unpaid orders'), - help_text=_("If checked, all unpaid orders will automatically go from 'pending' to 'expired' " - "after the end of their payment deadline. This means that those tickets go back to " - "the pool and can be ordered by other people."), - required=False - ) - payment_term_accept_late = forms.BooleanField( - label=_('Accept late payments'), - help_text=_("Accept payments for orders even when they are in 'expired' state as long as enough " - "capacity is available. No payments will ever be accepted after the 'Last date of payments' " - "configured above."), - required=False - ) + auto_fields = [ + 'payment_term_days', + 'payment_term_last', + 'payment_term_weekdays', + 'payment_term_expire_automatically', + 'payment_term_accept_late', + 'payment_explanation', + ] tax_rate_default = forms.ModelChoiceField( queryset=TaxRule.objects.none(), label=_('Tax rule for payment fees'), @@ -722,27 +506,13 @@ class PaymentSettingsForm(SettingsForm): help_text=_("The tax rule that applies for additional fees you configured for single payment methods. This " "will set the tax rate and reverse charge rules, other settings of the tax rule are ignored.") ) - payment_explanation = I18nFormField( - widget=I18nTextarea, - 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.") - ) def clean(self): - cleaned_data = super().clean() - payment_term_last = cleaned_data.get('payment_term_last') - if payment_term_last and self.obj.presale_end: - if payment_term_last.date(self.obj) < self.obj.presale_end.date(): - self.add_error( - 'payment_term_last', - _('The last payment date cannot be before the end of presale.'), - ) - return cleaned_data + data = super().clean() + settings_dict = self.obj.settings.freeze() + settings_dict.update(data) + validate_settings(self.obj, data) + return data def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -790,90 +560,37 @@ class ProviderForm(SettingsForm): class InvoiceSettingsForm(SettingsForm): - allcountries = list(countries) - allcountries.insert(0, ('', _('Select country'))) - invoice_address_asked = forms.BooleanField( - label=_("Ask for invoice address"), - required=False - ) - invoice_address_required = forms.BooleanField( - label=_("Require invoice address"), - required=False, - widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}), - ) - invoice_address_company_required = forms.BooleanField( - label=_("Require a business addresses"), - help_text=_('This will require users to enter a company name.'), - required=False, - widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_required'}), - ) - invoice_name_required = forms.BooleanField( - label=_("Require customer name"), - required=False, - ) - invoice_address_vatid = forms.BooleanField( - label=_("Ask for VAT ID"), - help_text=_("Does only work if an invoice address is asked for. VAT ID is not required."), - widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}), - required=False - ) - invoice_address_beneficiary = forms.BooleanField( - label=_("Ask for beneficiary"), - widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}), - required=False - ) - invoice_address_not_asked_free = forms.BooleanField( - label=_('Do not ask for invoice address if an order is free'), - required=False - ) - invoice_include_free = forms.BooleanField( - label=_("Show free products on invoices"), - help_text=_("Note that invoices will never be generated for orders that contain only free " - "products."), - required=False - ) - invoice_address_explanation_text = I18nFormField( - label=_("Invoice address explanation"), - required=False, - widget=I18nTextarea, - widget_kwargs={'attrs': {'rows': '2'}}, - help_text=_("This text will be shown above the invoice address form during checkout.") - ) - invoice_numbers_consecutive = forms.BooleanField( - label=_("Generate invoices with consecutive numbers"), - help_text=_("If deactivated, the order code will be used in the invoice number."), - required=False - ) - invoice_numbers_prefix = forms.CharField( - label=_("Invoice number prefix"), - help_text=_("This will be prepended to invoice numbers. If you leave this field empty, your event slug will " - "be used followed by a dash. Attention: If multiple events within the same organization use the " - "same value in this field, they will share their number range, i.e. every full number will be " - "used at most once over all of your events. This setting only affects future invoices. You can " - "use %Y (with century) %y (without century) to insert the year of the invoice, or %m and %d for " - "the day of month."), - required=False, - ) - invoice_numbers_prefix_cancellations = forms.CharField( - label=_("Invoice number prefix for cancellations"), - help_text=_("This will be prepended to invoice numbers of cancellations. If you leave this field empty, " - "the same numbering scheme will be used that you configured for regular invoices."), - required=False, - ) - invoice_generate = forms.ChoiceField( - label=_("Generate invoices"), - required=False, - widget=forms.RadioSelect, - choices=( - ('False', _('Do not generate invoices')), - ('admin', _('Only manually in admin panel')), - ('user', _('Automatically on user request')), - ('True', _('Automatically for all created orders')), - ('paid', _('Automatically on payment')), - ), - help_text=_("Invoices will never be automatically generated for free orders.") - ) + auto_fields = [ + 'invoice_address_asked', + 'invoice_address_required', + 'invoice_address_vatid', + 'invoice_address_company_required', + 'invoice_address_beneficiary', + 'invoice_name_required', + 'invoice_address_not_asked_free', + 'invoice_include_free', + 'invoice_generate', + 'invoice_attendee_name', + 'invoice_include_expire_date', + 'invoice_numbers_consecutive', + 'invoice_numbers_prefix', + 'invoice_numbers_prefix_cancellations', + 'invoice_address_explanation_text', + 'invoice_email_attachment', + 'invoice_address_from_name', + 'invoice_address_from', + 'invoice_address_from_zipcode', + 'invoice_address_from_city', + 'invoice_address_from_country', + 'invoice_address_from_tax_id', + 'invoice_address_from_vat_id', + 'invoice_introductory_text', + 'invoice_additional_text', + 'invoice_footer_text', + + ] + invoice_generate_sales_channels = forms.MultipleChoiceField( label=_('Generate invoices for Sales channels'), choices=[], @@ -881,105 +598,11 @@ class InvoiceSettingsForm(SettingsForm): help_text=_("If you have enabled invoice generation in the previous setting, you can limit it here to specific " "sales channels.") ) - invoice_attendee_name = forms.BooleanField( - label=_("Show attendee names on invoices"), - required=False - ) - invoice_include_expire_date = forms.BooleanField( - label=_("Show expiration date of order"), - help_text=_("The expiration date will not be shown if the invoice is generated after the order is paid."), - required=False - ) - invoice_email_attachment = forms.BooleanField( - label=_("Attach invoices to emails"), - help_text=_("If invoices are automatically generated for all orders, they will be attached to the order " - "confirmation mail. If they are automatically generated on payment, they will be attached to the " - "payment confirmation mail. If they are not automatically generated, they will not be attached " - "to emails."), - required=False - ) invoice_renderer = forms.ChoiceField( label=_("Invoice style"), required=True, choices=[] ) - invoice_address_from_name = forms.CharField( - label=_("Company name"), - required=False, - ) - invoice_address_from = forms.CharField( - label=_("Address line"), - widget=forms.Textarea(attrs={ - 'rows': 2, - 'placeholder': _( - 'Albert Einstein Road 52' - ) - }), - required=False, - ) - invoice_address_from_zipcode = forms.CharField( - widget=forms.TextInput(attrs={ - 'placeholder': '12345' - }), - required=False, - label=_("ZIP code"), - ) - invoice_address_from_city = forms.CharField( - widget=forms.TextInput(attrs={ - 'placeholder': _('Random City') - }), - required=False, - label=_("City"), - ) - invoice_address_from_country = forms.ChoiceField( - choices=allcountries, - required=False, - label=_("Country"), - ) - invoice_address_from_tax_id = forms.CharField( - required=False, - label=_("Domestic tax ID"), - ) - invoice_address_from_vat_id = forms.CharField( - required=False, - label=_("EU VAT ID"), - ) - invoice_introductory_text = I18nFormField( - widget=I18nTextarea, - widget_kwargs={'attrs': { - 'rows': 3, - 'placeholder': _( - 'e.g. With this document, we sent you the invoice for your ticket order.' - ) - }}, - required=False, - label=_("Introductory text"), - help_text=_("Will be printed on every invoice above the invoice rows.") - ) - invoice_additional_text = I18nFormField( - widget=I18nTextarea, - widget_kwargs={'attrs': { - 'rows': 3, - 'placeholder': _( - 'e.g. Thank you for your purchase! You can find more information on the event at ...' - ) - }}, - required=False, - label=_("Additional text"), - help_text=_("Will be printed on every invoice below the invoice total.") - ) - invoice_footer_text = I18nFormField( - widget=I18nTextarea, - widget_kwargs={'attrs': { - 'rows': 5, - 'placeholder': _( - 'e.g. your bank details, legal details like your VAT ID, registration numbers, etc.' - ) - }}, - required=False, - label=_("Footer"), - help_text=_("Will be printed centered and in a smaller font at the end of every invoice page.") - ) invoice_language = forms.ChoiceField( widget=forms.Select, required=True, label=_("Invoice language"), @@ -1009,6 +632,13 @@ class InvoiceSettingsForm(SettingsForm): (c.identifier, c.verbose_name) for c in get_all_sales_channels().values() ) + def clean(self): + data = super().clean() + settings_dict = self.obj.settings.freeze() + settings_dict.update(data) + validate_settings(self.obj, data) + return data + def multimail_validate(val): s = val.split(',') @@ -1018,22 +648,13 @@ def multimail_validate(val): class MailSettingsForm(SettingsForm): - mail_prefix = forms.CharField( - label=_("Subject prefix"), - help_text=_("This will be prepended to the subject of all outgoing emails, formatted as [prefix]. " - "Choose, for example, a short form of your event name."), - required=False - ) - mail_from = forms.EmailField( - label=_("Sender address"), - help_text=_("Sender address for outgoing emails"), - ) - mail_from_name = forms.CharField( - label=_("Sender name"), - help_text=_("Sender name used in conjunction with the sender address for outgoing emails. " - "Defaults to your event name."), - required=False - ) + auto_fields = [ + 'mail_prefix', + 'mail_from', + 'mail_from_name', + 'mail_attach_ical', + ] + mail_bcc = forms.CharField( label=_("Bcc address"), help_text=_("All emails will be sent to this address as a Bcc copy"), @@ -1041,12 +662,6 @@ class MailSettingsForm(SettingsForm): required=False, max_length=255 ) - mail_attach_ical = forms.BooleanField( - label=_("Attach calendar files"), - help_text=_("If enabled, we will attach an .ics calendar file to order confirmation emails."), - required=False - ) - mail_text_signature = I18nFormField( label=_("Signature"), required=False, @@ -1065,7 +680,6 @@ class MailSettingsForm(SettingsForm): required=True, choices=[] ) - mail_text_order_placed = I18nFormField( label=_("Text sent to order contact address"), required=False, @@ -1301,30 +915,13 @@ class MailSettingsForm(SettingsForm): class TicketSettingsForm(SettingsForm): - ticket_download = forms.BooleanField( - label=_("Use feature"), - help_text=_("Use pretix to generate tickets for the user to download and print out."), - required=False - ) - ticket_download_date = RelativeDateTimeField( - label=_("Download date"), - help_text=_("Ticket download will be offered after this date. If you use the event series feature and an order " - "contains tickets for multiple event dates, download of all tickets will be available if at least " - "one of the event dates allows it."), - required=False, - ) - ticket_download_addons = forms.BooleanField( - label=_("Offer to download tickets separately for add-on products"), - required=False, - ) - ticket_download_nonadm = forms.BooleanField( - label=_("Generate tickets for non-admission products"), - required=False, - ) - ticket_download_pending = forms.BooleanField( - label=_("Offer to download tickets even before an order is paid"), - required=False, - ) + auto_fields = [ + 'ticket_download', + 'ticket_download_date', + 'ticket_download_addons', + 'ticket_download_nonadm', + 'ticket_download_pending', + ] def prepare_fields(self): # See clean() diff --git a/src/tests/api/test_events.py b/src/tests/api/test_events.py index 798c52a5e..6c3c6017a 100644 --- a/src/tests/api/test_events.py +++ b/src/tests/api/test_events.py @@ -941,3 +941,102 @@ def test_event_create_with_seating_maps(token_client, organizer, event, meta_pro ) assert resp.status_code == 400 assert resp.content.decode() == '{"seat_category_mapping":["You cannot specify seat category mappings on event creation."]}' + + +@pytest.mark.django_db +def test_get_event_settings(token_client, organizer, event): + event.settings.imprint_url = "https://example.org" + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), + ) + assert resp.status_code == 200 + assert resp.data['imprint_url'] == "https://example.org" + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/settings/?explain=true'.format(organizer.slug, event.slug), + ) + assert resp.status_code == 200 + assert resp.data['imprint_url'] == { + "value": "https://example.org", + "label": "Imprint URL", + "help_text": "This should point e.g. to a part of your website that has your contact details and legal " + "information." + } + + +@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' + + 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.put( + '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), + { + 'imprint_url': 'invalid' + }, + format='json' + ) + assert resp.status_code == 405 + + +@pytest.mark.django_db +def test_patch_event_settings_validation(token_client, organizer, event): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), + { + 'imprint_url': 'invalid' + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.data == { + 'imprint_url': ['Enter a valid URL.'] + } + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), + { + 'invoice_address_required': True, + 'invoice_address_asked': False, + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.data == { + 'invoice_address_required': ['You have to ask for invoice addresses if you want to make them required.'] + } + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), + { + 'cancel_allow_user_until': 'RELDATE/3/12:00/foobar/', + 'invoice_address_asked': False, + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.data == { + 'cancel_allow_user_until': ['Invalid relative date'] + } diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index 721b7b6b9..d3837e173 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -23,6 +23,8 @@ event_urls = [ ] event_permission_sub_urls = [ + ('get', 'can_change_event_settings', 'settings/', 200), + ('patch', 'can_change_event_settings', 'settings/', 200), ('get', 'can_view_orders', 'orders/', 200), ('get', 'can_view_orders', 'orderpositions/', 200), ('delete', 'can_change_orders', 'orderpositions/1/', 404),