From a51a6123f554497e325906670db896a8a8ec2a5e Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 19 Aug 2025 11:33:34 +0200 Subject: [PATCH] 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 * Fix failing tests * Apply suggestions from code review Co-authored-by: Richard Schreiber * Update src/pretix/control/templates/pretixcontrol/organizers/plugin_events.html Co-authored-by: luelista * Update src/pretix/control/templates/pretixcontrol/organizers/plugins.html Co-authored-by: luelista * Update src/pretix/control/templates/pretixcontrol/organizers/plugins.html Co-authored-by: luelista * Update src/pretix/control/navigation.py Co-authored-by: luelista * Update src/pretix/control/urls.py Co-authored-by: luelista * 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 Co-authored-by: Richard Schreiber Co-authored-by: luelista --- doc/api/resources/events.rst | 4 +- doc/api/resources/organizers.rst | 59 +++- doc/api/resources/quotas.rst | 4 + doc/api/resources/vouchers.rst | 4 + doc/development/api/plugins.rst | 23 +- src/pretix/api/serializers/event.py | 33 +-- src/pretix/api/serializers/fields.py | 16 + src/pretix/api/serializers/organizer.py | 42 ++- src/pretix/api/signals.py | 8 +- src/pretix/api/views/organizer.py | 75 ++++- src/pretix/base/datasync/datasync.py | 4 +- src/pretix/base/logentrytype_registry.py | 4 +- .../base/migrations/0287_organizer_plugins.py | 18 ++ src/pretix/base/models/event.py | 13 +- src/pretix/base/models/log.py | 10 +- src/pretix/base/models/organizer.py | 66 +++++ src/pretix/base/plugins.py | 55 +++- src/pretix/base/settings.py | 2 +- src/pretix/base/signals.py | 207 ++++++++++--- src/pretix/control/forms/organizer.py | 22 +- src/pretix/control/logdisplay.py | 19 ++ src/pretix/control/navigation.py | 7 + src/pretix/control/signals.py | 20 +- .../pretixcontrol/event/plugins.html | 51 +++- .../organizers/plugin_events.html | 45 +++ .../pretixcontrol/organizers/plugins.html | 193 ++++++++++++ src/pretix/control/urls.py | 4 + src/pretix/control/views/event.py | 89 ++++-- src/pretix/control/views/organizer.py | 277 +++++++++++++++++- src/pretix/presale/signals.py | 13 +- src/pretix/presale/style.py | 5 +- src/pretix/settings.py | 2 + src/setup.cfg | 1 + src/tests/api/test_events.py | 33 +++ src/tests/api/test_oauth.py | 6 +- src/tests/api/test_organizers.py | 66 ++++- src/tests/api/test_permissions.py | 1 + src/tests/base/test_datasync.py | 5 + src/tests/base/test_registry.py | 25 +- src/tests/control/test_events.py | 22 ++ src/tests/control/test_organizer.py | 73 +++++ src/tests/control/test_permissions.py | 20 +- src/tests/settings.py | 3 + src/tests/testdummy/apps.py | 4 +- src/tests/testdummyhybrid/__init__.py | 21 ++ src/tests/testdummyhybrid/apps.py | 34 +++ src/tests/testdummyorga/__init__.py | 21 ++ src/tests/testdummyorga/apps.py | 32 ++ src/tests/testdummyorgarestricted/__init__.py | 21 ++ src/tests/testdummyorgarestricted/apps.py | 33 +++ 50 files changed, 1623 insertions(+), 192 deletions(-) create mode 100644 src/pretix/base/migrations/0287_organizer_plugins.py create mode 100644 src/pretix/control/templates/pretixcontrol/organizers/plugin_events.html create mode 100644 src/pretix/control/templates/pretixcontrol/organizers/plugins.html create mode 100644 src/tests/testdummyhybrid/__init__.py create mode 100644 src/tests/testdummyhybrid/apps.py create mode 100644 src/tests/testdummyorga/__init__.py create mode 100644 src/tests/testdummyorga/apps.py create mode 100644 src/tests/testdummyorgarestricted/__init__.py create mode 100644 src/tests/testdummyorgarestricted/apps.py diff --git a/doc/api/resources/events.rst b/doc/api/resources/events.rst index 5064377b6a..95bd5e2493 100644 --- a/doc/api/resources/events.rst +++ b/doc/api/resources/events.rst @@ -424,9 +424,9 @@ Endpoints :param organizer: The ``slug`` field of the organizer of the event to create. :param event: The ``slug`` field of the event to copy settings and items from. :statuscode 201: no error - :statuscode 400: The event could not be created due to invalid submitted data. + :statuscode 400: The event could not be updated due to invalid submitted data. :statuscode 401: Authentication failure - :statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource. + :statuscode 403: The requested organizer does not exist **or** you have no permission to update this resource. .. http:patch:: /api/v1/organizers/(organizer)/events/(event)/ diff --git a/doc/api/resources/organizers.rst b/doc/api/resources/organizers.rst index eb3b28fe4a..066657a4de 100644 --- a/doc/api/resources/organizers.rst +++ b/doc/api/resources/organizers.rst @@ -19,6 +19,11 @@ name string The organizer's slug string A short form of the name, used e.g. in URLs. public_url string The public, customer-facing URL of the organizer, where the list of all events can be found (read-only). +plugins list A list of package names of the enabled plugins for this + organizer. Note that most plugins are enabled on the + event level (or both levels). If you remove a plugin + that is also enabled on some events, it will + automatically be removed from all events as well. ===================================== ========================== ======================================================= @@ -53,7 +58,10 @@ Endpoints { "name": "Big Events LLC", "slug": "Big Events", - "public_url": "https://pretix.eu/bigevents/" + "public_url": "https://pretix.eu/bigevents/", + "plugins": [ + "pretix_datev" + ] } ] } @@ -87,7 +95,10 @@ Endpoints { "name": "Big Events LLC", "slug": "Big Events", - "public_url": "https://pretix.eu/bigevents/" + "public_url": "https://pretix.eu/bigevents/", + "plugins": [ + "pretix_datev" + ] } :param organizer: The ``slug`` field of the organizer to fetch @@ -95,6 +106,50 @@ Endpoints :statuscode 401: Authentication failure :statuscode 403: The requested organizer does not exist **or** you have no permission to view it. +.. http:patch:: /api/v1/organizers/(organizer)/ + + Updates an organizer. Currently only the ``plugins`` field may be updated. + + Permission required: "Can change organizer settings" + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "plugins": [ + "pretix_seating" + ] + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "name": "Big Events LLC", + "slug": "Big Events", + "public_url": "https://pretix.eu/bigevents/", + "plugins": [ + "pretix_seating" + ] + } + + :param organizer: The ``slug`` field of the organizer to update + :statuscode 200: no error + :statuscode 400: The organizer could not be updated due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to update this resource. + Organizer settings ------------------ diff --git a/doc/api/resources/quotas.rst b/doc/api/resources/quotas.rst index fa0623e3e1..42e263c2a5 100644 --- a/doc/api/resources/quotas.rst +++ b/doc/api/resources/quotas.rst @@ -38,6 +38,10 @@ available_number integer Number of avail slightly out of date. ``null`` means unlimited. ===================================== ========================== ======================================================= +.. versionchanged:: 2025.7 + + The attribute ``ignore_for_event_availability`` has been added. + Endpoints --------- diff --git a/doc/api/resources/vouchers.rst b/doc/api/resources/vouchers.rst index 1450f255ad..87fdd47b83 100644 --- a/doc/api/resources/vouchers.rst +++ b/doc/api/resources/vouchers.rst @@ -54,6 +54,10 @@ budget money (string) The budget a vo budget_used money (string) The amount of budget the voucher has already used up. ===================================== ========================== ======================================================= +.. versionchanged:: 2025.7 + + The attributes ``created``, ``budget``, and ``budget_used`` have been added. + Endpoints --------- diff --git a/doc/development/api/plugins.rst b/doc/development/api/plugins.rst index fe2287eeea..e81112dbc9 100644 --- a/doc/development/api/plugins.rst +++ b/doc/development/api/plugins.rst @@ -56,6 +56,20 @@ restricted boolean (optional) ``False`` by default, restricts a plugin for an event by system administrators / superusers. experimental boolean (optional) ``False`` by default, marks a plugin as an experimental feature in the plugins list. compatibility string Specifier for compatible pretix versions. +level string System level the plugin can be activated at. + Set to ``pretix.base.plugins.PLUGIN_LEVEL_EVENT`` for plugins that can be activated + at event level and then be active for that event only. + Set to ``pretix.base.plugins.PLUGIN_LEVEL_ORGANIZER`` for plugins that can be + activated only for the organizer as a whole and are active for any event within + that organizer. + Set to ``pretix.base.plugins.PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID`` for plugins that + can be activated at organizer level but are considered active only within events + for which they have also been specifically activated. + More levels, e.g. user-level plugins, might be invented in the future. +settings_links list List of ``((menu name, submenu name, …), urlname, url_kwargs)`` tuples that point + to the plugin's settings. +navigation_links list List of ``((menu name, submenu name, …), urlname, url_kwargs)`` tuples that point + to the plugin's system pages. ================== ==================== =========================================================== A working example would be: @@ -63,9 +77,9 @@ A working example would be: .. code-block:: python try: - from pretix.base.plugins import PluginConfig + from pretix.base.plugins import PluginConfig, PLUGIN_LEVEL_EVENT except ImportError: - raise RuntimeError("Please use pretix 2.7 or above to run this plugin!") + raise RuntimeError("Please use pretix 2025.7 or above to run this plugin!") from django.utils.translation import gettext_lazy as _ @@ -79,6 +93,7 @@ A working example would be: version = '1.0.0' category = 'PAYMENT' picture = 'pretix_paypal/paypal_logo.svg' + level = PLUGIN_LEVEL_EVENT visible = True featured = False restricted = False @@ -142,14 +157,14 @@ method to make your receivers available: from . import signals # NOQA You can optionally specify code that is executed when your plugin is activated for an event -in the ``installed`` method: +or organizer in the ``installed`` method: .. code-block:: python class PaypalApp(AppConfig): … - def installed(self, event): + def installed(self, event_or_organizer): pass # Your code here diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index f7351a805d..e271ecc9c9 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -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 diff --git a/src/pretix/api/serializers/fields.py b/src/pretix/api/serializers/fields.py index ebb6272f26..db8858a22a 100644 --- a/src/pretix/api/serializers/fields.py +++ b/src/pretix/api/serializers/fields.py @@ -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 + } diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index acff759049..b2ead95945 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -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): diff --git a/src/pretix/api/signals.py b/src/pretix/api/signals.py index 22cdd82eec..1f69fad60a 100644 --- a/src/pretix/api/signals.py +++ b/src/pretix/api/signals.py @@ -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`` diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index e8f948b69e..3bfe0e57a3 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -19,7 +19,9 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +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 diff --git a/src/pretix/base/datasync/datasync.py b/src/pretix/base/datasync/datasync.py index d5bfd304c4..4bee7fbe10 100644 --- a/src/pretix/base/datasync/datasync.py +++ b/src/pretix/base/datasync/datasync.py @@ -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): diff --git a/src/pretix/base/logentrytype_registry.py b/src/pretix/base/logentrytype_registry.py index dd434063b4..696ff24b69 100644 --- a/src/pretix/base/logentrytype_registry.py +++ b/src/pretix/base/logentrytype_registry.py @@ -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')}) diff --git a/src/pretix/base/migrations/0287_organizer_plugins.py b/src/pretix/base/migrations/0287_organizer_plugins.py new file mode 100644 index 0000000000..32a6c408ea --- /dev/null +++ b/src/pretix/base/migrations/0287_organizer_plugins.py @@ -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=""), + ), + ] diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index c5b5061f89..c12c5913de 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -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) diff --git a/src/pretix/base/models/log.py b/src/pretix/base/models/log.py index ecb55e2af3..368bd99f10 100644 --- a/src/pretix/base/models/log.py +++ b/src/pretix/base/models/log.py @@ -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, ) diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py index c9e0ee3ba1..95d92aaf84 100644 --- a/src/pretix/base/models/organizer.py +++ b/src/pretix/base/models/organizer.py @@ -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) diff --git a/src/pretix/base/plugins.py b/src/pretix/base/plugins.py index 79e6d23a9d..f26eb77477 100644 --- a/src/pretix/base/plugins.py +++ b/src/pretix/base/plugins.py @@ -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'), +} diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 550d747ebd..12074375cb 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -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 diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index 054228401d..2b17fccf65 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -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`` diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index 4060c89b49..0b72529312 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -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 diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 544f0c7ee4..34066f640b 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -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.'), diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py index cf8e43a798..67bccaccd4 100644 --- a/src/pretix/control/navigation.py +++ b/src/pretix/control/navigation.py @@ -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={ diff --git a/src/pretix/control/signals.py b/src/pretix/control/signals.py index a7e27ef6e7..5e1b6b76a7 100644 --- a/src/pretix/control/signals.py +++ b/src/pretix/control/signals.py @@ -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`` diff --git a/src/pretix/control/templates/pretixcontrol/event/plugins.html b/src/pretix/control/templates/pretixcontrol/event/plugins.html index 6b32d6a57f..2c3d6543db 100644 --- a/src/pretix/control/templates/pretixcontrol/event/plugins.html +++ b/src/pretix/control/templates/pretixcontrol/event/plugins.html @@ -87,6 +87,17 @@ {% trans "Not available" %} {% elif is_active %} + {% if plugin.level == "organizer" %} +

+ + {% trans "This plugin can only be disabled for the entire organizer account." %} +

+ {% elif plugin.level == "event_organizer" %} +

+ + {% trans "After disabling this plugin, some functionality may remain active in the organizer account." %} +

+ {% endif %}
{% if navigation_links %}
@@ -112,14 +123,42 @@
{% endif %} - + {% if plugin.level == "organizer" %} + + + {% trans "Open in organizer settings" %} + + {% else %} + + {% endif %}
{% else %} -
- -
+ {% if plugin.level == "organizer" %} +

+ + {% trans "This plugin can only be enabled for the entire organizer account." %} +

+ + {% else %} + {% if plugin.level == "event_organizer" and not plugin.module in request.organizer.get_plugins %} +

+ + {% trans "Enabling this plugin will enable some of its functionality for the entire organizer account." %} +

+ {% endif %} +
+ +
+ {% endif %} {% endif %} {% if plugin.featured %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/plugin_events.html b/src/pretix/control/templates/pretixcontrol/organizers/plugin_events.html new file mode 100644 index 0000000000..84ca00bc55 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/plugin_events.html @@ -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 %} +

+ {% blocktrans trimmed with name=plugin.name %} + Events with plugin {{ name }} + {% endblocktrans %} +

+ {% if plugin.level == "event" %} +

+ {% blocktrans trimmed with name=plugin.name %} + The plugin "{{ name }}" can be enabled or disabled for every event individually. + {% endblocktrans %} +

+ {% elif plugin.level == "event_organizer" %} +

+ {% 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 %} +

+ {% endif %} +

+ {% 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 %} +

+
+ {% csrf_token %} + {% bootstrap_form form layout="control" %} +
+ +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/plugins.html b/src/pretix/control/templates/pretixcontrol/organizers/plugins.html new file mode 100644 index 0000000000..964132f3a2 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/plugins.html @@ -0,0 +1,193 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load static %} +{% load bootstrap3 %} +{% block content %} +

