Organizer-level plugins (#5305)

* Add version notes to the docs

* Adapt signal handling

* Add UI

* Add API

* API and tests

* Fix registry

* Update doc/development/api/plugins.rst

Co-authored-by: Felix Rindt <felix@rindt.me>

* Fix failing tests

* Apply suggestions from code review

Co-authored-by: Richard Schreiber <schreiber@rami.io>

* Update src/pretix/control/templates/pretixcontrol/organizers/plugin_events.html

Co-authored-by: luelista <weller@rami.io>

* Update src/pretix/control/templates/pretixcontrol/organizers/plugins.html

Co-authored-by: luelista <weller@rami.io>

* Update src/pretix/control/templates/pretixcontrol/organizers/plugins.html

Co-authored-by: luelista <weller@rami.io>

* Update src/pretix/control/navigation.py

Co-authored-by: luelista <weller@rami.io>

* Update src/pretix/control/urls.py

Co-authored-by: luelista <weller@rami.io>

* Apply suggestion from @wiffbi

* REbase migration

* Fix review note

* Fix test cases

* Remove plugin from all events if disabled on org level

* Update doc/development/api/plugins.rst

* Unify registries

* Rebase migration

---------

Co-authored-by: Felix Rindt <felix@rindt.me>
Co-authored-by: Richard Schreiber <schreiber@rami.io>
Co-authored-by: luelista <weller@rami.io>
This commit is contained in:
Raphael Michel
2025-08-19 11:33:34 +02:00
committed by GitHub
parent 56964b6764
commit a51a6123f5
50 changed files with 1623 additions and 192 deletions

View File

@@ -70,9 +70,9 @@ from pretix.base.forms.widgets import (
SplitDateTimePickerWidget, format_placeholders_help_text,
)
from pretix.base.models import (
Customer, Device, EventMetaProperty, Gate, GiftCard, GiftCardAcceptance,
Membership, MembershipType, OrderPosition, Organizer, ReusableMedium,
SalesChannel, Team,
Customer, Device, Event, EventMetaProperty, Gate, GiftCard,
GiftCardAcceptance, Membership, MembershipType, OrderPosition, Organizer,
ReusableMedium, SalesChannel, Team,
)
from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider
from pretix.base.models.organizer import OrganizerFooterLink
@@ -1204,3 +1204,19 @@ class SalesChannelForm(I18nModelForm):
)
return d
class OrganizerPluginEventsForm(forms.Form):
events = SafeEventMultipleChoiceField(
queryset=Event.objects.none(),
widget=forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice scrolling-multiple-choice-large',
}),
label=_("Events with active plugin"),
required=False,
)
def __init__(self, *args, **kwargs):
events = kwargs.pop('events')
super().__init__(*args, **kwargs)
self.fields['events'].queryset = events

View File

