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

@@ -50,6 +50,7 @@ from rest_framework.relations import SlugRelatedField
from pretix.api.serializers import (
CompatibleJSONField, SalesChannelMigrationMixin,
)
from pretix.api.serializers.fields import PluginsField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.settings import SettingsSerializer
from pretix.base.models import (
@@ -61,6 +62,9 @@ from pretix.base.models.items import (
ItemMetaProperty, SubEventItem, SubEventItemVariation,
)
from pretix.base.models.tax import CustomRulesValidator
from pretix.base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
)
from pretix.base.services.seating import (
SeatProtected, generate_seats, validate_plan_change,
)
@@ -126,22 +130,6 @@ class SeatCategoryMappingField(Field):
}
class PluginsField(Field):
def to_representation(self, obj):
from pretix.base.plugins import get_all_plugins
return sorted([
p.module for p in get_all_plugins()
if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in obj.get_plugins()
])
def to_internal_value(self, data):
return {
'plugins': data
}
class TimeZoneField(ChoiceField):
def get_attribute(self, instance):
return instance.cache.get_or_set(
@@ -283,17 +271,28 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
from pretix.base.plugins import get_all_plugins
plugins_available = {
p.module: p for p in get_all_plugins(self.instance)
p.module: p for p in get_all_plugins(event=self.instance)
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
current_plugins = self.instance.get_plugins() if self.instance and self.instance.pk else []
settings_holder = self.instance if self.instance and self.instance.pk else self.context['organizer']
allowed_levels = (PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID)
for plugin in value.get('plugins'):
if plugin not in plugins_available:
raise ValidationError(_('Unknown plugin: \'{name}\'.').format(name=plugin))
if getattr(plugins_available[plugin], 'restricted', False):
if plugin not in settings_holder.settings.allowed_restricted_plugins:
raise ValidationError(_('Restricted plugin: \'{name}\'.').format(name=plugin))
level = getattr(plugins_available[plugin], 'level', PLUGIN_LEVEL_EVENT)
if level not in allowed_levels:
raise ValidationError('Plugin cannot be enabled on this level: \'{name}\'.'.format(name=plugin))
if level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and plugin not in self.context['organizer'].get_plugins():
if plugin not in current_plugins:
# Technically, this is allowed, but consumers might be confused if the API call doesn't do anything
# so we prevent this change.
raise ValidationError('Plugin should be enabled on organizer level first: \'{name}\'.'.format(name=plugin))
return value

View File

@@ -109,3 +109,19 @@ class UploadedFileField(serializers.Field):
return None
request = self.context['request']
return request.build_absolute_uri(url)
class PluginsField(serializers.Field):
def to_representation(self, obj):
from pretix.base.plugins import get_all_plugins
return sorted([
p.module for p in get_all_plugins()
if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in obj.get_plugins()
])
def to_internal_value(self, data):
return {
'plugins': data
}

View File

@@ -24,6 +24,7 @@ from decimal import Decimal
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.db.models import Q
from django.utils.crypto import get_random_string
from django.utils.translation import gettext_lazy as _
@@ -32,6 +33,7 @@ from rest_framework.exceptions import ValidationError
from pretix.api.auth.devicesecurity import get_all_security_profiles
from pretix.api.serializers import AsymmetricField
from pretix.api.serializers.fields import PluginsField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import CompatibleJSONField
from pretix.api.serializers.settings import SettingsSerializer
@@ -43,6 +45,10 @@ from pretix.base.models import (
SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
)
from pretix.base.models.seating import SeatingPlanLayoutValidator
from pretix.base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
PLUGIN_LEVEL_ORGANIZER,
)
from pretix.base.services.mail import SendMailException, mail
from pretix.base.settings import validate_organizer_settings
from pretix.helpers.urls import build_absolute_uri as build_global_uri
@@ -53,13 +59,47 @@ logger = logging.getLogger(__name__)
class OrganizerSerializer(I18nAwareModelSerializer):
public_url = serializers.SerializerMethodField('get_organizer_url', read_only=True)
plugins = PluginsField(required=False, source='*')
name = serializers.CharField(read_only=True)
slug = serializers.CharField(read_only=True)
def get_organizer_url(self, organizer):
return build_absolute_uri(organizer, 'presale:organizer.index')
class Meta:
model = Organizer
fields = ('name', 'slug', 'public_url')
fields = ('name', 'slug', 'public_url', 'plugins')
def validate_plugins(self, value):
from pretix.base.plugins import get_all_plugins
plugins_available = {
p.module: p for p in get_all_plugins(organizer=self.instance)
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
settings_holder = self.instance
allowed_levels = (PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID)
for plugin in value.get('plugins'):
if plugin not in plugins_available:
raise ValidationError(_('Unknown plugin: \'{name}\'.').format(name=plugin))
if getattr(plugins_available[plugin], 'restricted', False):
if plugin not in settings_holder.settings.allowed_restricted_plugins:
raise ValidationError(_('Restricted plugin: \'{name}\'.').format(name=plugin))
if getattr(plugins_available[plugin], 'level', PLUGIN_LEVEL_EVENT) not in allowed_levels:
raise ValidationError('Plugin cannot be enabled on this level: \'{name}\'.'.format(name=plugin))
return value
@transaction.atomic
def update(self, instance, validated_data):
plugins = validated_data.pop('plugins', None)
organizer = super().update(instance, validated_data)
# Plugins
if plugins is not None:
organizer.set_active_plugins(plugins)
organizer.save()
return organizer
class SeatingPlanSerializer(I18nAwareModelSerializer):

View File

@@ -21,22 +21,22 @@
#
from datetime import timedelta
from django.dispatch import Signal, receiver
from django.dispatch import receiver
from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.api.models import ApiCall, WebHookCall
from pretix.base.signals import EventPluginSignal, periodic_task
from pretix.base.signals import EventPluginSignal, GlobalSignal, periodic_task
from pretix.helpers.periodic import minimum_interval
register_webhook_events = Signal()
register_webhook_events = GlobalSignal()
"""
This signal is sent out to get all known webhook events. Receivers should return an
instance of a subclass of ``pretix.api.webhooks.WebhookEvent`` or a list of such
instances.
"""
register_device_security_profile = Signal()
register_device_security_profile = GlobalSignal()
"""
This signal is sent out to get all known device security_profiles. Receivers should
return an instance of a subclass of ``pretix.api.auth.devicesecurity.BaseSecurityProfile``

View File

@@ -19,7 +19,9 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import operator
from decimal import Decimal
from functools import reduce
import django_filters
from django.contrib.auth.hashers import make_password
@@ -48,15 +50,18 @@ from pretix.api.serializers.organizer import (
TeamInviteSerializer, TeamMemberSerializer, TeamSerializer,
)
from pretix.base.models import (
Customer, Device, GiftCard, GiftCardTransaction, Membership,
MembershipType, Organizer, SalesChannel, SeatingPlan, Team, TeamAPIToken,
TeamInvite, User,
Customer, Device, Event, GiftCard, GiftCardTransaction, LogEntry,
Membership, MembershipType, Organizer, SalesChannel, SeatingPlan, Team,
TeamAPIToken, TeamInvite, User,
)
from pretix.base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
)
from pretix.helpers import OF_SELF
from pretix.helpers.dicts import merge_dicts
class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
class OrganizerViewSet(mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrganizerSerializer
queryset = Organizer.objects.none()
lookup_field = 'slug'
@@ -65,6 +70,7 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
filter_backends = (TotalOrderingFilter,)
ordering = ('slug',)
ordering_fields = ('name', 'slug')
write_permission = "can_change_organizer_settings"
def get_queryset(self):
if self.request.user.is_authenticated:
@@ -83,6 +89,67 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
else:
return Organizer.objects.filter(pk=self.request.auth.team.organizer_id)
@transaction.atomic()
def perform_update(self, serializer):
from pretix.base.plugins import get_all_plugins
original_data = self.get_serializer(instance=serializer.instance).data
current_plugins_value = serializer.instance.get_plugins()
updated_plugins_value = serializer.validated_data.get('plugins', None)
super().perform_update(serializer)
if serializer.data == original_data:
# Performance optimization: If nothing was changed, we do not need to save or log anything.
# This costs us a few cycles on save, but avoids thousands of lines in our log.
return
if updated_plugins_value is not None and set(updated_plugins_value) != set(current_plugins_value):
enabled = {m: 'enabled' for m in updated_plugins_value if m not in current_plugins_value}
disabled = {m: 'disabled' for m in current_plugins_value if m not in updated_plugins_value}
changed = merge_dicts(enabled, disabled)
plugins_available = {
p.module: p
for p in get_all_plugins(organizer=serializer.instance)
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
qs = []
for module in disabled:
pluginmeta = plugins_available[module]
level = getattr(pluginmeta, 'level', PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID:
qs.append(Q(plugins__regex='(^|,)' + module + '(,|$)'))
if qs:
events_to_disable = set(self.request.organizer.events.filter(
reduce(operator.or_, qs)
).values_list("pk", flat=True))
logentries_to_save = []
events_to_save = []
for e in self.request.organizer.events.filter(pk__in=events_to_disable):
for module in disabled:
if module in e.get_plugins():
logentries_to_save.append(
e.log_action('pretix.event.plugins.disabled', user=self.request.user, auth=self.request.auth,
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)
for module, operation in changed.items():
serializer.instance.log_action(
'pretix.organizer.plugins.' + operation,
user=self.request.user,
auth=self.request.auth,
data={'plugin': module}
)
class SeatingPlanViewSet(viewsets.ModelViewSet):
serializer_class = SeatingPlanSerializer

View File

@@ -38,13 +38,13 @@ from pretix.base.datasync.sourcefields import (
from pretix.base.i18n import language
from pretix.base.logentrytype_registry import make_link
from pretix.base.models.datasync import OrderSyncQueue, OrderSyncResult
from pretix.base.signals import EventPluginRegistry
from pretix.base.signals import PluginAwareRegistry
from pretix.helpers import OF_SELF
logger = logging.getLogger(__name__)
datasync_providers = EventPluginRegistry({"identifier": lambda o: o.identifier})
datasync_providers = PluginAwareRegistry({"identifier": lambda o: o.identifier})
class BaseSyncError(Exception):

View File

@@ -26,7 +26,7 @@ from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from pretix.base.signals import EventPluginRegistry
from pretix.base.signals import PluginAwareRegistry
def make_link(a_map, wrapper, is_active=True, event=None, plugin_name=None):
@@ -55,7 +55,7 @@ def make_link(a_map, wrapper, is_active=True, event=None, plugin_name=None):
return format_html(wrapper, **a_map)
class LogEntryTypeRegistry(EventPluginRegistry):
class LogEntryTypeRegistry(PluginAwareRegistry):
def __init__(self):
super().__init__({'action_type': lambda o: getattr(o, 'action_type')})

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.17 on 2025-07-12 09:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0286_settingsstore_unique"),
]
operations = [
migrations.AddField(
model_name="organizer",
name="plugins",
field=models.TextField(default=""),
),
]

View File

@@ -551,8 +551,7 @@ class Event(EventMixin, LoggedModel):
:type presale_end: datetime
:param location: venue
:type location: str
:param plugins: A comma-separated list of plugin names that are active for this
event.
:param plugins: A comma-separated list of plugin names that are active for this event.
:type plugins: str
:param has_subevents: Enable event series functionality
:type has_subevents: bool
@@ -1393,7 +1392,7 @@ class Event(EventMixin, LoggedModel):
from pretix.base.plugins import get_all_plugins
return {
p.module: p for p in get_all_plugins(self)
p.module: p for p in get_all_plugins(event=self)
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
@@ -1412,12 +1411,20 @@ class Event(EventMixin, LoggedModel):
self.plugins = ",".join(modules)
def enable_plugin(self, module, allow_restricted=frozenset()):
"""
Adds a plugin to the list of plugins, calling its ``installed`` hook (if available).
It is the caller's responsibility to save the event object.
"""
plugins_active = self.get_plugins()
if module not in plugins_active:
plugins_active.append(module)
self.set_active_plugins(plugins_active, allow_restricted=allow_restricted)
def disable_plugin(self, module):
"""
Adds a plugin to the list of plugins, calling its ``uninstalled`` hook (if available).
It is the caller's responsibility to save the event object.
"""
plugins_active = self.get_plugins()
if module in plugins_active:
plugins_active.remove(module)

View File

@@ -40,9 +40,6 @@ from django.contrib.contenttypes.models import ContentType
from django.db import connections, models
from django.utils.functional import cached_property
from pretix.base.logentrytype_registry import log_entry_types, make_link
from pretix.base.signals import is_app_active, logentry_object_link
class VisibleOnlyManager(models.Manager):
def get_queryset(self):
@@ -91,6 +88,8 @@ class LogEntry(models.Model):
indexes = [models.Index(fields=["datetime", "id"])]
def display(self):
from pretix.base.logentrytype_registry import log_entry_types
log_entry_type, meta = log_entry_types.get(action_type=self.action_type)
if log_entry_type:
return log_entry_type.display(self, self.parsed_data)
@@ -128,6 +127,11 @@ class LogEntry(models.Model):
@cached_property
def display_object(self):
from pretix.base.logentrytype_registry import (
log_entry_types, make_link,
)
from pretix.base.signals import is_app_active, logentry_object_link
from . import (
Discount, Event, Item, Order, Question, Quota, SubEvent, Voucher,
)

View File

@@ -68,6 +68,8 @@ class Organizer(LoggedModel):
:param slug: A globally unique, short name for this organizer, to be used
in URLs and similar places.
:type slug: str
:param plugins: A comma-separated list of plugin names that are active for this organizer.
:type plugins: str
"""
settings_namespace = 'organizer'
@@ -91,6 +93,10 @@ class Organizer(LoggedModel):
verbose_name=_("Short form"),
unique=True
)
plugins = models.TextField(
verbose_name=_("Plugins"),
null=False, blank=True, default="",
)
class Meta:
verbose_name = _("Organizer")
@@ -119,6 +125,11 @@ class Organizer(LoggedModel):
"""
self.settings.cookie_consent = True
plugins = [p for p in settings.PRETIX_PLUGINS_ORGANIZER_DEFAULT.split(",") if p]
if plugins:
self.set_active_plugins(plugins, allow_restricted=plugins)
self.save()
def get_cache(self):
"""
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
@@ -143,6 +154,61 @@ class Organizer(LoggedModel):
return ObjectRelatedCache(self)
def get_plugins(self):
"""
Returns the names of the plugins activated for this organizer as a list.
"""
if not self.plugins:
return []
return self.plugins.split(",")
def get_available_plugins(self):
from pretix.base.plugins import get_all_plugins
return {
p.module: p for p in get_all_plugins(organizer=self)
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
def set_active_plugins(self, modules, allow_restricted=frozenset()):
plugins_active = self.get_plugins()
plugins_available = self.get_available_plugins()
enable = [m for m in modules if m not in plugins_active and m in plugins_available]
for module in enable:
if getattr(plugins_available[module].app, 'restricted', False) and module not in allow_restricted:
modules.remove(module)
elif hasattr(plugins_available[module].app, 'installed'):
getattr(plugins_available[module].app, 'installed')(self)
self.plugins = ",".join(modules)
def enable_plugin(self, module, allow_restricted=frozenset()):
"""
Adds a plugin to the list of plugins, calling its ``installed`` hook (if available).
It is the caller's responsibility to save the organizer object.
"""
plugins_active = self.get_plugins()
if module not in plugins_active:
plugins_active.append(module)
self.set_active_plugins(plugins_active, allow_restricted=allow_restricted)
def disable_plugin(self, module):
"""
Removes a plugin from the list of plugins, calling its ``uninstalled`` hook (if available).
It is the caller's responsibility to save the organizer object and, in case of a hybrid organizer-event plugin,
to remove it from all events.
"""
plugins_active = self.get_plugins()
if module in plugins_active:
plugins_active.remove(module)
self.set_active_plugins(plugins_active)
plugins_available = self.get_available_plugins()
if module in plugins_available and hasattr(plugins_available[module].app, 'uninstalled'):
getattr(plugins_available[module].app, 'uninstalled')(self)
@property
def timezone(self):
return pytz_deprecation_shim.timezone(self.settings.timezone)

View File

@@ -28,8 +28,13 @@ import importlib_metadata as metadata
from django.apps import AppConfig, apps
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import gettext_lazy as _
from packaging.requirements import Requirement
PLUGIN_LEVEL_EVENT = 'event'
PLUGIN_LEVEL_ORGANIZER = 'organizer'
PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID = 'event_organizer'
class PluginType(Enum):
"""
@@ -43,11 +48,14 @@ class PluginType(Enum):
EXPORT = 4
def get_all_plugins(event=None) -> List[type]:
def get_all_plugins(*, event=None, organizer=None) -> List[type]:
"""
Returns the PretixPluginMeta classes of all plugins found in the installed Django apps.
"""
assert not event or not organizer
plugins = []
event_fallback = None
event_fallback_used = False
for app in apps.get_app_configs():
if hasattr(app, 'PretixPluginMeta'):
meta = app.PretixPluginMeta
@@ -56,8 +64,26 @@ def get_all_plugins(event=None) -> List[type]:
if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
continue
if hasattr(app, 'is_available') and event:
if not app.is_available(event):
level = getattr(app, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_EVENT:
if event and hasattr(app, 'is_available'):
if not app.is_available(event):
continue
elif organizer and hasattr(app, 'is_available'):
if not event_fallback_used:
event_fallback = organizer.events.first()
event_fallback_used = True
if not event_fallback or not app.is_available(event_fallback):
continue
elif level == PLUGIN_LEVEL_ORGANIZER:
if organizer and hasattr(app, 'is_available'):
if not app.is_available(organizer):
continue
elif event and hasattr(app, 'is_available'):
if not app.is_available(event.organizer):
continue
elif level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and (event or organizer) and hasattr(app, 'is_available'):
if not app.is_available(event or organizer):
continue
plugins.append(meta)
@@ -91,3 +117,26 @@ class PluginConfig(AppConfig, metaclass=PluginConfigMeta):
self.name, req, requirement_version
))
sys.exit(1)
if not hasattr(self.PretixPluginMeta, 'level'):
self.PretixPluginMeta.level = PLUGIN_LEVEL_EVENT
if self.PretixPluginMeta.level not in (PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID):
raise ImproperlyConfigured(f"Unknown plugin level '{self.PretixPluginMeta.level}'")
CATEGORY_ORDER = [
'FEATURE',
'PAYMENT',
'INTEGRATION',
'CUSTOMIZATION',
'FORMAT',
'API',
]
CATEGORY_LABELS = {
'FEATURE': _('Features'),
'PAYMENT': _('Payment providers'),
'INTEGRATION': _('Integrations'),
'CUSTOMIZATION': _('Customizations'),
'FORMAT': _('Output and export formats'),
'API': _('API features'),
}

View File

@@ -114,7 +114,7 @@ def restricted_plugin_kwargs():
from pretix.base.plugins import get_all_plugins
plugins_available = [
(p.module, p.name) for p in get_all_plugins(None)
(p.module, p.name) for p in get_all_plugins()
if (
not p.name.startswith('.') and
getattr(p, 'restricted', False) and

View File

@@ -33,16 +33,23 @@
# License for the specific language governing permissions and limitations under the License.
import warnings
from typing import Any, Callable, List, Tuple
from typing import Any, Callable, Generic, List, Tuple, TypeVar
import django.dispatch
from django.apps import apps
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.dispatch.dispatcher import NO_RECEIVERS
from .models import Event
from .models.event import Event
from .models.organizer import Organizer
from .plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
PLUGIN_LEVEL_ORGANIZER,
)
app_cache = {}
T = TypeVar('T')
def _populate_app_cache():
@@ -56,6 +63,9 @@ def get_defining_app(o):
if "sentry" in o.__module__:
o = o.__wrapped__
if hasattr(o, "__mocked_app"):
return o.__mocked_app
# Find the Django application this belongs to
searchpath = o.__module__
@@ -74,43 +84,71 @@ def get_defining_app(o):
return app
def is_app_active(sender, app):
def is_app_active(sender, app, allow_legacy_plugins=False):
if app == 'CORE':
return True
excluded = settings.PRETIX_PLUGINS_EXCLUDE
if sender and app and app.name in sender.get_plugins() and app.name not in excluded:
if not sender or not app or app.name in excluded:
return False
level = getattr(app.PretixPluginMeta, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_EVENT:
if isinstance(sender, Event):
enabled = app.name in sender.get_plugins()
elif isinstance(sender, Organizer) and allow_legacy_plugins:
# Deprecated behaviour: Event plugins that are registered on organizer level are considered active for
# all organizers in the context of signals that used to be global signals before the introduction of
# organizer-level plugin. A deprecation warning is emitted at .connect() time.
enabled = True
else:
raise ImproperlyConfigured(f"Cannot check if event-level plugin is active on {type(sender)}")
elif level == PLUGIN_LEVEL_ORGANIZER:
if isinstance(sender, Organizer):
enabled = app.name in sender.get_plugins()
elif isinstance(sender, Event):
enabled = app.name in sender.organizer.get_plugins()
else:
raise ImproperlyConfigured(f"Cannot check if organizer-level plugin is active on {type(sender)}")
elif level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID:
if isinstance(sender, Organizer):
enabled = app.name in sender.get_plugins()
elif isinstance(sender, Event):
enabled = app.name in sender.get_plugins() and app.name in sender.organizer.get_plugins()
else:
raise ImproperlyConfigured(f"Cannot check if hybrid event/organizer-level plugin is active on {type(sender)}")
else:
raise ImproperlyConfigured("Unknown plugin level")
if enabled:
if not hasattr(app, 'compatibility_errors') or not app.compatibility_errors:
return True
return False
def is_receiver_active(sender, receiver):
def is_receiver_active(sender, receiver, allow_legacy_plugins=False):
if sender is None:
# Send to all events!
return True
app = get_defining_app(receiver)
return is_app_active(sender, app)
return is_app_active(sender, app, allow_legacy_plugins)
class EventPluginSignal(django.dispatch.Signal):
"""
This is an extension to Django's built-in signals which differs in a way that it sends
out it's events only to receivers which belong to plugins that are enabled for the given
Event.
"""
class PluginSignal(Generic[T], django.dispatch.Signal):
type = None
def send(self, sender: Event, **named) -> List[Tuple[Callable, Any]]:
def _is_receiver_active(self, sender, receiver):
return is_receiver_active(sender, receiver)
def send(self, sender: T, **named) -> List[Tuple[Callable, Any]]:
"""
Send signal from sender to all connected receivers that belong to
plugins enabled for the given Event.
sender is required to be an instance of ``pretix.base.models.Event``.
plugins enabled for the given event / organizer.
"""
if sender and not isinstance(sender, Event):
raise ValueError("Sender needs to be an event.")
if sender and not isinstance(sender, self.type):
raise ValueError(f"Sender needs to be of type {self.type}.")
responses = []
if not self.receivers or self.sender_receivers_cache.get(sender) is NO_RECEIVERS:
@@ -120,20 +158,18 @@ class EventPluginSignal(django.dispatch.Signal):
_populate_app_cache()
for receiver in self._sorted_receivers(sender):
if is_receiver_active(sender, receiver):
if self._is_receiver_active(sender, receiver):
response = receiver(signal=self, sender=sender, **named)
responses.append((receiver, response))
return responses
def send_chained(self, sender: Event, chain_kwarg_name, **named) -> List[Tuple[Callable, Any]]:
def send_chained(self, sender: T, chain_kwarg_name, **named) -> List[Tuple[Callable, Any]]:
"""
Send signal from sender to all connected receivers. The return value of the first receiver
will be used as the keyword argument specified by ``chain_kwarg_name`` in the input to the
second receiver and so on. The return value of the last receiver is returned by this method.
sender is required to be an instance of ``pretix.base.models.Event``.
"""
if sender and not isinstance(sender, Event):
if sender and not isinstance(sender, self.type):
raise ValueError("Sender needs to be an event.")
response = named.get(chain_kwarg_name)
@@ -144,20 +180,18 @@ class EventPluginSignal(django.dispatch.Signal):
_populate_app_cache()
for receiver in self._sorted_receivers(sender):
if is_receiver_active(sender, receiver):
if self._is_receiver_active(sender, receiver):
named[chain_kwarg_name] = response
response = receiver(signal=self, sender=sender, **named)
return response
def send_robust(self, sender: Event, **named) -> List[Tuple[Callable, Any]]:
def send_robust(self, sender: T, **named) -> List[Tuple[Callable, Any]]:
"""
Send signal from sender to all connected receivers. If a receiver raises an exception
instead of returning a value, the exception is included as the result instead of
stopping the response chain at the offending receiver.
sender is required to be an instance of ``pretix.base.models.Event``.
"""
if sender and not isinstance(sender, Event):
if sender and not isinstance(sender, self.type):
raise ValueError("Sender needs to be an event.")
responses = []
@@ -171,7 +205,7 @@ class EventPluginSignal(django.dispatch.Signal):
_populate_app_cache()
for receiver in self._sorted_receivers(sender):
if is_receiver_active(sender, receiver):
if self._is_receiver_active(sender, receiver):
try:
response = receiver(signal=self, sender=sender, **named)
except Exception as err:
@@ -193,6 +227,67 @@ class EventPluginSignal(django.dispatch.Signal):
return sorted_list
class EventPluginSignal(PluginSignal[Event]):
"""
This is an extension to Django's built-in signals which differs in a way that it sends
out it's events only to receivers which belong to plugins that are enabled for the given
Event.
"""
type = Event
def connect(self, receiver, sender=None, weak=True, dispatch_uid=None):
app = get_defining_app(receiver)
if app != "CORE":
if not hasattr(app, "PretixPluginMeta"):
raise ImproperlyConfigured(
f"{app} uses an EventPluginSignal but is not a pretix plugin"
)
allowed_levels = (PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID, PLUGIN_LEVEL_EVENT)
if getattr(app.PretixPluginMeta, "level", PLUGIN_LEVEL_EVENT) not in allowed_levels:
# This check is redundant for now, but will be useful if we ever add other levels
raise ImproperlyConfigured(
f"{app} uses an EventPluginSignal but is not a plugin that can be active on event or organizer level"
)
return super().connect(receiver, sender, weak, dispatch_uid)
class OrganizerPluginSignal(PluginSignal[Organizer]):
"""
This is an extension to Django's built-in signals which differs in a way that it sends
out it's events only to receivers which belong to plugins that are enabled for the given
Organizer.
"""
type = Organizer
def __init__(self, allow_legacy_plugins=False):
self.allow_legacy_plugins = allow_legacy_plugins
super().__init__()
def _is_receiver_active(self, sender, receiver):
return is_receiver_active(sender, receiver, allow_legacy_plugins=self.allow_legacy_plugins)
def connect(self, receiver, sender=None, weak=True, dispatch_uid=None):
app = get_defining_app(receiver)
if app != "CORE":
if not hasattr(app, "PretixPluginMeta"):
raise ImproperlyConfigured(
f"{app} uses an OrganizerPluginSignal but is not a pretix plugin"
)
allowed_levels = (PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID)
if getattr(app.PretixPluginMeta, "level", PLUGIN_LEVEL_EVENT) not in allowed_levels:
if getattr(app.PretixPluginMeta, "level", PLUGIN_LEVEL_EVENT) == PLUGIN_LEVEL_EVENT and self.allow_legacy_plugins:
warnings.warn(
'This signal will soon be only available for plugins that declare to be organizer-level',
stacklevel=3,
category=DeprecationWarning,
)
else:
raise ImproperlyConfigured(
f"{app} uses an OrganizerPluginSignal but is not a plugin that can be active on organizer level"
)
return super().connect(receiver, sender, weak, dispatch_uid)
class GlobalSignal(django.dispatch.Signal):
def send_chained(self, sender: Event, chain_kwarg_name, **named) -> List[Tuple[Callable, Any]]:
"""
@@ -211,10 +306,14 @@ class GlobalSignal(django.dispatch.Signal):
return response
class DeprecatedSignal(django.dispatch.Signal):
class DeprecatedSignal(GlobalSignal):
def connect(self, receiver, sender=None, weak=True, dispatch_uid=None):
warnings.warn('This signal is deprecated and will soon be removed', stacklevel=3)
warnings.warn(
'This signal is deprecated and will soon be removed',
stacklevel=3,
category=DeprecationWarning,
)
super().connect(receiver, sender=None, weak=True, dispatch_uid=None)
@@ -324,20 +423,39 @@ class Registry:
)
class EventPluginRegistry(Registry):
class PluginAwareRegistry(Registry):
"""
A Registry which automatically annotates entries with a "plugin" key, specifying which plugin
the entry is defined in. This allows the consumer of entries to determine whether an entry is
enabled for a given event, or filter only for entries defined by enabled plugins.
enabled for a given event or organizer, or filter only for entries defined by enabled plugins.
.. code-block:: python
logtype, meta = my_registry.find(action_type="foo.bar.baz")
# meta["plugin"] contains the django app name of the defining plugin
"""
allowed_levels = [
PLUGIN_LEVEL_EVENT,
PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
PLUGIN_LEVEL_ORGANIZER,
]
def __init__(self, keys):
super().__init__({"plugin": lambda o: get_defining_app(o), **keys})
def get_plugin(o):
app = get_defining_app(o)
if app != "CORE":
if not hasattr(app, "PretixPluginMeta"):
raise ImproperlyConfigured(
f"{app} uses an PluginAwareRegistry but is not a pretix plugin"
)
level = getattr(app.PretixPluginMeta, "level", PLUGIN_LEVEL_EVENT)
if level not in self.allowed_levels:
raise ImproperlyConfigured(
f"{app} has level {level} but should have one of {self.allowed_levels} to use this registry"
)
return app
super().__init__({"plugin": get_plugin, **keys})
def filter(self, active_in=None, **kwargs):
result = super().filter(**kwargs)
@@ -357,6 +475,9 @@ class EventPluginRegistry(Registry):
return item, meta
EventPluginRegistry = PluginAwareRegistry # for backwards compatibility
event_live_issues = EventPluginSignal()
"""
This signal is sent out to determine whether an event can be taken live. If you want to
@@ -449,7 +570,7 @@ This signal is sent out when a notification is sent.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
register_sales_channel_types = django.dispatch.Signal()
register_sales_channel_types = GlobalSignal()
"""
This signal is sent out to get all known sales channels types. Receivers should return an
instance of a subclass of ``pretix.base.channels.SalesChannelType`` or a list of such
@@ -467,10 +588,8 @@ subclass of pretix.base.exporter.BaseExporter
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
register_multievent_data_exporters = django.dispatch.Signal()
register_multievent_data_exporters = OrganizerPluginSignal(allow_legacy_plugins=True)
"""
Arguments: ``event``
This signal is sent out to get all known data exporters, which support exporting data for
multiple events. Receivers should return a subclass of pretix.base.exporter.BaseExporter
@@ -742,7 +861,7 @@ The ``sender`` keyword argument will contain the event. The ``target`` will cont
copy to, the ``source`` keyword argument will contain the product to **copy from**.
"""
periodic_task = django.dispatch.Signal()
periodic_task = GlobalSignal()
"""
This is a regular django signal (no pretix event signal) that we send out every
time the periodic task cronjob runs. This interval is not sharply defined, it can
@@ -751,13 +870,13 @@ idempotent, i.e. it should not make a difference if this is sent out more often
than expected.
"""
register_global_settings = django.dispatch.Signal()
register_global_settings = GlobalSignal()
"""
All plugins that are installed may send fields for the global settings form, as
an OrderedDict of (setting name, form field).
"""
gift_card_transaction_display = django.dispatch.Signal()
gift_card_transaction_display = GlobalSignal() # todo: replace with OrganizerPluginSignal?
"""
Arguments: ``transaction``, ``customer_facing``
@@ -969,7 +1088,7 @@ return a dictionary mapping names of attributes in the settings store to DRF ser
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
customer_created = GlobalSignal()
customer_created = OrganizerPluginSignal(allow_legacy_plugins=True)
"""
Arguments: ``customer``
@@ -979,7 +1098,7 @@ object is given as the first argument.
The ``sender`` keyword argument will contain the organizer.
"""
customer_signed_in = GlobalSignal()
customer_signed_in = OrganizerPluginSignal(allow_legacy_plugins=True)
"""
Arguments: ``customer``
@@ -989,7 +1108,7 @@ is given as the first argument.
The ``sender`` keyword argument will contain the organizer.
"""
device_info_updated = django.dispatch.Signal()
device_info_updated = GlobalSignal() # todo: replace with OrganizerPluginSignal?
"""
Arguments: ``old_device``, ``new_device``

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'

View File

@@ -31,12 +31,9 @@
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# 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 pretix.base.signals import EventPluginSignal, GlobalSignal
from django.dispatch import Signal
from pretix.base.signals import EventPluginSignal
global_html_head = Signal()
global_html_head = GlobalSignal()
"""
Arguments: ``request``
@@ -47,7 +44,7 @@ of every page in the frontend. You will get the request as the keyword argument
This signal is called regardless of whether your plugin is active for all pages of the system.
"""
global_html_page_header = Signal()
global_html_page_header = GlobalSignal()
"""
Arguments: ``request``
@@ -58,7 +55,7 @@ of every page in the frontend. You will get the request as the keyword argument
This signal is called regardless of whether your plugin is active for all pages of the system.
"""
global_html_footer = Signal()
global_html_footer = GlobalSignal()
"""
Arguments: ``request``
@@ -134,7 +131,7 @@ are expected to return a dictionary containing the keys ``label`` and ``url``.
As with all plugin signals, the ``sender`` keyword argument will contain the event.
"""
global_footer_link = Signal()
global_footer_link = GlobalSignal()
"""
Arguments: ``request``

View File

@@ -26,11 +26,10 @@ from urllib.parse import urljoin, urlsplit
import sass
from django.conf import settings
from django.contrib.staticfiles import finders
from django.dispatch import Signal
from django.templatetags.static import static as _static
from pretix.base.models import Event, Organizer
from pretix.base.signals import EventPluginSignal
from pretix.base.signals import EventPluginSignal, GlobalSignal
from pretix.multidomain.urlreverse import (
get_event_domain, get_organizer_domain,
)
@@ -38,7 +37,7 @@ from pretix.multidomain.urlreverse import (
logger = logging.getLogger('pretix.presale.style')
register_fonts = Signal()
register_fonts = GlobalSignal()
"""
Return a dictionaries of the following structure. Paths should be relative to static root or an absolute URL. In the
latter case, the fonts won't be available for PDF-rendering.

View File

@@ -217,6 +217,8 @@ if config.getboolean('pretix', 'trust_x_forwarded_proto', fallback=False):
PRETIX_PLUGINS_DEFAULT = config.get('pretix', 'plugins_default',
fallback='pretix.plugins.sendmail,pretix.plugins.statistics,pretix.plugins.checkinlists')
PRETIX_PLUGINS_ORGANIZER_DEFAULT = config.get('pretix', 'plugins_organizer_default',
fallback='')
PRETIX_PLUGINS_EXCLUDE = config.get('pretix', 'plugins_exclude', fallback='').split(',')
PRETIX_PLUGINS_SHOW_META = config.getboolean('pretix', 'plugins_show_meta', fallback=True)