{% trans "Available plugins" %}

+

+ {% 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 %} +

+ {% if "success" in request.GET %} +
+ {% trans "Your changes have been saved." %} +
+ {% endif %} +
+
+

+
+
+

+ + +

+
+
+
+ {% csrf_token %} +
+
+ + {% trans "Search results" %} +
+
+
+
+
+
+ {% for cat, catlabel, plist, has_pictures in plugins %} +
+ {{ catlabel }} +
+ {% for plugin, is_active, settings_links, navigation_links, events_counter in plist %} +
+ {% if plugin.featured %} +
+
+ {% endif %} +
+ {% if plugin.featured or plugin.experimental %} +

+ {% if plugin.featured %} + + {% trans "Top recommendation" %} + {% endif %} + {% if plugin.experimental %} + + {% trans "Experimental feature" %} + {% endif %} +

+ {% endif %} + {% if plugin.picture %} +

+ {% endif %} +

+ {{ plugin.name }} + {% if show_meta %} + {{ plugin.version }} + {% endif %} + {% if is_active and level == "organizer" %} + + + {% trans "Active" %} + + {% elif events_counter == events_total %} + + + {% trans "Active (all events)" %} + + {% elif events_counter %} + + + {% blocktrans trimmed count count=events_counter %} + Active ({{ count }} event) + {% plural %} + Active ({{ count }} events) + {% endblocktrans %} + + {% elif level == "event_organizer" %} + + + {% blocktrans trimmed count count=0 %} + Active ({{ count }} event) + {% plural %} + Active ({{ count }} events) + {% endblocktrans %} + + {% endif %} +