@@ -783,6 +783,25 @@ class CoreLogEntryType(LogEntryType):
pass
@log_entry_types.new_from_dict({
'pretix.organizer.plugins.enabled': _('The plugin has been enabled.'),
'pretix.organizer.plugins.disabled': _('The plugin has been disabled.'),
})
class OrganizerPluginStateLogEntryType(LogEntryType):
object_link_wrapper = _('Plugin {val}')
def get_object_link_info(self, logentry) -> Optional[dict]:
if 'plugin' in logentry.parsed_data:
app = app_cache.get(logentry.parsed_data['plugin'])
if app and hasattr(app, 'PretixPluginMeta'):
return {
'href': reverse('control:organizer.settings.plugins', kwargs={
'organizer': logentry.event.organizer.slug,
}) + '#plugin_' + logentry.parsed_data['plugin'],
'val': app.PretixPluginMeta.name
}
@log_entry_types.new_from_dict({
'pretix.event.item_meta_property.added': _('A meta property has been added to this event.'),
'pretix.event.item_meta_property.deleted': _('A meta property has been removed from this event.'),

View File

@@ -495,6 +495,13 @@ def get_organizer_navigation(request):
}),
'active': url.url_name == 'organizer.edit',
},
{
'label': _('Plugins'),
'url': reverse('control:organizer.settings.plugins', kwargs={
'organizer': request.organizer.slug,
}),
'active': url.url_name == 'organizer.settings.plugins' or url.url_name == 'organizer.settings.plugin-events',
},
{
'label': _('Event metadata'),
'url': reverse('control:organizer.properties', kwargs={

View File

@@ -32,11 +32,11 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
from django.dispatch import Signal
from pretix.base.signals import (
DeprecatedSignal, EventPluginSignal, GlobalSignal, OrganizerPluginSignal,
)
from pretix.base.signals import DeprecatedSignal, EventPluginSignal
html_page_start = Signal()
html_page_start = GlobalSignal()
"""
This signal allows you to put code in the beginning of the main page for every
page in the backend. You are expected to return HTML.
@@ -80,7 +80,7 @@ in pretix.
As with all plugin signals, the ``sender`` keyword argument will contain the event.
"""
nav_topbar = Signal()
nav_topbar = GlobalSignal()
"""
Arguments: ``request``
@@ -99,7 +99,7 @@ This is no ``EventPluginSignal``, so you do not get the event in the ``sender``
and you may get the signal regardless of whether your plugin is active.
"""
nav_global = Signal()
nav_global = GlobalSignal()
"""
Arguments: ``request``
@@ -150,7 +150,7 @@ As with all plugin signals, the ``sender`` keyword argument will contain the eve
An additional keyword argument ``subevent`` *can* contain a sub-event.
"""
user_dashboard_widgets = Signal()
user_dashboard_widgets = GlobalSignal()
"""
Arguments: 'user'
@@ -221,7 +221,7 @@ Deprecated signal, no longer works. We just keep the definition so old plugins d
break the installation.
"""
nav_organizer = Signal()
nav_organizer = OrganizerPluginSignal(allow_legacy_plugins=True)
"""
Arguments: 'organizer', 'request'
@@ -350,14 +350,14 @@ will be passed a ``form`` variable with your form.
As with all plugin signals, the ``sender`` keyword argument will contain the event.
"""
oauth_application_registered = Signal()
oauth_application_registered = GlobalSignal()
"""
Arguments: ``user``, ``application``
This signal will be called whenever a user registers a new OAuth application.
"""
order_search_filter_q = Signal()
order_search_filter_q = GlobalSignal()
"""
Arguments: ``query``

View File

@@ -87,6 +87,17 @@
<span class="text-muted">{% trans "Not available" %}</span>
</div>
{% elif is_active %}
{% if plugin.level == "organizer" %}
<p class="text-muted">
<span class="fa fa-group" aria-hidden="true"></span>
{% trans "This plugin can only be disabled for the entire organizer account." %}
</p>
{% elif plugin.level == "event_organizer" %}
<p class="text-muted">
<span class="fa fa-group" aria-hidden="true"></span>
{% trans "After disabling this plugin, some functionality may remain active in the organizer account." %}
</p>
{% endif %}
<div class="plugin-action flip">
{% if navigation_links %}
<div class="btn-group">
@@ -112,14 +123,42 @@
</ul>
</div>
{% endif %}
<button class="btn btn-default{% if plugin.featured %} btn-lg{% endif %}" name="plugin:{{ plugin.module }}"
value="disable">{% trans "Disable" %}</button>
{% if plugin.level == "organizer" %}
<a href="{% url "control:organizer.settings.plugins" organizer=request.organizer.slug %}?q={{ plugin.module|urlencode }}"
class="btn btn-default" target="_blank">
<span class="fa fa-external-link" aria-hidden="true"></span>
{% trans "Open in organizer settings" %}
</a>
{% else %}
<button class="btn btn-default{% if plugin.featured %} btn-lg{% endif %}" name="plugin:{{ plugin.module }}"
value="disable">{% trans "Disable" %}</button>
{% endif %}
</div>
{% else %}
<div class="plugin-action flip">
<button class="btn btn-primary{% if plugin.featured %} btn-lg{% endif %}" name="plugin:{{ plugin.module }}"
value="enable">{% trans "Enable" %}</button>
</div>
{% if plugin.level == "organizer" %}
<p class="text-muted">
<span class="fa fa-group" aria-hidden="true"></span>
{% trans "This plugin can only be enabled for the entire organizer account." %}
</p>
<div class="plugin-action flip">
<a href="{% url "control:organizer.settings.plugins" organizer=request.organizer.slug %}?q={{ plugin.module|urlencode }}"
class="btn btn-default" target="_blank">
<span class="fa fa-external-link" aria-hidden="true"></span>
{% trans "Open in organizer settings" %}
</a>
</div>
{% else %}
{% if plugin.level == "event_organizer" and not plugin.module in request.organizer.get_plugins %}
<p class="text-muted">
<span class="fa fa-group" aria-hidden="true"></span>
{% trans "Enabling this plugin will enable some of its functionality for the entire organizer account." %}
</p>
{% endif %}
<div class="plugin-action flip">
<button class="btn btn-primary{% if plugin.featured %} btn-lg{% endif %}" name="plugin:{{ plugin.module }}"
value="enable">{% trans "Enable" %}</button>
</div>
{% endif %}
{% endif %}
{% if plugin.featured %}
</div>

View File

@@ -0,0 +1,45 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load money %}
{% block title %}
{% blocktrans trimmed with name=plugin.name %}
Events with plugin {{ name }}
{% endblocktrans %}
{% endblock %}
{% block inner %}
<h1>
{% blocktrans trimmed with name=plugin.name %}
Events with plugin {{ name }}
{% endblocktrans %}
</h1>
{% if plugin.level == "event" %}
<p>
{% blocktrans trimmed with name=plugin.name %}
The plugin "{{ name }}" can be enabled or disabled for every event individually.
{% endblocktrans %}
</p>
{% elif plugin.level == "event_organizer" %}
<p>
{% blocktrans trimmed with name=plugin.name %}
The plugin "{{ name }}" is enabled for your organizer account, but also needs to be enabled for the
specific events you want to use it with.
{% endblocktrans %}
</p>
{% endif %}
<p>
{% blocktrans trimmed %}
Using this form, you can quickly enable or disable it for many events. Note that it might still
be necessary to configure the plugin for each event individually.
{% endblocktrans %}
</p>
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form form layout="control" %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,193 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load static %}
{% load bootstrap3 %}
{% block content %}
<h1>{% trans "Available plugins" %}</h1>
<p>
{% blocktrans trimmed %}
On this page, you can choose plugins you want to enable for your organizer account. Plugins might bring additional
software functionality, connect your events to third-party services, or apply other forms of customizations.
{% endblocktrans %}
</p>
{% if "success" in request.GET %}
<div class="alert alert-success">
{% trans "Your changes have been saved." %}
</div>
{% endif %}
<div class="row">
<div class="col-lg-10">
<p><input type="search" id="plugin_search_input" class="form-control" placeholder="{% trans "Search" %}"></p>
</div>
<div class="col-lg-2 text-right">
<p class="btn-group btn-group-flex" data-toggle="buttons">
<label class="btn btn-primary-if-active active"><input type="radio" name="plugin_state_filter" value="all" checked> {% trans "All" %}</label>
<label class="btn btn-primary-if-active"><input type="radio" name="plugin_state_filter" value="active"> {% trans "Active" %}</label>
</p>
</div>
</div>
<form action="" method="post" class="form-horizontal form-plugins">
{% csrf_token %}
<div id="plugin_search_results" class="panel panel-default collapse">
<div class="panel-heading">
<button type="button" class="close" aria-label="Close"><span aria-hidden="true">×</span></button>
{% trans "Search results" %}
</div>
<div class="panel-body">
<div class="plugin-list"></div>
</div>
</div>
<div id="plugin_tabs"><div class="tabbed-form">
{% for cat, catlabel, plist, has_pictures in plugins %}
<fieldset data-plugin-category="{{ cat }}" data-plugin-category-label="{{ catlabel }}">
<legend>{{ catlabel }}</legend>
<div class="plugin-list">
{% for plugin, is_active, settings_links, navigation_links, events_counter in plist %}
<div class="plugin-container {% if plugin.featured %}featured-plugin{% endif %}" id="plugin_{{ plugin.module }}" data-plugin-module="{{ plugin.module }}" data-plugin-name="{{ plugin.name }}">
{% if plugin.featured %}
<div class="panel panel-default">
<div class="panel-body">
{% endif %}
<div class="plugin-text">
{% if plugin.featured or plugin.experimental %}
<p class="text-muted">
{% if plugin.featured %}
<span class="fa fa-thumbs-up" aria-hidden="true"></span>
{% trans "Top recommendation" %}
{% endif %}
{% if plugin.experimental %}
<span class="fa fa-flask" aria-hidden="true"></span>
{% trans "Experimental feature" %}
{% endif %}
</p>
{% endif %}
{% if plugin.picture %}
<p><img src="{% static plugin.picture %}" class="plugin-picture"></p>
{% endif %}
<h4>
{{ plugin.name }}
{% if show_meta %}
<span class="text-muted text-sm">{{ plugin.version }}</span>
{% endif %}
{% if is_active and level == "organizer" %}
<span class="label label-success" data-is-active>
<span class="fa fa-check" aria-hidden="true"></span>
{% trans "Active" %}
</span>
{% elif events_counter == events_total %}
<span class="label label-success" data-is-active>
<span class="fa fa-check" aria-hidden="true"></span>
{% trans "Active (all events)" %}
</span>
{% elif events_counter %}
<span class="label label-info" data-is-active>
<span class="fa fa-check" aria-hidden="true"></span>
{% blocktrans trimmed count count=events_counter %}
Active ({{ count }} event)
{% plural %}
Active ({{ count }} events)
{% endblocktrans %}
</span>
{% elif level == "event_organizer" %}
<span class="label label-info" data-is-active>
<span class="fa fa-check" aria-hidden="true"></span>
{% blocktrans trimmed count count=0 %}
Active ({{ count }} event)
{% plural %}
Active ({{ count }} events)
{% endblocktrans %}
</span>
{% endif %}
</h4>
{% include "pretixcontrol/event/fragment_plugin_description.html" with plugin=plugin %}
</div>
{% if plugin.app.compatibility_errors %}
<div class="plugin-action">
<span class="text-muted">{% trans "Incompatible" %}</span>
</div>
{% elif plugin.restricted and plugin.module not in request.organizer.settings.allowed_restricted_plugins %}
<div class="plugin-action">
<span class="text-muted">{% trans "Not available" %}</span>
</div>
{% elif is_active %}
{% if plugin.level == "event_organizer" %}
<p class="text-muted">
<span class="fa fa-calendar" aria-hidden="true"></span>
{% trans "Parts of this plugin can be enabled or disabled for events individually." %}
</p>
{% endif %}
<div class="plugin-action flip">
{% if navigation_links %}
<div class="btn-group">
<button type="button" class="btn btn-default dropdown-toggle{% if plugin.featured %} btn-lg{% endif %}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="{% trans "Open plugin settings" %}">
<span class="fa fa-compass"></span> {% trans "Go to" %} <span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% for link in navigation_links %}
<li><a href="{{ link.0 }}">{{ link.1 }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if settings_links %}
<div class="btn-group">
<button type="button" class="btn btn-default dropdown-toggle{% if plugin.featured %} btn-lg{% endif %}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="{% trans "Open plugin settings" %}">
<span class="fa fa-cog"></span> {% trans "Settings" %} <span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% for link in settings_links %}
<li><a href="{{ link.0 }}">{{ link.1 }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
<button class="btn btn-default{% if plugin.featured %} btn-lg{% endif %}" name="plugin:{{ plugin.module }}"
value="disable">{% trans "Disable" %}</button>
{% if plugin.level == "event_organizer" %}
<a class="btn btn-default {% if plugin.featured %} btn-lg{% endif %}"
href="{% url "control:organizer.settings.plugin-events" organizer=request.organizer.slug plugin=plugin.module %}">
{% trans "Manage events" %}
</a>
{% endif %}
</div>
{% else %}
{% if plugin.level == "organizer" %}
<div class="plugin-action flip">
<button class="btn btn-primary{% if plugin.featured %} btn-lg{% endif %}" name="plugin:{{ plugin.module }}"
value="enable">{% trans "Enable" %}</button>
</div>
{% elif not plugin.level or plugin.level == "event" %}
<p class="text-muted">
<span class="fa fa-calendar" aria-hidden="true"></span>
{% trans "This plugin can be enabled or disabled for events individually." %}
</p>
<div class="plugin-action flip">
<a class="btn btn-default {% if plugin.featured %} btn-lg{% endif %}"
href="{% url "control:organizer.settings.plugin-events" organizer=request.organizer.slug plugin=plugin.module %}">
{% trans "Manage events" %}
</a>
</div>
{% elif plugin.level == "event_organizer" %}
<p class="text-muted">
<span class="fa fa-calendar" aria-hidden="true"></span>
{% trans "Parts of this plugin can be enabled or disabled for events individually." %}
</p>
<div class="plugin-action flip">
<button class="btn btn-primary{% if plugin.featured %} btn-lg{% endif %}" name="plugin:{{ plugin.module }}"
value="enable">{% trans "Enable" %}</button>
</div>
{% endif %}
{% endif %}
{% if plugin.featured %}
</div>
</div>
{% endif %}
</div>
{% endfor %}
</div>
</fieldset>
{% endfor %}
</div></div>
</form>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/plugins.js" %}"></script>
{% endblock %}

View File

@@ -115,6 +115,10 @@ urlpatterns = [
re_path(r'^organizers/select2$', typeahead.organizer_select2, name='organizers.select2'),
re_path(r'^organizer/(?P<organizer>[^/]+)/$', organizer.OrganizerDetail.as_view(), name='organizer'),
re_path(r'^organizer/(?P<organizer>[^/]+)/edit$', organizer.OrganizerUpdate.as_view(), name='organizer.edit'),
re_path(r'^organizer/(?P<organizer>[^/]+)/settings/plugins$',
organizer.OrganizerPlugins.as_view(), name='organizer.settings.plugins'),
re_path(r'^organizer/(?P<organizer>[^/]+)/settings/plugins/(?P<plugin>[^/]+)/events$',
organizer.OrganizerPluginEvents.as_view(), name='organizer.settings.plugin-events'),
re_path(r'^organizer/(?P<organizer>[^/]+)/settings/email$',
organizer.OrganizerMailSettings.as_view(), name='organizer.settings.mail'),
re_path(r'^organizer/(?P<organizer>[^/]+)/settings/email/setup$',

View File

@@ -61,7 +61,7 @@ from django.http import (
JsonResponse,
)
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.urls import NoReverseMatch, reverse
from django.utils.functional import cached_property
from django.utils.html import conditional_escape, format_html
from django.utils.http import url_has_allowed_host_and_scheme
@@ -104,6 +104,10 @@ from ...base.i18n import language
from ...base.models.items import (
Item, ItemCategory, ItemMetaProperty, Question, Quota,
)
from ...base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
PLUGIN_LEVEL_ORGANIZER,
)
from ...base.services.mail import prefix_subject
from ...base.services.placeholders import get_sample_context
from ...base.settings import LazyI18nStringList
@@ -349,43 +353,36 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat
def available_plugins(self, event):
from pretix.base.plugins import get_all_plugins
return (p for p in get_all_plugins(event) if not p.name.startswith('.')
return (p for p in get_all_plugins(event=event) if not p.name.startswith('.')
and getattr(p, 'visible', True))
def prepare_links(self, pluginmeta, key):
links = getattr(pluginmeta, key, [])
try:
return [
(
reverse(urlname, kwargs={"organizer": self.request.organizer.slug, "event": self.request.event.slug, **kwargs}),
" > ".join(map(str, linktext)) if isinstance(linktext, tuple) else linktext,
) for linktext, urlname, kwargs in links
]
result = []
for linktext, urlname, kwargs in links:
try:
result.append((
reverse(urlname, kwargs={"organizer": self.request.organizer.slug, "event": self.request.event.slug, **kwargs}),
" > ".join(map(str, linktext)) if isinstance(linktext, tuple) else linktext,
))
except NoReverseMatch:
if pluginmeta.level != PLUGIN_LEVEL_EVENT:
# Ignore, link might be for another level
pass
else:
raise
return result
except:
logger.exception('Failed to resolve settings links.')
return []
def get_context_data(self, *args, **kwargs) -> dict:
from pretix.base.plugins import CATEGORY_LABELS, CATEGORY_ORDER
context = super().get_context_data(*args, **kwargs)
plugins = list(self.available_plugins(self.object))
order = [
'FEATURE',
'PAYMENT',
'INTEGRATION',
'CUSTOMIZATION',
'FORMAT',
'API',
]
labels = {
'FEATURE': _('Features'),
'PAYMENT': _('Payment providers'),
'INTEGRATION': _('Integrations'),
'CUSTOMIZATION': _('Customizations'),
'FORMAT': _('Output and export formats'),
'API': _('API features'),
}
plugins_grouped = groupby(
sorted(
plugins,
@@ -400,17 +397,24 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat
plugins_grouped = [(c, list(plist)) for c, plist in plugins_grouped]
active_plugins = self.object.get_plugins()
organizer_active_plugins = self.request.organizer.get_plugins()
def plugin_details(plugin):
is_active = plugin.module in active_plugins
if getattr(plugin, "level", PLUGIN_LEVEL_EVENT) == PLUGIN_LEVEL_ORGANIZER:
is_active = plugin.module in organizer_active_plugins
if getattr(plugin, "level", PLUGIN_LEVEL_EVENT) == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID:
is_active = is_active and plugin.module in organizer_active_plugins
settings_links = self.prepare_links(plugin, 'settings_links') if is_active else None
navigation_links = self.prepare_links(plugin, 'navigation_links') if is_active else None
return (plugin, is_active, settings_links, navigation_links)
return plugin, is_active, settings_links, navigation_links
context['plugins'] = sorted([
(c, labels.get(c, c), map(plugin_details, plist), any(getattr(p, 'picture', None) for p in plist))
(c, CATEGORY_LABELS.get(c, c), map(plugin_details, plist), any(getattr(p, 'picture', None) for p in plist))
for c, plist
in plugins_grouped
], key=lambda c: (order.index(c[0]), c[1]) if c[0] in order else (999, str(c[1])))
], key=lambda c: (CATEGORY_ORDER.index(c[0]), c[1]) if c[0] in CATEGORY_ORDER else (999, str(c[1])))
context['show_meta'] = settings.PRETIX_PLUGINS_SHOW_META
return context
@@ -427,6 +431,7 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat
}
with transaction.atomic():
save_organizer = False
for key, value in request.POST.items():
if key.startswith("plugin:"):
module = key.split(":")[1]
@@ -436,8 +441,26 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat
if module not in request.event.settings.allowed_restricted_plugins:
continue
self.request.event.log_action('pretix.event.plugins.enabled', user=self.request.user,
data={'plugin': module})
if getattr(pluginmeta, 'level', PLUGIN_LEVEL_EVENT) not in (PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID):
continue
if getattr(pluginmeta, 'level', PLUGIN_LEVEL_EVENT) == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID:
if not request.user.has_organizer_permission(request.organizer, "can_change_organizer_settings", request):
messages.error(
request,
_("You do not have sufficient permission to enable plugins that need to be enabled "
"for the entire organizer account.")
)
continue
if module not in self.object.organizer.get_plugins():
self.object.organizer.log_action('pretix.organizer.plugins.enabled', user=self.request.user,
data={'plugin': module})
self.object.organizer.enable_plugin(module, allow_restricted=request.event.settings.allowed_restricted_plugins)
save_organizer = True
self.object.log_action('pretix.event.plugins.enabled', user=self.request.user,
data={'plugin': module})
self.object.enable_plugin(module, allow_restricted=request.event.settings.allowed_restricted_plugins)
links = self.prepare_links(pluginmeta, 'settings_links')
@@ -463,12 +486,14 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat
self.object.disable_plugin(module)
messages.success(self.request, _('The plugin has been disabled.'))
self.object.save()
if save_organizer:
self.object.organizer.save()
return redirect(self.get_success_url())
def get_success_url(self) -> str:
return reverse('control:event.settings.plugins', kwargs={
'organizer': self.get_object().organizer.slug,
'event': self.get_object().slug,
'organizer': self.request.organizer.slug,
'event': self.request.event.slug,
})

View File

@@ -33,10 +33,13 @@
# License for the specific language governing permissions and limitations under the License.
import json
import logging
import re
from collections import Counter
from datetime import time, timedelta
from decimal import Decimal
from hashlib import sha1
from itertools import groupby
from json import JSONDecodeError
import bleach
@@ -59,9 +62,11 @@ from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, JsonResponse,
)
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.urls import NoReverseMatch, reverse
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext, gettext_lazy as _
from django.views import View
@@ -69,6 +74,7 @@ from django.views.decorators.http import require_http_methods
from django.views.generic import (
CreateView, DetailView, FormView, ListView, TemplateView, UpdateView,
)
from django.views.generic.detail import SingleObjectMixin
from pretix.api.models import ApiCall, WebHook
from pretix.api.webhooks import manually_retry_all_calls
@@ -91,6 +97,10 @@ from pretix.base.models.giftcards import (
from pretix.base.models.orders import CancellationRequest
from pretix.base.models.organizer import SalesChannel, TeamAPIToken
from pretix.base.payment import PaymentException
from pretix.base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
PLUGIN_LEVEL_ORGANIZER,
)
from pretix.base.services.export import multiexport, scheduled_organizer_export
from pretix.base.services.mail import SendMailException, mail, prefix_subject
from pretix.base.signals import register_multievent_data_exporters
@@ -108,9 +118,9 @@ from pretix.control.forms.organizer import (
GiftCardAcceptanceInviteForm, GiftCardCreateForm, GiftCardUpdateForm,
KnownDomainFormset, MailSettingsForm, MembershipTypeForm,
MembershipUpdateForm, OrganizerDeleteForm, OrganizerFooterLinkFormset,
OrganizerForm, OrganizerSettingsForm, OrganizerUpdateForm,
ReusableMediumCreateForm, ReusableMediumUpdateForm, SalesChannelForm,
SSOClientForm, SSOProviderForm, TeamForm, WebHookForm,
OrganizerForm, OrganizerPluginEventsForm, OrganizerSettingsForm,
OrganizerUpdateForm, ReusableMediumCreateForm, ReusableMediumUpdateForm,
SalesChannelForm, SSOClientForm, SSOProviderForm, TeamForm, WebHookForm,
)
from pretix.control.forms.rrule import RRuleForm
from pretix.control.logdisplay import OVERVIEW_BANLIST
@@ -129,6 +139,8 @@ from pretix.helpers.urls import build_absolute_uri as build_global_uri
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.forms.customer import TokenGenerator
logger = logging.getLogger(__name__)
class OrganizerList(PaginationMixin, ListView):
model = Organizer
@@ -582,6 +594,263 @@ class OrganizerCreate(CreateView):
})
class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, TemplateView, SingleObjectMixin):
model = Organizer
context_object_name = 'organizer'
permission = 'can_change_organizer_settings'
template_name = 'pretixcontrol/organizers/plugins.html'
def get_object(self, queryset=None) -> Organizer:
return self.request.organizer
def available_plugins(self, organizer):
from pretix.base.plugins import get_all_plugins
return (p for p in get_all_plugins(organizer=organizer) if not p.name.startswith('.')
and getattr(p, 'visible', True))
def prepare_links(self, pluginmeta, key):
links = getattr(pluginmeta, key, [])
try:
result = []
for linktext, urlname, kwargs in links:
try:
result.append((
reverse(urlname, kwargs={"organizer": self.request.organizer.slug}),
" > ".join(map(str, linktext)) if isinstance(linktext, tuple) else linktext,
))
except NoReverseMatch:
if pluginmeta.level != PLUGIN_LEVEL_ORGANIZER:
# Ignore, link might be for another level
pass
else:
raise
return result
except:
logger.exception('Failed to resolve settings links.')
return []
def get_context_data(self, *args, **kwargs) -> dict:
from pretix.base.plugins import CATEGORY_LABELS, CATEGORY_ORDER
context = super().get_context_data(*args, **kwargs)
plugins = list(self.available_plugins(self.object))
active_counter = Counter()
events_total = 0
for e in self.object.events.only("plugins").iterator():
events_total += 1
for p in e.get_plugins():
active_counter[p] += 1
plugins_grouped = groupby(
sorted(
plugins,
key=lambda p: (
str(getattr(p, 'category', _('Other'))),
(0 if getattr(p, 'featured', False) else 1),
str(p.name).lower().replace('pretix ', '')
),
),
lambda p: str(getattr(p, 'category', _('Other')))
)
plugins_grouped = [(c, list(plist)) for c, plist in plugins_grouped]
active_plugins = self.object.get_plugins()
def plugin_details(plugin):
is_active = plugin.module in active_plugins
events_counter = active_counter[plugin.module]
settings_links = self.prepare_links(plugin, 'settings_links') if is_active else None
navigation_links = self.prepare_links(plugin, 'navigation_links') if is_active else None
return plugin, is_active, settings_links, navigation_links, events_counter
context['plugins'] = sorted([
(c, CATEGORY_LABELS.get(c, c), map(plugin_details, plist), any(getattr(p, 'picture', None) for p in plist))
for c, plist
in plugins_grouped
], key=lambda c: (CATEGORY_ORDER.index(c[0]), c[1]) if c[0] in CATEGORY_ORDER else (999, str(c[1])))
context['show_meta'] = settings.PRETIX_PLUGINS_SHOW_META
context['events_total'] = events_total
return context
def get(self, request, *args, **kwargs):
self.object = self.get_object()
context = self.get_context_data(object=self.object)
return self.render_to_response(context)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
plugins_available = {
p.module: p for p in self.available_plugins(self.object)
}
choose_events_next = False
with transaction.atomic():
for key, value in request.POST.items():
if key.startswith("plugin:"):
module = key.split(":")[1]
if value == "enable" and module in plugins_available:
pluginmeta = plugins_available[module]
if getattr(pluginmeta, 'restricted', False):
if module not in request.organizer.settings.allowed_restricted_plugins:
continue
level = getattr(pluginmeta, 'level', PLUGIN_LEVEL_EVENT)
if level not in (PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID):
continue
if level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID:
choose_events_next = module
self.object.log_action('pretix.organizer.plugins.enabled', user=self.request.user,
data={'plugin': module})
self.object.enable_plugin(module, allow_restricted=request.organizer.settings.allowed_restricted_plugins)
links = self.prepare_links(pluginmeta, 'settings_links')
if links:
info = [
'<p>',
format_html(_('The plugin {} is now active, you can configure it here:'),
format_html("<strong>{}</strong>", pluginmeta.name)),
'</p><p>',
] + [
format_html('<a href="{}" class="btn btn-default">{}</a> ', url, text)
for url, text in links
] + ['</p>']
else:
info = [
format_html(_('The plugin {} is now active.'),
format_html("<strong>{}</strong>", pluginmeta.name)),
]
messages.success(self.request, mark_safe("".join(info)))
elif value == "disable" and module in plugins_available:
pluginmeta = plugins_available[module]
level = getattr(pluginmeta, 'level', PLUGIN_LEVEL_EVENT)
if level not in (PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID):
continue
if level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID:
events_to_disable = set(self.request.organizer.events.filter(
plugins__regex='(^|,)' + module + '(,|$)'
).values_list("pk", flat=True))
logentries_to_save = []
events_to_save = []
for e in self.request.organizer.events.filter(pk__in=events_to_disable):
logentries_to_save.append(
e.log_action('pretix.event.plugins.disabled', user=self.request.user,
data={'plugin': module}, save=False)
)
e.disable_plugin(module)
events_to_save.append(e)
Event.objects.bulk_update(events_to_save, fields=["plugins"])
LogEntry.objects.bulk_create(logentries_to_save)
self.object.log_action('pretix.organizer.plugins.disabled', user=self.request.user,
data={'plugin': module})
self.object.disable_plugin(module)
messages.success(self.request, _('The plugin has been disabled.'))
self.object.save()
if choose_events_next:
return redirect(reverse('control:organizer.settings.plugin-events', kwargs={
'organizer': self.request.organizer.slug,
'plugin': choose_events_next,
}))
else:
return redirect(self.get_success_url())
def get_success_url(self) -> str:
return reverse('control:organizer.settings.plugins', kwargs={
'organizer': self.request.organizer.slug,
})
class OrganizerPluginEvents(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, FormView):
model = Organizer
context_object_name = 'organizer'
permission = 'can_change_organizer_settings'
template_name = 'pretixcontrol/organizers/plugin_events.html'
form_class = OrganizerPluginEventsForm
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["events"] = self.request.user.get_events_with_permission(
"can_change_event_settings", request=self.request
).filter(organizer=self.request.organizer)
kwargs["initial"] = {
"events": self.request.organizer.events.filter(plugins__regex='(^|,)' + self.plugin.module + '(,|$)')
}
return kwargs
def available_plugins(self, organizer):
from pretix.base.plugins import get_all_plugins
return (p for p in get_all_plugins(organizer=organizer) if not p.name.startswith('.')
and getattr(p, 'visible', True))
def get_context_data(self, **kwargs):
return super().get_context_data(
plugin=self.plugin,
**kwargs
)
def dispatch(self, request, *args, **kwargs):
plugins_available = {
p.module: p for p in self.available_plugins(self.request.organizer)
}
if kwargs["plugin"] not in plugins_available:
raise Http404(_("Unknown plugin."))
self.plugin = plugins_available[kwargs["plugin"]]
level = getattr(self.plugin, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_ORGANIZER:
raise Http404(_("This plugin can only be enabled for the entire organizer account."))
if level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and self.plugin.module not in self.request.organizer.get_plugins():
raise Http404(_("This plugin is currently not active on the organizer account."))
if getattr(self.plugin, 'restricted', False):
if self.plugin.module not in request.organizer.settings.allowed_restricted_plugins:
raise Http404(_("This plugin is currently not allowed for this organizer account."))
return super().dispatch(request, *args, **kwargs)
def get_success_url(self) -> str:
return reverse('control:organizer.settings.plugins', kwargs={
'organizer': self.request.organizer.slug,
})
@transaction.atomic()
def form_valid(self, form):
enabled_events_before = set(
self.request.organizer.events.filter(plugins__regex='(^|,)' + self.plugin.module + '(,|$)').values_list("pk", flat=True)
)
enabled_events_now = {e.pk for e in form.cleaned_data["events"]}
events_to_enable = enabled_events_now - enabled_events_before
events_to_disable = enabled_events_before - enabled_events_now
events_to_save = []
logentries_to_save = []
for e in self.request.organizer.events.filter(pk__in=events_to_enable):
logentries_to_save.append(
e.log_action('pretix.event.plugins.enabled', user=self.request.user, data={'plugin': self.plugin.module}, save=False)
)
e.enable_plugin(self.plugin.module, allow_restricted=self.request.organizer.settings.allowed_restricted_plugins)
events_to_save.append(e)
for e in self.request.organizer.events.filter(pk__in=events_to_disable):
logentries_to_save.append(
e.log_action('pretix.event.plugins.disabled', user=self.request.user, data={'plugin': self.plugin.module}, save=False)
)
e.disable_plugin(self.plugin.module)
events_to_save.append(e)
Event.objects.bulk_update(events_to_save, fields=["plugins"])
LogEntry.objects.bulk_create(logentries_to_save)
messages.success(self.request, _("Your changes have been saved."))
return super().form_valid(form)
class TeamListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
model = Team
template_name = 'pretixcontrol/organizers/teams.html'