diff --git a/doc/development/api/cookieconsent.rst b/doc/development/api/cookieconsent.rst
new file mode 100644
index 0000000000..64e67f5bab
--- /dev/null
+++ b/doc/development/api/cookieconsent.rst
@@ -0,0 +1,119 @@
+.. highlight:: python
+ :linenothreshold: 5
+
+.. _`cookieconsent`:
+
+Handling cookie consent
+=======================
+
+pretix includes an optional feature to handle cookie consent explicitly to comply with EU regulations.
+If your plugin sets non-essential cookies or includes a third-party service that does so, you should
+integrate with this feature.
+
+Server-side integration
+-----------------------
+
+First, you need to declare that you are using non-essential cookies by responding to the following
+signal:
+
+.. automodule:: pretix.presale.signals
+ :members: register_cookie_providers
+
+You are expected to return a list of ``CookieProvider`` objects instantiated from the following class:
+
+.. class:: pretix.presale.cookies.CookieProvider
+
+ .. py:attribute:: CookieProvider.identifier
+
+ A short and unique identifier used to distinguish this cookie provider form others (required).
+
+ .. py:attribute:: CookieProvider.provider_name
+
+ A human-readable name of the entity of feature responsible for setting the cookie (required).
+
+ .. py:attribute:: CookieProvider.usage_classes
+
+ A list of enum values from the ``pretix.presale.cookies.UsageClass`` enumeration class, such as
+ ``UsageClass.ANALYTICS``, ``UsageClass.MARKETING``, or ``UsageClass.SOCIAL`` (required).
+
+ .. py:attribute:: CookieProvider.privacy_url
+
+ A link to a privacy policy (optional).
+
+Here is an example of such a receiver:
+
+.. code-block:: python
+
+ @receiver(register_cookie_providers)
+ def recv_cookie_providers(sender, request, **kwargs):
+ return [
+ CookieProvider(
+ identifier='google_analytics',
+ provider_name='Google Analytics',
+ usage_classes=[UsageClass.ANALYTICS],
+ )
+ ]
+
+JavaScript-side integration
+---------------------------
+
+The server-side integration only causes the cookie provider to show up in the cookie dialog. You still
+need to care about actually enforcing the consent state.
+
+You can access the consent state through the ``window.pretix.cookie_consent`` variable. Whenever the
+value changes, a ``pretix:cookie-consent:change`` event is fired on the ``document`` object.
+
+The variable will generally have one of the following states:
+
+.. rst-class:: rest-resource-table
+
+================================================================ =====================================================
+State Interpretation
+================================================================ =====================================================
+``pretix === undefined || pretix.cookie_consent === undefined`` Your JavaScript has loaded before the cookie consent
+ script. Wait for the event to be fired, then try again,
+ do not yet set a cookie.
+``pretix.cookie_consent === null`` The cookie consent mechanism has not been enabled. This
+ usually means that you can set cookies however you like.
+``pretix.cookie_consent[identifier] === undefined`` The cookie consent mechanism is loaded, but has no data
+ on your cookie yet, wait for the event to be fired, do not
+ yet set a cookie.
+``pretix.cookie_consent[identifier] === true`` The user has consented to your cookie.
+``pretix.cookie_consent[identifier] === false`` The user has actively rejected your cookie.
+================================================================ =====================================================
+
+If you are integrating e.g. a tracking provider with native cookie consent support such
+as Facebook's Pixel, you can integrate it like this:
+
+.. code-block:: javascript
+
+ var consent = (window.pretix || {}).cookie_consent;
+ if (consent !== null && !(consent || {}).facebook) {
+ fbq('consent', 'revoke');
+ }
+ fbq('init', ...);
+ document.addEventListener('pretix:cookie-consent:change', function (e) {
+ fbq('consent', (e.detail || {}).facebook ? 'grant' : 'revoke');
+ })
+
+If you have a JavaScript function that you only want to load if consent for a specific ``identifier``
+is given, you can wrap it like this:
+
+.. code-block:: javascript
+
+ var consent_identifier = "youridentifier";
+ var consent = (window.pretix || {}).cookie_consent;
+ if (consent === null || (consent || {})[consent_identifier] === true) {
+ // Cookie consent tool is either disabled or consent is given
+ addScriptElement(src);
+ return;
+ }
+
+ // Either cookie consent tool has not loaded yet or consent is not given
+ document.addEventListener('pretix:cookie-consent:change', function onChange(e) {
+ var consent = e.detail || {};
+ if (consent === null || consent[consent_identifier] === true) {
+ addScriptElement(src);
+ document.removeEventListener('pretix:cookie-consent:change', onChange);
+ }
+ })
diff --git a/doc/development/api/index.rst b/doc/development/api/index.rst
index bf4d2352b1..5ccf0596ad 100644
--- a/doc/development/api/index.rst
+++ b/doc/development/api/index.rst
@@ -17,6 +17,7 @@ Contents:
shredder
import
customview
+ cookieconsent
auth
general
quality
diff --git a/doc/user/events/widget.rst b/doc/user/events/widget.rst
index 6cda0b599e..bf816dd386 100644
--- a/doc/user/events/widget.rst
+++ b/doc/user/events/widget.rst
@@ -309,6 +309,10 @@ Currently, the following attributes are understood by pretix itself:
always be modified. Note that this is not a security feature and can easily be overridden by users, so do not rely
on this for authentication.
+* If ``data-consent="…"`` is given, the cookie consent mechanism will be initialized with consent for the given cookie
+ providers. All other providers will be disabled, no consent dialog will be shown. This is useful if you already
+ asked the user for consent and don't want them to be asked again. Example: ``data-consent="facebook,google_analytics"``
+
Any configured pretix plugins might understand more data fields. For example, if the appropriate plugins on pretix
Hosted or pretix Enterprise are active, you can pass the following fields:
diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py
index 2e49e44eea..b2c86ff56f 100644
--- a/src/pretix/api/serializers/organizer.py
+++ b/src/pretix/api/serializers/organizer.py
@@ -296,7 +296,14 @@ class OrganizerSettingsSerializer(SettingsSerializer):
'theme_round_borders',
'primary_font',
'organizer_logo_image_inherit',
- 'organizer_logo_image'
+ 'organizer_logo_image',
+ 'privacy_url',
+ 'cookie_consent',
+ 'cookie_consent_dialog_title',
+ 'cookie_consent_dialog_text',
+ 'cookie_consent_dialog_text_secondary',
+ 'cookie_consent_dialog_button_yes',
+ 'cookie_consent_dialog_button_no',
]
def __init__(self, *args, **kwargs):
diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py
index b5c72ba624..f1f85a4b4b 100644
--- a/src/pretix/base/models/organizer.py
+++ b/src/pretix/base/models/organizer.py
@@ -97,10 +97,21 @@ class Organizer(LoggedModel):
return self.name
def save(self, *args, **kwargs):
+ is_new = not self.pk
obj = super().save(*args, **kwargs)
- self.get_cache().clear()
+ if is_new:
+ self.set_defaults()
+ else:
+ self.get_cache().clear()
return obj
+ def set_defaults(self):
+ """
+ This will be called after organizer creation.
+ This way, we can use this to introduce new default settings to pretix that do not affect existing organizers.
+ """
+ self.settings.cookie_consent = True
+
def get_cache(self):
"""
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py
index bd6ca615be..a314874eda 100644
--- a/src/pretix/base/settings.py
+++ b/src/pretix/base/settings.py
@@ -1512,6 +1512,17 @@ DEFAULTS = {
),
'serializer_class': serializers.URLField,
},
+ 'privacy_url': {
+ 'default': None,
+ 'type': str,
+ 'form_class': forms.URLField,
+ 'form_kwargs': dict(
+ label=_("Privacy Policy URL"),
+ help_text=_("This should point e.g. to a part of your website that explains how you use data gathered in "
+ "your ticket shop."),
+ ),
+ 'serializer_class': serializers.URLField,
+ },
'confirm_texts': {
'default': LazyI18nStringList(),
'type': LazyI18nStringList,
@@ -2489,6 +2500,77 @@ Your {organizer} team"""))
'many years. If you keep it empty, gift cards do not have an explicit expiry date.'),
)
},
+ 'cookie_consent': {
+ 'default': 'False',
+ 'form_class': forms.BooleanField,
+ 'serializer_class': serializers.BooleanField,
+ 'form_kwargs': dict(
+ label=_("Enable cookie consent management features"),
+ ),
+ 'type': bool,
+ },
+ 'cookie_consent_dialog_text': {
+ 'default': LazyI18nString.from_gettext(gettext_noop(
+ 'By clicking "Accept all cookies", you agree to the storing of cookies and use of similar technologies on '
+ 'your device.'
+ )),
+ 'type': LazyI18nString,
+ 'serializer_class': I18nField,
+ 'form_class': I18nFormField,
+ 'form_kwargs': dict(
+ label=_("Dialog text"),
+ widget=I18nTextarea,
+ widget_kwargs={'attrs': {'rows': '3', 'data-display-dependency': '#id_settings-cookie_consent'}},
+ )
+ },
+ 'cookie_consent_dialog_text_secondary': {
+ 'default': LazyI18nString.from_gettext(gettext_noop(
+ 'We use cookies and similar technologies to gather data that allows us to improve this website and our '
+ 'offerings. If you do not agree, we will only use cookies if they are essential to providing the services '
+ 'this website offers.'
+ )),
+ 'type': LazyI18nString,
+ 'serializer_class': I18nField,
+ 'form_class': I18nFormField,
+ 'form_kwargs': dict(
+ label=_("Secondary dialog text"),
+ widget=I18nTextarea,
+ widget_kwargs={'attrs': {'rows': '3', 'data-display-dependency': '#id_settings-cookie_consent'}},
+ )
+ },
+ 'cookie_consent_dialog_title': {
+ 'default': LazyI18nString.from_gettext(gettext_noop('Privacy settings')),
+ 'type': LazyI18nString,
+ 'serializer_class': I18nField,
+ 'form_class': I18nFormField,
+ 'form_kwargs': dict(
+ label=_('Dialog title'),
+ widget=I18nTextInput,
+ widget_kwargs={'attrs': {'data-display-dependency': '#id_settings-cookie_consent'}},
+ )
+ },
+ 'cookie_consent_dialog_button_yes': {
+ 'default': LazyI18nString.from_gettext(gettext_noop('Accept all cookies')),
+ 'type': LazyI18nString,
+ 'serializer_class': I18nField,
+ 'form_class': I18nFormField,
+ 'form_kwargs': dict(
+ label=_('"Accept" button description'),
+ widget=I18nTextInput,
+ widget_kwargs={'attrs': {'data-display-dependency': '#id_settings-cookie_consent'}},
+ )
+ },
+ 'cookie_consent_dialog_button_no': {
+ 'default': LazyI18nString.from_gettext(gettext_noop('Required cookies only')),
+ 'type': LazyI18nString,
+ 'serializer_class': I18nField,
+ 'form_class': I18nFormField,
+ 'form_kwargs': dict(
+ label=_('"Reject" button description'),
+ widget=I18nTextInput,
+ widget_kwargs={'attrs': {'data-display-dependency': '#id_settings-cookie_consent'}},
+ )
+ },
'seating_choice': {
'default': 'True',
'form_class': forms.BooleanField,
diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py
index 158db11b84..82afb94a44 100644
--- a/src/pretix/control/forms/organizer.py
+++ b/src/pretix/control/forms/organizer.py
@@ -307,8 +307,14 @@ class OrganizerSettingsForm(SettingsForm):
'theme_color_danger',
'theme_color_background',
'theme_round_borders',
- 'primary_font'
-
+ 'primary_font',
+ 'privacy_url',
+ 'cookie_consent',
+ 'cookie_consent_dialog_title',
+ 'cookie_consent_dialog_text',
+ 'cookie_consent_dialog_text_secondary',
+ 'cookie_consent_dialog_button_yes',
+ 'cookie_consent_dialog_button_no',
]
organizer_logo_image = ExtFileField(
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/edit.html b/src/pretix/control/templates/pretixcontrol/organizers/edit.html
index 7f5a082f73..34bc2520e0 100644
--- a/src/pretix/control/templates/pretixcontrol/organizers/edit.html
+++ b/src/pretix/control/templates/pretixcontrol/organizers/edit.html
@@ -82,6 +82,49 @@
{% bootstrap_field sform.giftcard_expiry_years layout="control" %}
{% bootstrap_field sform.giftcard_length layout="control" %}
+