+ {% include "pretixcontrol/event/fragment_plugin_description.html" with plugin=plugin %} +
+ {% if plugin.app.compatibility_errors %} +
+ {% trans "Incompatible" %} +
+ {% elif plugin.restricted and plugin.module not in request.organizer.settings.allowed_restricted_plugins %} +
+ {% trans "Not available" %} +
+ {% elif is_active %} + {% if plugin.level == "event_organizer" %} +

+ + {% trans "Parts of this plugin can be enabled or disabled for events individually." %} +

+ {% endif %} +
+ {% if navigation_links %} +
+ + +
+ {% endif %} + {% if settings_links %} +
+ + +
+ {% endif %} + + {% if plugin.level == "event_organizer" %} + + {% trans "Manage events" %} + + {% endif %} +
+ {% else %} + {% if plugin.level == "organizer" %} +
+ +
+ {% elif not plugin.level or plugin.level == "event" %} +

+ + {% trans "This plugin can be enabled or disabled for events individually." %} +

+ + {% elif plugin.level == "event_organizer" %} +

+ + {% trans "Parts of this plugin can be enabled or disabled for events individually." %} +

+
+ +
+ {% endif %} + {% endif %} + {% if plugin.featured %} +
+
+ {% endif %} +
+ {% endfor %} +
+
+ {% endfor %} +
+
+ +{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index f14286706e..9fceaf6320 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -115,6 +115,10 @@ urlpatterns = [ re_path(r'^organizers/select2$', typeahead.organizer_select2, name='organizers.select2'), re_path(r'^organizer/(?P[^/]+)/$', organizer.OrganizerDetail.as_view(), name='organizer'), re_path(r'^organizer/(?P[^/]+)/edit$', organizer.OrganizerUpdate.as_view(), name='organizer.edit'), + re_path(r'^organizer/(?P[^/]+)/settings/plugins$', + organizer.OrganizerPlugins.as_view(), name='organizer.settings.plugins'), + re_path(r'^organizer/(?P[^/]+)/settings/plugins/(?P[^/]+)/events$', + organizer.OrganizerPluginEvents.as_view(), name='organizer.settings.plugin-events'), re_path(r'^organizer/(?P[^/]+)/settings/email$', organizer.OrganizerMailSettings.as_view(), name='organizer.settings.mail'), re_path(r'^organizer/(?P[^/]+)/settings/email/setup$', diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index a5d6fc408e..9f3f7cdb8a 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -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, }) diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index dba56b546f..d0fe7a6ff3 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -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 = [ + '

', + format_html(_('The plugin {} is now active, you can configure it here:'), + format_html("{}", pluginmeta.name)), + '

', + ] + [ + format_html('{} ', url, text) + for url, text in links + ] + ['

'] + else: + info = [ + format_html(_('The plugin {} is now active.'), + format_html("{}", 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' diff --git a/src/pretix/presale/signals.py b/src/pretix/presale/signals.py index 920466adc5..9c425a7f5b 100644 --- a/src/pretix/presale/signals.py +++ b/src/pretix/presale/signals.py @@ -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`` diff --git a/src/pretix/presale/style.py b/src/pretix/presale/style.py index 6f8945a33e..c19d41cc1e 100644 --- a/src/pretix/presale/style.py +++ b/src/pretix/presale/style.py @@ -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. diff --git a/src/pretix/settings.py b/src/pretix/settings.py index e25555e47d..10123aa9bb 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -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) diff --git a/src/setup.cfg b/src/setup.cfg index ac2dfb682b..5ee13e4487 100644 --- a/src/setup.cfg +++ b/src/setup.cfg @@ -26,6 +26,7 @@ filterwarnings = ignore::django.utils.deprecation.RemovedInDjango51Warning:django.core.files.storage ignore:.*index_together.*:django.utils.deprecation.RemovedInDjango51Warning: ignore:.*get_storage_class.*:django.utils.deprecation.RemovedInDjango51Warning:compressor + ignore:.*This signal will soon be only available for plugins that declare to be organizer-level.*:DeprecationWarning: ignore::DeprecationWarning:mt940 ignore::DeprecationWarning:cbor2 ignore::DeprecationWarning:markdown diff --git a/src/tests/api/test_events.py b/src/tests/api/test_events.py index 6737af5fe0..60a1522ee1 100644 --- a/src/tests/api/test_events.py +++ b/src/tests/api/test_events.py @@ -855,6 +855,39 @@ def test_event_update_plugins_validation(token_client, organizer, event, item, m ) assert resp.status_code == 200 + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), + { + "plugins": ["tests.testdummyorga"] + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.data == {"plugins": ["Plugin cannot be enabled on this level: 'tests.testdummyorga'."]} + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), + { + "plugins": ["tests.testdummyhybrid"] + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.data == {"plugins": ["Plugin should be enabled on organizer level first: 'tests.testdummyhybrid'."]} + + with scopes_disabled(): + organizer.enable_plugin("tests.testdummyhybrid") + organizer.save() + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), + { + "plugins": ["tests.testdummyhybrid"] + }, + format='json' + ) + assert resp.status_code == 200 + resp = token_client.patch( '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), { diff --git a/src/tests/api/test_oauth.py b/src/tests/api/test_oauth.py index 33e825c1c0..8c93b6bf72 100644 --- a/src/tests/api/test_oauth.py +++ b/src/tests/api/test_oauth.py @@ -423,7 +423,7 @@ def test_use_token_for_access_one_organizer(client, admin_user, organizer, appli assert resp.status_code == 200 data = json.loads(resp.content.decode()) assert data == {'count': 1, 'next': None, 'previous': None, 'results': [ - {'name': 'Dummy', 'slug': 'dummy', 'public_url': 'http://example.com/dummy/'} + {'name': 'Dummy', 'slug': 'dummy', 'public_url': 'http://example.com/dummy/', 'plugins': []} ]} resp = client.get('/api/v1/organizers/dummy/events/', HTTP_AUTHORIZATION='Bearer %s' % access_token) assert resp.status_code == 200 @@ -470,8 +470,8 @@ def test_use_token_for_access_two_organizers(client, admin_user, organizer, appl assert resp.status_code == 200 data = json.loads(resp.content.decode()) assert data == {'count': 2, 'next': None, 'previous': None, 'results': [ - {'name': 'A', 'slug': 'a', 'public_url': 'http://example.com/a/'}, - {'name': 'Dummy', 'slug': 'dummy', 'public_url': 'http://example.com/dummy/'}, + {'name': 'A', 'slug': 'a', 'public_url': 'http://example.com/a/', 'plugins': []}, + {'name': 'Dummy', 'slug': 'dummy', 'public_url': 'http://example.com/dummy/', 'plugins': []}, ]} resp = client.get('/api/v1/organizers/dummy/events/', HTTP_AUTHORIZATION='Bearer %s' % access_token) assert resp.status_code == 200 diff --git a/src/tests/api/test_organizers.py b/src/tests/api/test_organizers.py index b5bb0c208b..1773252a11 100644 --- a/src/tests/api/test_organizers.py +++ b/src/tests/api/test_organizers.py @@ -19,14 +19,19 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +import zoneinfo +from datetime import datetime + import pytest from django.core.files.base import ContentFile +from django_scopes import scopes_disabled from tests.const import SAMPLE_PNG TEST_ORGANIZER_RES = { "name": "Dummy", "slug": "dummy", - "public_url": "http://example.com/dummy/" + "public_url": "http://example.com/dummy/", + "plugins": [], } @@ -45,24 +50,57 @@ def test_organizer_detail(token_client, organizer): @pytest.mark.django_db -def test_get_settings(token_client, organizer): - organizer.settings.event_list_type = "week" - resp = token_client.get( - '/api/v1/organizers/{}/settings/'.format(organizer.slug,), +def test_organizer_patch(token_client, organizer): + with scopes_disabled(): + # An event needs to exist for the backwards-compatibility mechanism in get_all_plugins to trigger + event = organizer.events.create( + name="Event", slug="e2", live=True, + date_from=datetime(2020, 1, 10, 16, 0, tzinfo=zoneinfo.ZoneInfo("UTC")), + date_to=datetime(2020, 1, 10, 17, 0, tzinfo=zoneinfo.ZoneInfo("UTC")), + ) + resp = token_client.patch( + '/api/v1/organizers/{}/'.format(organizer.slug), + { + 'slug': 'willbeignored', + 'name': 'Willbeignored', + 'plugins': ['tests.testdummyorga', 'tests.testdummyhybrid'] + }, + format='json', ) assert resp.status_code == 200 - assert resp.data['event_list_type'] == "week" + assert resp.data['slug'] == 'dummy' + assert resp.data['name'] == 'Dummy' + assert set(resp.data['plugins']) == {'tests.testdummyorga', 'tests.testdummyhybrid'} - resp = token_client.get( - '/api/v1/organizers/{}/settings/?explain=true'.format(organizer.slug), + resp = token_client.patch( + '/api/v1/organizers/{}/'.format(organizer.slug), + { + 'slug': 'willbeignored', + 'name': 'Willbeignored', + 'plugins': ['pretix.plugins.statistics'] + }, + format='json', + ) + assert resp.status_code == 400 + assert resp.data == { + "plugins": ["Plugin cannot be enabled on this level: 'pretix.plugins.statistics'."] + } + + event.plugins = "tests.testdummyhybrid,tests.testdummy" + event.save() + resp = token_client.patch( + '/api/v1/organizers/{}/'.format(organizer.slug), + { + 'slug': 'willbeignored', + 'name': 'Willbeignored', + 'plugins': ['tests.testdummyorga'] + }, + format='json', ) assert resp.status_code == 200 - assert resp.data['event_list_type'] == { - "value": "week", - "label": "Default overview style", - "help_text": "If your event series has more than 50 dates in the future, only the month or week calendar can be used.", - "readonly": False, - } + + event.refresh_from_db() + assert event.plugins == "tests.testdummy" @pytest.mark.django_db diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index 7380efd1bb..96518a448c 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -203,6 +203,7 @@ event_permission_sub_urls = [ ] org_permission_sub_urls = [ + ('patch', 'can_change_organizer_settings', '', 200), ('get', 'can_change_organizer_settings', 'settings/', 200), ('patch', 'can_change_organizer_settings', 'settings/', 200), ('get', 'can_change_organizer_settings', 'webhooks/', 200), diff --git a/src/tests/base/test_datasync.py b/src/tests/base/test_datasync.py index aed1b93825..1d2f35d0b8 100644 --- a/src/tests/base/test_datasync.py +++ b/src/tests/base/test_datasync.py @@ -182,6 +182,11 @@ def _register_with_fake_plugin_name(registry, obj, plugin_name): class App: name = plugin_name + + class PretixPluginMeta: + pass + + obj.__mocked_app = App registry.register(obj) registry.registered_entries[obj]['plugin'] = App diff --git a/src/tests/base/test_registry.py b/src/tests/base/test_registry.py index 403f2d1d88..c170a1d95c 100644 --- a/src/tests/base/test_registry.py +++ b/src/tests/base/test_registry.py @@ -22,10 +22,12 @@ from unittest import mock import pytest +from django.apps import apps from pretix.base.logentrytypes import ( ItemLogEntryType, LogEntryType, LogEntryTypeRegistry, ) +from pretix.base.models import Event from pretix.base.signals import Registry @@ -140,29 +142,38 @@ def test_logentrytype_registry(): reg = LogEntryTypeRegistry() with mock.patch('pretix.base.signals.get_defining_app') as mock_get_defining_app: - mock_get_defining_app.return_value = 'my_plugin' + mock_get_defining_app.return_value = apps.get_app_config("testdummy") @reg.new("foo.mytype") class MyType(LogEntryType): pass - @reg.new("foo.myothertype") - class MyOtherType(LogEntryType): - pass + with mock.patch('pretix.base.signals.get_defining_app') as mock_get_defining_app: + mock_get_defining_app.return_value = "CORE" + + @reg.new("foo.myothertype") + class MyOtherType(LogEntryType): + pass typ, meta = reg.get(action_type="foo.mytype") assert isinstance(typ, MyType) assert meta['action_type'] == "foo.mytype" - assert meta['plugin'] == 'my_plugin' + assert meta['plugin'] == apps.get_app_config("testdummy") typ, meta = reg.get(action_type="foo.myothertype") assert isinstance(typ, MyOtherType) assert meta['action_type'] == "foo.myothertype" - assert meta['plugin'] is None + assert meta['plugin'] == "CORE" - by_my_plugin = reg.filter(plugin='my_plugin') + by_my_plugin = reg.filter(plugin=apps.get_app_config("testdummy")) assert set(type(typ) for typ, meta in by_my_plugin) == {MyType} + by_active_plugin = reg.filter(active_in=Event(plugins="")) + assert set(type(typ) for typ, meta in by_active_plugin) == {MyOtherType} + + by_active_plugin = reg.filter(active_in=Event(plugins="tests.testdummy")) + assert set(type(typ) for typ, meta in by_active_plugin) == {MyType, MyOtherType} + def test_logentrytype_registry_validation(): reg = LogEntryTypeRegistry() diff --git a/src/tests/control/test_events.py b/src/tests/control/test_events.py index e7c2e4011f..4ea17dbc9a 100644 --- a/src/tests/control/test_events.py +++ b/src/tests/control/test_events.py @@ -327,6 +327,28 @@ class EventsTest(SoupTest): self.event1.refresh_from_db() assert "testdummyrestricted" in self.event1.plugins + self.post_doc('/control/event/%s/%s/settings/plugins' % (self.orga1.slug, self.event1.slug), + {'plugin:tests.testdummyorga': 'enable'}) + self.event1.refresh_from_db() + assert "testdummyorga" not in self.event1.plugins + + self.post_doc('/control/event/%s/%s/settings/plugins' % (self.orga1.slug, self.event1.slug), + {'plugin:tests.testdummyhybrid': 'enable'}) + self.event1.refresh_from_db() + assert "tests.testdummyhybrid" not in self.event1.plugins + self.orga1.refresh_from_db() + assert "tests.testdummyhybrid" not in self.orga1.plugins + + t2 = Team.objects.create(organizer=self.orga1, can_change_organizer_settings=True) + t2.members.add(self.user) + + self.post_doc('/control/event/%s/%s/settings/plugins' % (self.orga1.slug, self.event1.slug), + {'plugin:tests.testdummyhybrid': 'enable'}) + self.event1.refresh_from_db() + assert "tests.testdummyhybrid" in self.event1.plugins + self.orga1.refresh_from_db() + assert "tests.testdummyhybrid" in self.orga1.plugins + def test_testmode_enable(self): self.event1.testmode = False self.event1.save() diff --git a/src/tests/control/test_organizer.py b/src/tests/control/test_organizer.py index 4604ebee44..fe7db04568 100644 --- a/src/tests/control/test_organizer.py +++ b/src/tests/control/test_organizer.py @@ -380,3 +380,76 @@ class OrganizerTest(SoupTest): assert doc.select('.alert-danger') with scopes_disabled(): assert self.orga1.sales_channels.filter(identifier="web").exists() + + def test_plugins(self): + doc = self.get_doc('/control/organizer/%s/settings/plugins' % self.orga1.slug) + self.assertIn("Stripe", doc.select(".form-plugins")[0].text) + self.assertIn("Enable", doc.select("[name=\"plugin:tests.testdummyorga\"]")[0].text) + self.assertIn("Enable", doc.select("[name=\"plugin:tests.testdummyhybrid\"]")[0].text) + assert not doc.select("[name=\"plugin:pretix.plugins.stripe\"]") + assert not doc.select("[name=\"plugin:tests.testdummy\"]") + assert not doc.select("[name=\"plugin:tests.testdummyrestricted\"]") + assert not doc.select("[name=\"plugin:tests.testdummyorgarestricted\"]") + assert not doc.select("[name=\"plugin:tests.testdummyhidden\"]") + + doc = self.post_doc('/control/organizer/%s/settings/plugins' % self.orga1.slug, + {'plugin:tests.testdummyorga': 'enable'}) + self.assertIn("Disable", doc.select("[name=\"plugin:tests.testdummyorga\"]")[0].text) + + doc = self.post_doc('/control/organizer/%s/settings/plugins' % self.orga1.slug, + {'plugin:tests.testdummyhybrid': 'enable'}) + self.assertIn("Events with plugin testdummyhybrid", doc.select("h1")[0].text) + self.orga1.refresh_from_db() + assert "tests.testdummyhybrid" in self.orga1.get_plugins() + + doc = self.post_doc('/control/organizer/%s/settings/plugins' % self.orga1.slug, + {'plugin:tests.testdummyhybrid': 'disable'}) + self.assertIn("Enable", doc.select("[name=\"plugin:tests.testdummyhybrid\"]")[0].text) + + self.post_doc('/control/organizer/%s/settings/plugins' % self.orga1.slug, + {'plugin:tests.testdummy': 'enable'}) + self.orga1.refresh_from_db() + assert "tests.testdummy" not in self.orga1.get_plugins() + + self.post_doc('/control/organizer/%s/settings/plugins' % self.orga1.slug, + {'plugin:tests.testdummyorgarestricted': 'enable'}) + self.orga1.refresh_from_db() + assert "testdummyorgarestricted" not in self.orga1.get_plugins() + + self.orga1.settings.allowed_restricted_plugins = ["tests.testdummyorgarestricted"] + + self.post_doc('/control/organizer/%s/settings/plugins' % self.orga1.slug, + {'plugin:tests.testdummyorgarestricted': 'enable'}) + self.orga1.refresh_from_db() + assert "tests.testdummyorgarestricted" in self.orga1.get_plugins() + + def test_plugin_events(self): + resp = self.client.get('/control/organizer/%s/settings/plugins/tests.testdummyorga/events' % self.orga1.slug) + assert resp.status_code == 404 + assert b"only be enabled for the entire organizer account" in resp.content + + resp = self.client.get( + '/control/organizer/%s/settings/plugins/tests.testdummyrestricted/events' % self.orga1.slug) + assert resp.status_code == 404 + assert b"currently not allowed" in resp.content + + resp = self.client.get('/control/organizer/%s/settings/plugins/tests.testdummyhybrid/events' % self.orga1.slug) + assert resp.status_code == 404 + assert b"currently not active on the organizer" in resp.content + + resp = self.client.get('/control/organizer/%s/settings/plugins/pretix.plugins.stripe/events' % self.orga1.slug) + assert resp.status_code == 200 + + resp = self.client.post('/control/organizer/%s/settings/plugins/pretix.plugins.stripe/events' % self.orga1.slug, + {'events': self.event1.pk}) + assert resp.status_code == 302 + self.event1.refresh_from_db() + assert 'pretix.plugins.stripe' in self.event1.get_plugins() + assert 'pretix.plugins.banktransfer' in self.event1.get_plugins() + + resp = self.client.post('/control/organizer/%s/settings/plugins/pretix.plugins.banktransfer/events' % self.orga1.slug, + {}) + assert resp.status_code == 302 + self.event1.refresh_from_db() + assert 'pretix.plugins.banktransfer' not in self.event1.get_plugins() + assert 'pretix.plugins.stripe' in self.event1.get_plugins() diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index 5a846d4cc8..d2a35284d8 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -183,6 +183,8 @@ event_urls = [ organizer_urls = [ 'organizer/abc/edit', 'organizer/abc/', + 'organizer/abc/settings/plugins', + 'organizer/abc/settings/plugins/pretix.plugins.sendmail/events', 'organizer/abc/settings/email', 'organizer/abc/settings/email/setup', 'organizer/abc/teams', @@ -287,7 +289,7 @@ def test_wrong_event(perf_patch, client, env, url): organizer=env[2], name='Dummy', slug='dummy2', date_from=now(), plugins='pretix.plugins.banktransfer' ) - t = Team.objects.create(organizer=env[2], can_change_event_settings=True) + t = Team.objects.create(pk=2, organizer=env[2], can_change_event_settings=True) t.members.add(env[1]) t.limit_events.add(event2) @@ -418,7 +420,7 @@ event_permission_urls = [ @pytest.mark.parametrize("perm,url,code,http_method", event_permission_urls) def test_wrong_event_permission(perf_patch, client, env, perm, url, code, http_method): t = Team( - organizer=env[2], all_events=True + pk=2, organizer=env[2], all_events=True ) setattr(t, perm, False) t.save() @@ -438,7 +440,7 @@ def test_limited_event_permission_for_other_event(perf_patch, client, env, perm, organizer=env[2], name='Dummy', slug='dummy2', date_from=now(), plugins='pretix.plugins.banktransfer' ) - t = Team.objects.create(organizer=env[2], can_change_event_settings=True) + t = Team.objects.create(pk=2, organizer=env[2], can_change_event_settings=True) t.members.add(env[1]) t.limit_events.add(event2) @@ -453,7 +455,7 @@ def test_limited_event_permission_for_other_event(perf_patch, client, env, perm, @pytest.mark.django_db def test_current_permission(client, env): t = Team( - organizer=env[2], all_events=True + pk=2, organizer=env[2], all_events=True ) setattr(t, 'can_change_event_settings', True) t.save() @@ -471,7 +473,7 @@ def test_current_permission(client, env): @pytest.mark.django_db @pytest.mark.parametrize("perm,url,code,http_method", event_permission_urls) def test_correct_event_permission_all_events(perf_patch, client, env, perm, url, code, http_method): - t = Team(organizer=env[2], all_events=True) + t = Team(pk=2, organizer=env[2], all_events=True) setattr(t, perm, True) t.save() t.members.add(env[1]) @@ -489,7 +491,7 @@ def test_correct_event_permission_all_events(perf_patch, client, env, perm, url, @pytest.mark.django_db @pytest.mark.parametrize("perm,url,code,http_method", event_permission_urls) def test_correct_event_permission_limited(perf_patch, client, env, perm, url, code, http_method): - t = Team(organizer=env[2]) + t = Team(pk=2, organizer=env[2]) setattr(t, perm, True) t.save() t.members.add(env[1]) @@ -522,6 +524,8 @@ organizer_permission_urls = [ ("can_change_teams", "organizer/dummy/team/1/edit", 200), ("can_change_teams", "organizer/dummy/team/1/delete", 200), ("can_change_organizer_settings", "organizer/dummy/edit", 200), + ("can_change_organizer_settings", "organizer/dummy/settings/plugins", 200), + ("can_change_organizer_settings", "organizer/dummy/settings/plugins/pretix.plugins.sendmail/events", 200), ("can_change_organizer_settings", "organizer/dummy/settings/email", 200), ("can_change_organizer_settings", "organizer/dummy/settings/email/setup", 200), ("can_change_organizer_settings", "organizer/dummy/devices", 200), @@ -580,7 +584,7 @@ organizer_permission_urls = [ @pytest.mark.django_db @pytest.mark.parametrize("perm,url,code", organizer_permission_urls) def test_wrong_organizer_permission(perf_patch, client, env, perm, url, code): - t = Team(organizer=env[2]) + t = Team(pk=2, organizer=env[2]) setattr(t, perm, False) t.save() t.members.add(env[1]) @@ -592,7 +596,7 @@ def test_wrong_organizer_permission(perf_patch, client, env, perm, url, code): @pytest.mark.django_db @pytest.mark.parametrize("perm,url,code", organizer_permission_urls) def test_correct_organizer_permission(perf_patch, client, env, perm, url, code): - t = Team(organizer=env[2]) + t = Team(pk=2, organizer=env[2]) setattr(t, perm, True) t.save() t.members.add(env[1]) diff --git a/src/tests/settings.py b/src/tests/settings.py index 827f0a2e28..39ab239a1c 100644 --- a/src/tests/settings.py +++ b/src/tests/settings.py @@ -30,6 +30,9 @@ TEMPLATES[0]['DIRS'].append(os.path.join(TEST_DIR, 'templates')) # NOQA INSTALLED_APPS.append('tests.testdummy') # NOQA INSTALLED_APPS.append('tests.testdummyrestricted') # NOQA INSTALLED_APPS.append('tests.testdummyhidden') # NOQA +INSTALLED_APPS.append('tests.testdummyorga') # NOQA +INSTALLED_APPS.append('tests.testdummyhybrid') # NOQA +INSTALLED_APPS.append('tests.testdummyorgarestricted') # NOQA PRETIX_AUTH_BACKENDS = [ 'pretix.base.auth.NativeAuthBackend', diff --git a/src/tests/testdummy/apps.py b/src/tests/testdummy/apps.py index ea89f7ce8b..818642209b 100644 --- a/src/tests/testdummy/apps.py +++ b/src/tests/testdummy/apps.py @@ -19,10 +19,10 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # -from django.apps import AppConfig +from pretix.base.plugins import PluginConfig -class TestDummyApp(AppConfig): +class TestDummyApp(PluginConfig): name = 'tests.testdummy' verbose_name = '.testdummy' diff --git a/src/tests/testdummyhybrid/__init__.py b/src/tests/testdummyhybrid/__init__.py new file mode 100644 index 0000000000..9fd5bdc500 --- /dev/null +++ b/src/tests/testdummyhybrid/__init__.py @@ -0,0 +1,21 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# diff --git a/src/tests/testdummyhybrid/apps.py b/src/tests/testdummyhybrid/apps.py new file mode 100644 index 0000000000..fac15229ac --- /dev/null +++ b/src/tests/testdummyhybrid/apps.py @@ -0,0 +1,34 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +from pretix.base.plugins import ( + PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID, PluginConfig, +) + + +class TestDummyHybridApp(PluginConfig): + name = 'tests.testdummyhybrid' + verbose_name = 'testdummyhybrid' + + class PretixPluginMeta: + name = 'testdummyhybrid' + version = '1.0.0' + level = PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID diff --git a/src/tests/testdummyorga/__init__.py b/src/tests/testdummyorga/__init__.py new file mode 100644 index 0000000000..9fd5bdc500 --- /dev/null +++ b/src/tests/testdummyorga/__init__.py @@ -0,0 +1,21 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# diff --git a/src/tests/testdummyorga/apps.py b/src/tests/testdummyorga/apps.py new file mode 100644 index 0000000000..d7af97e5b2 --- /dev/null +++ b/src/tests/testdummyorga/apps.py @@ -0,0 +1,32 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +from pretix.base.plugins import PLUGIN_LEVEL_ORGANIZER, PluginConfig + + +class TestDummyOrgaApp(PluginConfig): + name = 'tests.testdummyorga' + verbose_name = 'testdummyorga' + + class PretixPluginMeta: + name = 'testdummyorga' + version = '1.0.0' + level = PLUGIN_LEVEL_ORGANIZER diff --git a/src/tests/testdummyorgarestricted/__init__.py b/src/tests/testdummyorgarestricted/__init__.py new file mode 100644 index 0000000000..9fd5bdc500 --- /dev/null +++ b/src/tests/testdummyorgarestricted/__init__.py @@ -0,0 +1,21 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# diff --git a/src/tests/testdummyorgarestricted/apps.py b/src/tests/testdummyorgarestricted/apps.py new file mode 100644 index 0000000000..324b995455 --- /dev/null +++ b/src/tests/testdummyorgarestricted/apps.py @@ -0,0 +1,33 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +from pretix.base.plugins import PLUGIN_LEVEL_ORGANIZER, PluginConfig + + +class TestDummyOrgaRestrictedApp(PluginConfig): + name = 'tests.testdummyorgarestricted' + verbose_name = 'testdummyorgarestricted' + + class PretixPluginMeta: + name = 'testdummyorgarestricted' + version = '1.0.0' + level = PLUGIN_LEVEL_ORGANIZER + restricted = True