diff --git a/doc/api/deviceauth.rst b/doc/api/deviceauth.rst index 494268a77b..65ccbc3572 100644 --- a/doc/api/deviceauth.rst +++ b/doc/api/deviceauth.rst @@ -197,10 +197,11 @@ Permissions & security profiles Device authentication is currently hardcoded to grant the following permissions: -* View event meta data and products etc. -* View orders -* Change orders -* Manage gift cards +* Read event meta data and products etc. +* Read and write orders +* Read and write gift cards +* Read and write reusable media +* Read vouchers Devices cannot change events or products and cannot access vouchers. diff --git a/doc/api/resources/devices.rst b/doc/api/resources/devices.rst index 85d507f223..76facd246b 100644 --- a/doc/api/resources/devices.rst +++ b/doc/api/resources/devices.rst @@ -30,7 +30,7 @@ software_brand string Device software software_version string Device software version (read-only) created datetime Creation time initialized datetime Time of initialization (or ``null``) -initialization_token string Token for initialization +initialization_token string Token for initialization (field invisible without write permission) revoked boolean Whether this device no longer has access security_profile string The name of a supported security profile restricting API access ===================================== ========================== ======================================================= diff --git a/doc/api/resources/events.rst b/doc/api/resources/events.rst index 95bd5e2493..69a3022f65 100644 --- a/doc/api/resources/events.rst +++ b/doc/api/resources/events.rst @@ -65,8 +65,6 @@ Endpoints Returns a list of all events within a given organizer the authenticated user/token has access to. - Permission required: "Can change event settings" - **Example request**: .. sourcecode:: http @@ -161,8 +159,6 @@ Endpoints Returns information on one event, identified by its slug. - Permission required: "Can change event settings" - **Example request**: .. sourcecode:: http @@ -234,8 +230,6 @@ Endpoints Please note that events cannot be created as 'live' using this endpoint. Quotas and payment must be added to the event before sales can go live. - Permission required: "Can create events" - **Example request**: .. sourcecode:: http @@ -338,8 +332,6 @@ Endpoints Please note that you can only copy from events under the same organizer this way. Use the ``clone_from`` parameter when creating a new event for this instead. - Permission required: "Can create events" - **Example request**: .. sourcecode:: http @@ -433,8 +425,6 @@ Endpoints Updates an event - Permission required: "Can change event settings" - **Example request**: .. sourcecode:: http @@ -510,8 +500,6 @@ Endpoints Delete an event. Note that events with orders cannot be deleted to ensure data integrity. - Permission required: "Can change event settings" - **Example request**: .. sourcecode:: http @@ -561,8 +549,6 @@ organizer level. Get current values of event settings. - Permission required: "Can change event settings" (Exception: with device auth, *some* settings can always be *read*.) - **Example request**: .. sourcecode:: http @@ -615,6 +601,8 @@ organizer level. Updates event settings. Note that ``PUT`` is not allowed here, only ``PATCH``. + Permission "Can change event settings" is always required. Some keys require additional permissions. + .. warning:: Settings can be stored at different levels in pretix. If a value is not set on event level, a default setting diff --git a/doc/api/resources/organizers.rst b/doc/api/resources/organizers.rst index 066657a4de..3fa2c52734 100644 --- a/doc/api/resources/organizers.rst +++ b/doc/api/resources/organizers.rst @@ -110,8 +110,6 @@ Endpoints Updates an organizer. Currently only the ``plugins`` field may be updated. - Permission required: "Can change organizer settings" - **Example request**: .. sourcecode:: http @@ -172,8 +170,6 @@ information about the properties. Get current values of organizer settings. - Permission required: "Can change organizer settings" - **Example request**: .. sourcecode:: http diff --git a/doc/api/resources/reusablemedia.rst b/doc/api/resources/reusablemedia.rst index 101b11b4b1..e0618a5b41 100644 --- a/doc/api/resources/reusablemedia.rst +++ b/doc/api/resources/reusablemedia.rst @@ -154,7 +154,7 @@ Endpoints .. http:post:: /api/v1/organizers/(organizer)/reusablemedia/lookup/ Look up a new reusable medium by its identifier. In some cases, this might lead to the automatic creation of a new - medium behind the scenes. + medium behind the scenes, therefore this endpoint requires write permissions. This endpoint, and this endpoint only, might return media from a different organizer if there is a cross-acceptance agreement. In this case, only linked gift cards will be returned, no order position or customer records, diff --git a/doc/api/resources/subevents.rst b/doc/api/resources/subevents.rst index 0a3f77a193..dd5fea07a9 100644 --- a/doc/api/resources/subevents.rst +++ b/doc/api/resources/subevents.rst @@ -154,8 +154,6 @@ Endpoints Creates a new subevent. - Permission required: "Can create events" - **Example request**: .. sourcecode:: http @@ -300,8 +298,6 @@ Endpoints provide all fields of the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you want to change. - Permission required: "Can change event settings" - **Example request**: .. sourcecode:: http @@ -373,8 +369,6 @@ Endpoints Delete a sub-event. Note that events with orders cannot be deleted to ensure data integrity. - Permission required: "Can change event settings" - **Example request**: .. sourcecode:: http diff --git a/doc/api/resources/teams.rst b/doc/api/resources/teams.rst index baff8e493a..971a28d4aa 100644 --- a/doc/api/resources/teams.rst +++ b/doc/api/resources/teams.rst @@ -24,21 +24,58 @@ all_events boolean Whether this te limit_events list List of event slugs this team has access to require_2fa boolean Whether members of this team are required to use two-factor authentication -can_create_events boolean -can_change_teams boolean -can_change_organizer_settings boolean -can_manage_customers boolean -can_manage_reusable_media boolean -can_manage_gift_cards boolean -can_change_event_settings boolean -can_change_items boolean -can_view_orders boolean -can_change_orders boolean -can_view_vouchers boolean -can_change_vouchers boolean -can_checkin_orders boolean +all_event_permissions bool Whether members of this team are granted all event-level + permissions, including future additions +limit_event_permissions list of strings The event-level permissions team members are granted +all_organizer_permissions bool Whether members of this team are granted all organizer-level + permissions, including future additions +all_organizer_permissions list of strings The organizer-level permissions team members are granted +can_create_events boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``. +can_change_teams boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``. +can_change_organizer_settings boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``. +can_manage_customers boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``. +can_manage_reusable_media boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``. +can_manage_gift_cards boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``. +can_change_event_settings boolean **DEPRECATED**. Legacy interface, use ``limit_event_permissions``. +can_change_items boolean **DEPRECATED**. Legacy interface, use ``limit_event_permissions``. +can_view_orders boolean **DEPRECATED**. Legacy interface, use ``limit_event_permissions``. +can_change_orders boolean **DEPRECATED**. Legacy interface, use ``limit_event_permissions``. +can_view_vouchers boolean **DEPRECATED**. Legacy interface, use ``limit_event_permissions``. +can_change_vouchers boolean **DEPRECATED**. Legacy interface, use ``limit_event_permissions``. +can_checkin_orders boolean **DEPRECATED**. Legacy interface, use ``limit_event_permissions``. ===================================== ========================== ======================================================= +Possible values for ``limit_organizer_permissions`` defined in the core pretix system (plugins might add more):: + + organizer.events:create + organizer.settings.general:write + organizer.teams:write + organizer.seatingplans:write + organizer.giftcards:read + organizer.giftcards:write + organizer.customers:read + organizer.customers:write + organizer.reusablemedia:read + organizer.reusablemedia:write + organizer.devices:read + organizer.devices:write + organizer.outgoingmails:read + +Possible values for ``limit_event_permissions`` defined in the core pretix system (plugins might add more):: + + event.settings.general:write + event.settings.payment:write + event.settings.tax:write + event.settings.invoicing:write + event.subevents:write + event.items:write + event.orders:read + event.orders:write + event.orders:checkin + event.vouchers:read + event.vouchers:write + event:cancel + Team member resource -------------------- @@ -121,6 +158,10 @@ Team endpoints "all_events": true, "limit_events": [], "require_2fa": true, + "all_event_permissions": true, + "limit_event_permissions": [], + "all_organizer_permissions": true, + "limit_organizer_permissions": [], "can_create_events": true, ... } @@ -159,6 +200,10 @@ Team endpoints "all_events": true, "limit_events": [], "require_2fa": true, + "all_event_permissions": true, + "limit_event_permissions": [], + "all_organizer_permissions": true, + "limit_organizer_permissions": [], "can_create_events": true, ... } @@ -187,7 +232,10 @@ Team endpoints "all_events": true, "limit_events": [], "require_2fa": true, - "can_create_events": true, + "all_event_permissions": true, + "limit_event_permissions": [], + "all_organizer_permissions": true, + "limit_organizer_permissions": [], ... } @@ -205,6 +253,10 @@ Team endpoints "all_events": true, "limit_events": [], "require_2fa": true, + "all_event_permissions": true, + "limit_event_permissions": [], + "all_organizer_permissions": true, + "limit_organizer_permissions": [], "can_create_events": true, ... } @@ -232,7 +284,8 @@ Team endpoints Content-Length: 94 { - "can_create_events": true + "all_organizer_permissions": false, + "limit_organizer_permissions": ["organizer.events:create"] } **Example response**: @@ -249,6 +302,10 @@ Team endpoints "all_events": true, "limit_events": [], "require_2fa": true, + "all_event_permissions": true, + "limit_event_permissions": [], + "all_organizer_permissions": false, + "limit_organizer_permissions": ["organizer.events:create"], "can_create_events": true, ... } diff --git a/doc/development/api/customview.rst b/doc/development/api/customview.rst index 8c18dc9821..b147f73539 100644 --- a/doc/development/api/customview.rst +++ b/doc/development/api/customview.rst @@ -55,12 +55,12 @@ your views: ) class AdminView(EventPermissionRequiredMixin, View): - permission = 'can_view_orders' + permission = 'event.orders:read' ... - @event_permission_required('can_view_orders') + @event_permission_required('event.orders:read') def admin_view(request, organizer, event): ... @@ -78,7 +78,7 @@ event-related views, there is also a signal that allows you to add the view to t @receiver(nav_event, dispatch_uid='friends_tickets_nav') def navbar_info(sender, request, **kwargs): url = resolve(request.path_info) - if not request.user.has_event_permission(request.organizer, request.event, 'can_change_vouchers'): + if not request.user.has_event_permission(request.organizer, request.event, 'event.vouchers:read'): return [] return [{ 'label': _('My plugin view'), @@ -118,7 +118,7 @@ for good integration. If you just want to display a form, you could do it like t class MySettingsView(EventSettingsViewMixin, EventSettingsFormView): model = Event - permission = 'can_change_settings' + permission = 'event.settings.general:write' form_class = MySettingsForm template_name = 'my_plugin/settings.html' @@ -204,13 +204,13 @@ In case of ``orga_router`` and ``event_router``, permission checking is done for in the control panel. However, you need to make sure on your own only to return the correct subset of data! ``request .event`` and ``request.organizer`` are available as usual. -To require a special permission like ``can_view_orders``, you do not need to inherit from a special ViewSet base +To require a special permission like ``event.orders:read``, you do not need to inherit from a special ViewSet base class, you can just set the ``permission`` attribute on your viewset: .. code-block:: python class MyViewSet(ModelViewSet): - permission = 'can_view_orders' + permission = 'event.orders:read' ... If you want to check the permission only for some methods of your viewset, you have to do it yourself. Note here that @@ -220,7 +220,7 @@ following: .. code-block:: python perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken) else request.user) - if perm_holder.has_event_permission(request.event.organizer, request.event, 'can_view_orders'): + if perm_holder.has_event_permission(request.event.organizer, request.event, 'event.orders:read'): ... diff --git a/doc/development/api/exporter.rst b/doc/development/api/exporter.rst index 5974dab91e..6bcb24b004 100644 --- a/doc/development/api/exporter.rst +++ b/doc/development/api/exporter.rst @@ -80,8 +80,24 @@ The exporter class .. autoattribute:: category + .. autoattribute:: feature + .. autoattribute:: export_form_fields + .. autoattribute:: repeatable_read + .. automethod:: render This is an abstract method, you **must** override this! + + .. automethod:: available_for_user + + .. automethod:: get_required_event_permission + +On organizer level, by default exporters are expected to handle on a *set of events* and the system will automatically +add a form field that allows the selection of events, limited to events the user has correct permissions for. If this +does not fit your organizer, because it is not related to events, you should **also** inherit from the following class: + +.. class:: pretix.base.exporter.OrganizerLevelExportMixin + + .. automethod:: get_required_organizer_permission diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst index ce0bcf0dc6..f565da5c1a 100644 --- a/doc/development/api/general.rst +++ b/doc/development/api/general.rst @@ -14,7 +14,8 @@ Core :members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, notification, item_copy_data, register_sales_channel_types, register_global_settings, quota_availability, global_email_filter, register_ticket_secret_generators, gift_card_transaction_display, - register_text_placeholders, register_mail_placeholders, device_info_updated + register_text_placeholders, register_mail_placeholders, device_info_updated, + register_event_permission_groups, register_organizer_permission_groups Order events """""""""""" diff --git a/doc/development/implementation/logging.rst b/doc/development/implementation/logging.rst index 7c378b4561..8b618de7fc 100644 --- a/doc/development/implementation/logging.rst +++ b/doc/development/implementation/logging.rst @@ -196,7 +196,7 @@ A simple implementation could look like this: .. code-block:: python class MyNotificationType(NotificationType): - required_permission = "can_view_orders" + required_permission = "event.orders:read" action_type = "pretix.event.order.paid" verbose_name = _("Order has been paid") diff --git a/doc/development/implementation/permissions.rst b/doc/development/implementation/permissions.rst index ef6a4156dd..318c4baf0e 100644 --- a/doc/development/implementation/permissions.rst +++ b/doc/development/implementation/permissions.rst @@ -2,7 +2,7 @@ Permissions =========== pretix uses a fine-grained permission system to control who is allowed to control what parts of the system. -The central concept here is the concept of *Teams*. You can read more on `configuring teams and permissions `_ +The central concept here is the concept of *Teams*. You can read more on `configuring teams and permissions`_ and the :class:`pretix.base.models.Team` model in the respective parts of the documentation. The basic digest is: An organizer account can have any number of teams, and any number of users can be part of a team. A team can be assigned a set of permissions and connected to some or all of the events of the organizer. @@ -25,8 +25,8 @@ permission level to access a view: class MyOrgaView(OrganizerPermissionRequiredMixin, View): - permission = 'can_change_organizer_settings' - # Only users with the permission ``can_change_organizer_settings`` on + permission = 'organizer.settings.general:write' + # Only users with the permission ``organizer.settings.general:write`` on # this organizer can access this @@ -35,9 +35,9 @@ permission level to access a view: # Only users with *any* permission on this organizer can access this - @organizer_permission_required('can_change_organizer_settings') + @organizer_permission_required('organizer.settings.general:write') def my_orga_view(request, organizer, **kwargs): - # Only users with the permission ``can_change_organizer_settings`` on + # Only users with the permission ``organizer.settings.general:write`` on # this organizer can access this @@ -56,8 +56,8 @@ Of course, the same is available on event level: class MyEventView(EventPermissionRequiredMixin, View): - permission = 'can_change_event_settings' - # Only users with the permission ``can_change_event_settings`` on + permission = 'event.settings.general:write' + # Only users with the permission ``event.settings.general:write`` on # this event can access this @@ -65,13 +65,16 @@ Of course, the same is available on event level: permission = None # Only users with *any* permission on this event can access this + class MyThirdEventView(EventPermissionRequiredMixin, View): + permission = AnyPermissionOf('event.settings.payment:write', 'event.settings.general:write') + # Only users with at least one of the specified permissions on this event + # can access this - @event_permission_required('can_change_event_settings') + @event_permission_required('event.settings.general:write') def my_event_view(request, organizer, **kwargs): - # Only users with the permission ``can_change_event_settings`` on + # Only users with the permission ``event.settings.general:write`` on # this event can access this - @event_permission_required() def my_other_event_view(request, organizer, **kwargs): # Only users with *any* permission on this event can access this @@ -121,7 +124,7 @@ When creating your own ``viewset`` using Django REST framework, you just need to and pretix will check it automatically for you:: class MyModelViewSet(viewsets.ReadOnlyModelViewSet): - permission = 'can_view_orders' + permission = 'event.orders:read' Checking permission in code --------------------------- @@ -136,12 +139,12 @@ Return all users that are in any team that is connected to this event:: Return all users that are in a team with a specific permission for this event:: - >>> event.get_users_with_permission('can_change_event_settings') + >>> event.get_users_with_permission('event.orders:read') Determine if a user has a certain permission for a specific event:: - >>> user.has_event_permission(organizer, event, 'can_change_event_settings', request=request) + >>> user.has_event_permission(organizer, event, 'event.orders:read', request=request) True Determine if a user has any permission for a specific event:: @@ -153,27 +156,27 @@ In the two previous commands, the ``request`` argument is optional, but required The same method exists for organizer-level permissions:: - >>> user.has_organizer_permission(organizer, 'can_change_event_settings', request=request) + >>> user.has_organizer_permission(organizer, 'event.orders:read', request=request) True Sometimes, it might be more useful to get the set of permissions at once:: >>> user.get_event_permission_set(organizer, event) - {'can_change_event_settings', 'can_view_orders', 'can_change_orders'} + {'event.settings.general:write', 'event.orders:read', 'event.orders:write'} >>> user.get_organizer_permission_set(organizer, event) - {'can_change_organizer_settings', 'can_create_events'} + {'organizer.settings.general:write', 'organizer.events:create'} Within a view on the ``/control`` subpath, the results of these two methods are already available in the ``request.eventpermset`` and ``request.orgapermset`` properties. This makes it convenient to query them in templates:: - {% if "can_change_orders" in request.eventpermset %} + {% if "event.orders:write" in request.eventpermset %} … {% endif %} You can also do the reverse to get any events a user has access to:: - >>> user.get_events_with_permission('can_change_event_settings', request=request) + >>> user.get_events_with_permission('event.settings.general:write', request=request) >>> user.get_events_with_any_permission(request=request) @@ -195,3 +198,53 @@ staff mode is active. You can check if a user is in staff mode using their sessi Staff mode has a hard time limit and during staff mode, a middleware will log all requests made by that user. Later, the user is able to also save a message to comment on what they did in their administrative session. This feature is intended to help compliance with data protection rules as imposed e.g. by GDPR. + +Adding permissions +------------------ + +Plugins can add permissions through the ``register_event_permission_groups`` and ``register_organizer_permission_groups``. +We recommend to use this only for very significant permissions, as the system will become less usable with too many +permission levels, also because the team page will show all permission options, even those of disabled plugins. + +To register your permissions, you need to register a **permission group** (often representing an area of functionality +or a key model). Below that group, there are **actions**, which represent the actual permissions. Permissions will be +generated as ``:``. Then, you need to define **options** which are the valid combinations of the +actions that should be possible to select for a team. This two-step mechanism exists to provide a better user experience +and avoid useless combinations like "write but not read". + +Example:: + + @receiver(register_event_permission_groups) + def register_plugin_event_permissions(sender, **kwargs): + return [ + PermissionGroup( + name="pretix_myplugin.resource", + label=_("Resources"), + actions=["read", "write"], + options=[ + PermissionOption(actions=tuple(), label=_("No access")), + PermissionOption(actions=("read",), label=_("View")), + PermissionOption(actions=("read", "write"), label=_("View and change")), + ], + help_text=_("Some help text") + ), + ] + + + @receiver(register_organizer_permission_groups) + def register_plugin_organizer_permissions(sender, **kwargs): + return [ + PermissionGroup( + name="pretix_myplugin.resource", + label=_("Resources"), + actions=["read", "write"], + options=[ + PermissionOption(actions=tuple(), label=_("No access")), + PermissionOption(actions=("read",), label=_("View")), + PermissionOption(actions=("read", "write"), label=_("View and change")), + ], + help_text=_("Some help text") + ), + ] + +.. _configuring teams and permissions: https://docs.pretix.eu/guides/teams/ \ No newline at end of file diff --git a/src/pretix/api/auth/permission.py b/src/pretix/api/auth/permission.py index 802781d006..c08205fc4a 100644 --- a/src/pretix/api/auth/permission.py +++ b/src/pretix/api/auth/permission.py @@ -36,7 +36,9 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission from pretix.api.models import OAuthAccessToken from pretix.base.models import Device, Event, User -from pretix.base.models.auth import SuperuserPermissionSet +from pretix.base.models.auth import ( + EventPermissionSet, OrganizerPermissionSet, SuperuserPermissionSet, +) from pretix.base.models.organizer import TeamAPIToken from pretix.helpers.security import ( Session2FASetupRequired, SessionInvalid, SessionPasswordChangeRequired, @@ -85,7 +87,7 @@ class EventPermission(BasePermission): if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key): request.eventpermset = SuperuserPermissionSet() else: - request.eventpermset = perm_holder.get_event_permission_set(request.organizer, request.event) + request.eventpermset = EventPermissionSet(perm_holder.get_event_permission_set(request.organizer, request.event)) if isinstance(required_permission, (list, tuple)): if not any(p in request.eventpermset for p in required_permission): @@ -100,7 +102,7 @@ class EventPermission(BasePermission): if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key): request.orgapermset = SuperuserPermissionSet() else: - request.orgapermset = perm_holder.get_organizer_permission_set(request.organizer) + request.orgapermset = OrganizerPermissionSet(perm_holder.get_organizer_permission_set(request.organizer)) if isinstance(required_permission, (list, tuple)): if not any(p in request.eventpermset for p in required_permission): @@ -124,12 +126,12 @@ class EventCRUDPermission(EventPermission): def has_permission(self, request, view): if not super(EventCRUDPermission, self).has_permission(request, view): return False - elif view.action == 'create' and 'can_create_events' not in request.orgapermset: + elif view.action == 'create' and 'organizer.events:create' not in request.orgapermset: return False - elif view.action == 'destroy' and 'can_change_event_settings' not in request.eventpermset: + elif view.action == 'destroy' and 'event.settings.general:write' not in request.eventpermset: return False elif view.action in ['update', 'partial_update'] \ - and 'can_change_event_settings' not in request.eventpermset: + and 'event.settings.general:write' not in request.eventpermset: return False return True diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 58acf3d4f5..ca163f0d33 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -300,7 +300,7 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): def ignored_meta_properties(self): perm_holder = (self.context['request'].auth if isinstance(self.context['request'].auth, (Device, TeamAPIToken)) else self.context['request'].user) - if perm_holder.has_organizer_permission(self.context['request'].organizer, 'can_change_organizer_settings', request=self.context['request']): + if perm_holder.has_organizer_permission(self.context['request'].organizer, 'organizer.settings.general:write', request=self.context['request']): return [] return [k for k, p in self.meta_properties.items() if p.protected] @@ -445,7 +445,7 @@ class CloneEventSerializer(EventSerializer): date_admission = validated_data.pop('date_admission', None) new_event = super().create({**validated_data, 'plugins': None}) - event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first() + event = self.context['event'] new_event.copy_data_from(event, skip_meta_data='meta_data' in validated_data) if plugins is not None: @@ -561,7 +561,7 @@ class SubEventSerializer(I18nAwareModelSerializer): def ignored_meta_properties(self): perm_holder = (self.context['request'].auth if isinstance(self.context['request'].auth, (Device, TeamAPIToken)) else self.context['request'].user) - if perm_holder.has_organizer_permission(self.context['request'].organizer, 'can_change_organizer_settings', request=self.context['request']): + if perm_holder.has_organizer_permission(self.context['request'].organizer, 'organizer.settings.general:write', request=self.context['request']): return [] return [k for k, p in self.meta_properties.items() if p.protected] @@ -707,7 +707,10 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer): class EventSettingsSerializer(SettingsSerializer): + default_write_permission = 'event.settings.general:write' default_fields = [ + # These are readable for all users with access to the events, therefore secrets stored in the settings store + # should not be included! 'imprint_url', 'checkout_email_helptext', 'presale_has_ended_text', @@ -1080,16 +1083,16 @@ class SeatSerializer(I18nAwareModelSerializer): def prefetch_expanded_data(self, items, request, expand_fields): if 'orderposition' in expand_fields: - if 'can_view_orders' not in request.eventpermset: - raise PermissionDenied('can_view_orders permission required for expand=orderposition') + if 'event.orders:read' not in request.eventpermset: + raise PermissionDenied('event.orders:read permission required for expand=orderposition') prefetch_by_id(items, OrderPosition.objects.prefetch_related('order'), 'orderposition_id', 'orderposition') if 'cartposition' in expand_fields: - if 'can_view_orders' not in request.eventpermset: - raise PermissionDenied('can_view_orders permission required for expand=cartposition') + if 'event.orders:read' not in request.eventpermset: + raise PermissionDenied('event.orders:read permission required for expand=cartposition') prefetch_by_id(items, CartPosition.objects, 'cartposition_id', 'cartposition') if 'voucher' in expand_fields: - if 'can_view_vouchers' not in request.eventpermset: - raise PermissionDenied('can_view_vouchers permission required for expand=voucher') + if 'event.vouchers:read' not in request.eventpermset: + raise PermissionDenied('event.vouchers:read permission required for expand=voucher') prefetch_by_id(items, Voucher.objects, 'voucher_id', 'voucher') def __init__(self, instance, *args, **kwargs): diff --git a/src/pretix/api/serializers/exporters.py b/src/pretix/api/serializers/exporters.py index b5f99ad466..1a612b1023 100644 --- a/src/pretix/api/serializers/exporters.py +++ b/src/pretix/api/serializers/exporters.py @@ -27,7 +27,9 @@ from rest_framework.exceptions import ValidationError from pretix.api.serializers.forms import form_field_to_serializer_field from pretix.base.exporter import OrganizerLevelExportMixin -from pretix.base.models import ScheduledEventExport, ScheduledOrganizerExport +from pretix.base.models import ( + Event, ScheduledEventExport, ScheduledOrganizerExport, +) from pretix.base.timeframes import SerializerDateFrameField @@ -54,20 +56,28 @@ class ExporterSerializer(serializers.Serializer): class JobRunSerializer(serializers.Serializer): def __init__(self, *args, **kwargs): - ex = kwargs.pop('exporter') - events = kwargs.pop('events', None) + ex = self.ex = kwargs.pop('exporter') super().__init__(*args, **kwargs) - if events is not None and not isinstance(ex, OrganizerLevelExportMixin): - self.fields["events"] = serializers.SlugRelatedField( - queryset=events, + if ex.is_multievent and not isinstance(ex, OrganizerLevelExportMixin): + self.fields["all_events"] = serializers.BooleanField( required=False, - allow_empty=False, + ) + self.fields["events"] = serializers.SlugRelatedField( + queryset=ex.events, + required=False, + allow_empty=True, slug_field='slug', many=True ) for k, v in ex.export_form_fields.items(): self.fields[k] = form_field_to_serializer_field(v) + def to_representation(self, instance): + # Translate between events as a list of slugs (API) and list of ints (database) + if self.ex.is_multievent and not isinstance(self.ex, OrganizerLevelExportMixin) and "events" in instance and isinstance(instance["events"], list): + instance["events"] = [e.slug for e in self.ex.events.filter(pk__in=instance["events"]).only("slug")] + return instance + def to_internal_value(self, data): if isinstance(data, QueryDict): data = data.copy() @@ -95,6 +105,14 @@ class JobRunSerializer(serializers.Serializer): data[fk] = f'{d_from.isoformat() if d_from else ""}/{d_to.isoformat() if d_to else ""}' data = super().to_internal_value(data) + + # Translate between events as a list of slugs (API) and list of ints (database) + if self.ex.is_multievent and not isinstance(self.ex, OrganizerLevelExportMixin) and "events" in data and isinstance(data["events"], list): + if data["events"] and isinstance(data["events"][0], Event): + data["events"] = [e.pk for e in data["events"]] + elif data["events"] and isinstance(data["events"][0], str): + data["events"] = [e.pk for e in self.ex.events.filter(slug__in=data["events"]).only("pk")] + return data def is_valid(self, raise_exception=False): @@ -131,13 +149,20 @@ class ScheduledExportSerializer(serializers.ModelSerializer): exporter = self.context['exporters'].get(identifier) if exporter: try: - JobRunSerializer(exporter=exporter).to_internal_value(attrs["export_form_data"]) + attrs["export_form_data"] = JobRunSerializer(exporter=exporter).to_internal_value(attrs["export_form_data"]) except ValidationError as e: raise ValidationError({"export_form_data": e.detail}) else: raise ValidationError({"export_identifier": ["Unknown exporter."]}) return attrs + def to_representation(self, instance): + repr = super().to_representation(instance) + exporter = self.context['exporters'].get(instance.export_identifier) + if exporter: + repr["export_form_data"] = JobRunSerializer(exporter=exporter).to_representation(repr["export_form_data"]) + return repr + def validate_mail_additional_recipients(self, value): d = value.replace(' ', '') if len(d.split(',')) > 25: diff --git a/src/pretix/api/serializers/media.py b/src/pretix/api/serializers/media.py index 8285fade48..894c1bcf0b 100644 --- a/src/pretix/api/serializers/media.py +++ b/src/pretix/api/serializers/media.py @@ -24,7 +24,7 @@ from decimal import Decimal from django.utils.translation import gettext_lazy as _ from rest_framework import serializers -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import PermissionDenied, ValidationError from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.order import OrderPositionSerializer @@ -66,6 +66,9 @@ class ReusableMediaSerializer(I18nAwareModelSerializer): super().__init__(*args, **kwargs) if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'): + if not self.context["can_read_giftcards"]: + raise PermissionDenied("No permission to access gift card details.") + self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True, context=self.context) if 'linked_giftcard.owner_ticket' in self.context['request'].query_params.getlist('expand'): self.fields['linked_giftcard'].fields['owner_ticket'] = NestedOrderPositionSerializer(read_only=True, context=self.context) @@ -77,6 +80,8 @@ class ReusableMediaSerializer(I18nAwareModelSerializer): ) if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'): + # No additional permission check performed, documented limitation of the permission system + # Would get to complex/unusable otherwise since the permission depends on the event self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True) else: self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField( @@ -86,6 +91,9 @@ class ReusableMediaSerializer(I18nAwareModelSerializer): ) if 'customer' in self.context['request'].query_params.getlist('expand'): + if not self.context["can_read_customers"]: + raise PermissionDenied("No permission to access customer details.") + self.fields['customer'] = CustomerSerializer(read_only=True) else: self.fields['customer'] = serializers.SlugRelatedField( diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index b88cf2a42a..6b83f832bc 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -615,7 +615,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer): # /events/…/checkinlists/…/positions/ # We're unable to check this on this level if we're on /checkinrpc/, in which case we rely on the view # layer to not set pdf_data=true in the first place. - request and hasattr(request, 'eventpermset') and 'can_view_orders' not in request.eventpermset + request and hasattr(request, 'eventpermset') and 'event.orders:read' not in request.eventpermset ) if ('pdf_data' in self.context and not self.context['pdf_data']) or pdf_data_forbidden: self.fields.pop('pdf_data', None) diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index ab12e7b942..05f25976a8 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -45,12 +45,19 @@ from pretix.base.models import ( SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User, ) from pretix.base.models.seating import SeatingPlanLayoutValidator +from pretix.base.permissions import ( + get_all_event_permission_groups, get_all_organizer_permission_groups, +) from pretix.base.plugins import ( PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID, PLUGIN_LEVEL_ORGANIZER, ) from pretix.base.services.mail import mail from pretix.base.settings import validate_organizer_settings +from pretix.helpers.permission_migration import ( + OLD_TO_NEW_EVENT_COMPAT, OLD_TO_NEW_EVENT_MIGRATION, + OLD_TO_NEW_ORGANIZER_COMPAT, OLD_TO_NEW_ORGANIZER_MIGRATION, +) from pretix.helpers.urls import build_absolute_uri as build_global_uri from pretix.multidomain.urlreverse import build_absolute_uri @@ -306,23 +313,128 @@ class EventSlugField(serializers.SlugRelatedField): return self.context['organizer'].events.all() +class PermissionMultipleChoiceField(serializers.MultipleChoiceField): + def to_internal_value(self, data): + return { + p: True for p in super().to_internal_value(data) + } + + def to_representation(self, value): + return [p for p, v in value.items() if v] + + class TeamSerializer(serializers.ModelSerializer): limit_events = EventSlugField(slug_field='slug', many=True) + limit_event_permissions = PermissionMultipleChoiceField(choices=[], required=False, allow_null=False, allow_empty=True) + limit_organizer_permissions = PermissionMultipleChoiceField(choices=[], required=False, allow_null=False, allow_empty=True) + + # Legacy fields, handled in to_representation and validate + can_change_event_settings = serializers.BooleanField(required=False, write_only=True) + can_change_items = serializers.BooleanField(required=False, write_only=True) + can_view_orders = serializers.BooleanField(required=False, write_only=True) + can_change_orders = serializers.BooleanField(required=False, write_only=True) + can_checkin_orders = serializers.BooleanField(required=False, write_only=True) + can_view_vouchers = serializers.BooleanField(required=False, write_only=True) + can_change_vouchers = serializers.BooleanField(required=False, write_only=True) + can_create_events = serializers.BooleanField(required=False, write_only=True) + can_change_organizer_settings = serializers.BooleanField(required=False, write_only=True) + can_change_teams = serializers.BooleanField(required=False, write_only=True) + can_manage_gift_cards = serializers.BooleanField(required=False, write_only=True) + can_manage_customers = serializers.BooleanField(required=False, write_only=True) + can_manage_reusable_media = serializers.BooleanField(required=False, write_only=True) class Meta: model = Team fields = ( - 'id', 'name', 'require_2fa', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams', - 'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings', - 'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers', - 'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers', 'can_manage_reusable_media' + 'id', 'name', 'require_2fa', 'all_events', 'limit_events', 'all_event_permissions', 'limit_event_permissions', + 'all_organizer_permissions', 'limit_organizer_permissions', 'can_change_event_settings', + 'can_change_items', 'can_view_orders', 'can_change_orders', 'can_checkin_orders', 'can_view_vouchers', + 'can_change_vouchers', 'can_create_events', 'can_change_organizer_settings', 'can_change_teams', + 'can_manage_gift_cards', 'can_manage_customers', 'can_manage_reusable_media' ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + event_perms_flattened = [] + organizer_perms_flattened = [] + for pg in get_all_event_permission_groups().values(): + for action in pg.actions: + event_perms_flattened.append(f"{pg.name}:{action}") + for pg in get_all_organizer_permission_groups().values(): + for action in pg.actions: + organizer_perms_flattened.append(f"{pg.name}:{action}") + + self.fields['limit_event_permissions'].choices = [(p, p) for p in event_perms_flattened] + self.fields['limit_organizer_permissions'].choices = [(p, p) for p in organizer_perms_flattened] + + def to_representation(self, instance): + r = super().to_representation(instance) + for old, new in OLD_TO_NEW_EVENT_COMPAT.items(): + r[old] = instance.all_event_permissions or all(instance.limit_event_permissions.get(n) for n in new) + for old, new in OLD_TO_NEW_ORGANIZER_COMPAT.items(): + r[old] = instance.all_organizer_permissions or all(instance.limit_organizer_permissions.get(n) for n in new) + return r + def validate(self, data): + old_data_set = any(k.startswith("can_") for k in data) + new_data_set = any(k in data for k in [ + "all_event_permissions", "limit_event_permissions", "all_organizer_permissions", "limit_organizer_permissions" + ]) + if old_data_set and new_data_set: + raise ValidationError("You cannot set deprecated and current permission attributes at the same time.") + full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {} full_data.update(data) + + if new_data_set: + if full_data.get('limit_event_permissions') and full_data.get('all_event_permissions'): + raise ValidationError('Do not set both limit_event_permissions and all_event_permissions.') + if full_data.get('limit_organizer_permissions') and full_data.get('all_organizer_permissions'): + raise ValidationError('Do not set both limit_organizer_permissions and all_organizer_permissions.') + + if old_data_set: + # Migrate with same logic as in migration 0297_pluggable_permissions + if all(full_data.get(k) is True for k in OLD_TO_NEW_EVENT_MIGRATION.keys() if k != "can_checkin_orders"): + data["all_event_permissions"] = True + data["limit_event_permissions"] = {} + else: + data["all_event_permissions"] = False + data["limit_event_permissions"] = {} + for k, v in OLD_TO_NEW_EVENT_MIGRATION.items(): + if full_data.get(k) is True: + data["limit_event_permissions"].update({kk: True for kk in v}) + if all(full_data.get(k) is True for k in OLD_TO_NEW_ORGANIZER_MIGRATION.keys() if k != "can_checkin_orders"): + data["all_organizer_permissions"] = True + data["limit_organizer_permissions"] = {} + else: + data["all_organizer_permissions"] = False + data["limit_organizer_permissions"] = {} + for k, v in OLD_TO_NEW_ORGANIZER_MIGRATION.items(): + if full_data.get(k) is True: + data["limit_organizer_permissions"].update({kk: True for kk in v}) + if full_data.get('limit_events') and full_data.get('all_events'): raise ValidationError('Do not set both limit_events and all_events.') + + full_data.update(data) + for pg in get_all_event_permission_groups().values(): + requested = ",".join(sorted( + a for a in pg.actions if self.instance and full_data["limit_event_permissions"].get(f"{pg.name}:{a}") + )) + if requested not in (",".join(sorted(opt.actions)) for opt in pg.options): + possible = '\' or \''.join(','.join(opt.actions) for opt in pg.options) + raise ValidationError(f"For permission group {pg.name}, the valid combinations of actions are " + f"'{possible}' but you tried to set '{requested}'.") + for pg in get_all_organizer_permission_groups().values(): + requested = ",".join(sorted( + a for a in pg.actions if self.instance and full_data["limit_organizer_permissions"].get(f"{pg.name}:{a}") + )) + if requested not in (",".join(sorted(opt.actions)) for opt in pg.options): + possible = '\' or \''.join(','.join(opt.actions) for opt in pg.options) + raise ValidationError(f"For permission group {pg.name}, the valid combinations of actions are " + f"'{possible}' but you tried to set '{requested}'.") + return data @@ -339,7 +451,7 @@ class DeviceSerializer(serializers.ModelSerializer): created = serializers.DateTimeField(read_only=True) revoked = serializers.BooleanField(read_only=True) initialized = serializers.DateTimeField(read_only=True) - initialization_token = serializers.DateTimeField(read_only=True) + initialization_token = serializers.CharField(read_only=True) security_profile = serializers.ChoiceField(choices=[], required=False, default="full") class Meta: @@ -353,6 +465,8 @@ class DeviceSerializer(serializers.ModelSerializer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['security_profile'].choices = [(k, v.verbose_name) for k, v in get_all_security_profiles().items()] + if not self.context['can_see_tokens']: + del self.fields['initialization_token'] class TeamInviteSerializer(serializers.ModelSerializer): @@ -437,7 +551,10 @@ class TeamMemberSerializer(serializers.ModelSerializer): class OrganizerSettingsSerializer(SettingsSerializer): + default_write_permission = 'organizer.settings.general:write' default_fields = [ + # These are readable for all users with access to the events, therefore secrets stored in the settings store + # should not be included! 'customer_accounts', 'customer_accounts_native', 'customer_accounts_link_by_email', diff --git a/src/pretix/api/serializers/settings.py b/src/pretix/api/serializers/settings.py index 435b964db0..ce940a99e0 100644 --- a/src/pretix/api/serializers/settings.py +++ b/src/pretix/api/serializers/settings.py @@ -37,6 +37,8 @@ logger = logging.getLogger(__name__) class SettingsSerializer(serializers.Serializer): default_fields = [] readonly_fields = [] + default_write_permission = 'organizer.settings.general:write' + write_permission_required = {} def __init__(self, *args, **kwargs): self.changed_data = [] @@ -58,9 +60,17 @@ class SettingsSerializer(serializers.Serializer): f._label = str(form_kwargs.get('label', fname)) f._help_text = str(form_kwargs.get('help_text')) f.parent = self + + self.write_permission_required[fname] = DEFAULTS[fname].get('write_permission', self.default_write_permission) + self.fields[fname] = f def validate(self, attrs): + for k in attrs.keys(): + p = self.write_permission_required.get(k, self.default_write_permission) + if p not in self.context["permissions"]: + raise ValidationError({k: f"Setting this field requires permission {p}"}) + return {k: v for k, v in attrs.items() if k not in self.readonly_fields} def update(self, instance: HierarkeyProxy, validated_data): diff --git a/src/pretix/api/views/cart.py b/src/pretix/api/views/cart.py index 9a02a86db4..3f858affc2 100644 --- a/src/pretix/api/views/cart.py +++ b/src/pretix/api/views/cart.py @@ -52,8 +52,8 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly ordering = ('datetime',) ordering_fields = ('datetime', 'cart_id') lookup_field = 'id' - permission = 'can_view_orders' - write_permission = 'can_change_orders' + permission = 'event.orders:read' + write_permission = 'event.orders:write' def get_queryset(self): return CartPosition.objects.filter( diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index 7fb054e24d..4130229193 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -67,6 +67,7 @@ from pretix.base.models import ( Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken, ) from pretix.base.models.orders import PrintLog +from pretix.base.permissions import AnyPermissionOf from pretix.base.services.checkin import ( CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin, ) @@ -118,11 +119,11 @@ class CheckinListViewSet(viewsets.ModelViewSet): def _get_permission_name(self, request): if request.path.endswith('/failed_checkins/'): - return 'can_checkin_orders', 'can_change_orders' + return 'event.orders:checkin', 'event.orders:write' elif request.method in SAFE_METHODS: - return 'can_view_orders', 'can_checkin_orders', + return 'event.orders:read', 'event.orders:checkin', else: - return 'can_change_event_settings' + return 'event.settings.general:write' def get_queryset(self): qs = self.request.event.checkin_lists.prefetch_related( @@ -474,7 +475,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, 'event': op.order.event, 'pdf_data': pdf_data and ( user if user and user.is_authenticated else auth - ).has_event_permission(request.organizer, event, 'can_view_orders', request), + ).has_event_permission(request.organizer, event, 'event.orders:read', request), } common_checkin_args = dict( @@ -839,8 +840,8 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet): } filterset_class = CheckinOrderPositionFilter - permission = ('can_view_orders', 'can_checkin_orders') - write_permission = ('can_change_orders', 'can_checkin_orders') + permission = AnyPermissionOf('event.orders:read', 'event.orders:checkin') + write_permission = AnyPermissionOf('event.orders:write', 'event.orders:checkin') def get_serializer_context(self): ctx = super().get_serializer_context() @@ -871,7 +872,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet): expand=self.request.query_params.getlist('expand'), ) - if 'pk' not in self.request.resolver_match.kwargs and 'can_view_orders' not in self.request.eventpermset \ + if 'pk' not in self.request.resolver_match.kwargs and 'event.orders:read' not in self.request.eventpermset \ and len(self.request.query_params.get('search', '')) < 3: qs = qs.none() @@ -920,9 +921,9 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet): class CheckinRPCRedeemView(views.APIView): def post(self, request, *args, **kwargs): if isinstance(self.request.auth, (TeamAPIToken, Device)): - events = self.request.auth.get_events_with_permission(('can_change_orders', 'can_checkin_orders')) + events = self.request.auth.get_events_with_permission(('event.orders:write', 'event.orders:checkin')) elif self.request.user.is_authenticated: - events = self.request.user.get_events_with_permission(('can_change_orders', 'can_checkin_orders'), self.request).filter( + events = self.request.user.get_events_with_permission(('event.orders:write', 'event.orders:checkin'), self.request).filter( organizer=self.request.organizer ) else: @@ -990,9 +991,9 @@ class CheckinRPCSearchView(ListAPIView): @cached_property def lists(self): if isinstance(self.request.auth, (TeamAPIToken, Device)): - events = self.request.auth.get_events_with_permission(('can_view_orders', 'can_checkin_orders')) + events = self.request.auth.get_events_with_permission(('event.orders:read', 'event.orders:checkin')) elif self.request.user.is_authenticated: - events = self.request.user.get_events_with_permission(('can_view_orders', 'can_checkin_orders'), self.request).filter( + events = self.request.user.get_events_with_permission(('event.orders:read', 'event.orders:checkin'), self.request).filter( organizer=self.request.organizer ) else: @@ -1009,9 +1010,9 @@ class CheckinRPCSearchView(ListAPIView): @cached_property def has_full_access_permission(self): if isinstance(self.request.auth, (TeamAPIToken, Device)): - events = self.request.auth.get_events_with_permission('can_view_orders') + events = self.request.auth.get_events_with_permission('event.orders:read') elif self.request.user.is_authenticated: - events = self.request.user.get_events_with_permission('can_view_orders', self.request).filter( + events = self.request.user.get_events_with_permission('event.orders:read', self.request).filter( organizer=self.request.organizer ) else: @@ -1038,9 +1039,9 @@ class CheckinRPCSearchView(ListAPIView): class CheckinRPCAnnulView(views.APIView): def post(self, request, *args, **kwargs): if isinstance(self.request.auth, (TeamAPIToken, Device)): - events = self.request.auth.get_events_with_permission(('can_change_orders', 'can_checkin_orders')) + events = self.request.auth.get_events_with_permission(('event.orders:write', 'event.orders:checkin')) elif self.request.user.is_authenticated: - events = self.request.user.get_events_with_permission(('can_change_orders', 'can_checkin_orders'), self.request).filter( + events = self.request.user.get_events_with_permission(('event.orders:write', 'event.orders:checkin'), self.request).filter( organizer=self.request.organizer ) else: @@ -1118,7 +1119,7 @@ class CheckinViewSet(viewsets.ReadOnlyModelViewSet): filterset_class = CheckinFilter ordering = ('created', 'id') ordering_fields = ('created', 'datetime', 'id',) - permission = 'can_view_orders' + permission = 'event.orders:read' def get_queryset(self): qs = Checkin.all.filter().select_related( diff --git a/src/pretix/api/views/discount.py b/src/pretix/api/views/discount.py index 4504d0381e..53386d3d04 100644 --- a/src/pretix/api/views/discount.py +++ b/src/pretix/api/views/discount.py @@ -57,7 +57,7 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet): ordering_fields = ('id', 'position') ordering = ('position', 'id') permission = None - write_permission = 'can_change_items' + write_permission = 'event.items:write' def get_queryset(self): return self.request.event.discounts.prefetch_related( diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index b61771bdb1..604300b300 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -281,6 +281,11 @@ class EventViewSet(viewsets.ModelViewSet): new_event = serializer.save(organizer=self.request.organizer) if copy_from: + perm_holder = (self.request.auth if isinstance(self.request.auth, (Device, TeamAPIToken)) + else self.request.user) + if not copy_from.allow_copy_data(self.request.organizer, perm_holder): + raise PermissionDenied("Not sufficient permission on source event to copy") + new_event.copy_data_from(copy_from, skip_meta_data='meta_data' in serializer.validated_data) if plugins is not None: @@ -341,15 +346,24 @@ class CloneEventViewSet(viewsets.ModelViewSet): lookup_field = 'slug' lookup_url_kwarg = 'event' http_method_names = ['post'] - write_permission = 'can_create_events' + write_permission = 'event.settings.general:write' def get_serializer_context(self): ctx = super().get_serializer_context() - ctx['event'] = self.kwargs['event'] + ctx['event'] = Event.objects.get(slug=self.kwargs['event'], organizer=self.request.organizer) ctx['organizer'] = self.request.organizer return ctx def perform_create(self, serializer): + # Weird edge case: Requires settings permission on the event (to read) but also on the organizer (two write) + perm_holder = (self.request.auth if isinstance(self.request.auth, (Device, TeamAPIToken)) + else self.request.user) + if not perm_holder.has_organizer_permission(self.request.organizer, "organizer.events:create", request=self.request): + raise PermissionDenied("No permission to create events") + + if not serializer.context['event'].allow_copy_data(self.request.organizer, perm_holder): + raise PermissionDenied("Not sufficient permission on source event to copy") + serializer.save(organizer=self.request.organizer) serializer.instance.log_action( @@ -426,7 +440,7 @@ with scopes_disabled(): class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet): serializer_class = SubEventSerializer queryset = SubEvent.objects.none() - write_permission = 'can_change_event_settings' + write_permission = 'event.subevents:write' filter_backends = (DjangoFilterBackend, TotalOrderingFilter) ordering = ('date_from',) ordering_fields = ('id', 'date_from', 'last_modified') @@ -546,7 +560,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet): class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet): serializer_class = TaxRuleSerializer queryset = TaxRule.objects.none() - write_permission = 'can_change_event_settings' + write_permission = 'event.settings.tax:write' def get_queryset(self): return self.request.event.tax_rules.all() @@ -589,7 +603,7 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet): class ItemMetaPropertiesViewSet(viewsets.ModelViewSet): serializer_class = ItemMetaPropertiesSerializer queryset = ItemMetaProperty.objects.none() - write_permission = 'can_change_event_settings' + write_permission = 'event.settings.general:write' def get_queryset(self): qs = self.request.event.item_meta_properties.all() @@ -636,19 +650,18 @@ class ItemMetaPropertiesViewSet(viewsets.ModelViewSet): class EventSettingsView(views.APIView): permission = None - write_permission = 'can_change_event_settings' + write_permission = 'event.settings.general:write' def get(self, request, *args, **kwargs): if isinstance(request.auth, Device): s = DeviceEventSettingsSerializer(instance=request.event.settings, event=request.event, context={ - 'request': request - }) - elif 'can_change_event_settings' in request.eventpermset: - s = EventSettingsSerializer(instance=request.event.settings, event=request.event, context={ - 'request': request + 'request': request, 'permissions': request.eventpermset }) else: - raise PermissionDenied() + s = EventSettingsSerializer(instance=request.event.settings, event=request.event, context={ + 'request': request, 'permissions': request.eventpermset, + }) + if 'explain' in request.GET: return Response({ fname: { @@ -662,7 +675,7 @@ class EventSettingsView(views.APIView): def patch(self, request, *wargs, **kwargs): s = EventSettingsSerializer(instance=request.event.settings, data=request.data, partial=True, - event=request.event, context={'request': request}) + event=request.event, context={'request': request, 'permissions': request.eventpermset}) s.is_valid(raise_exception=True) with transaction.atomic(): s.save() @@ -674,7 +687,7 @@ class EventSettingsView(views.APIView): ) s = EventSettingsSerializer( instance=request.event.settings, event=request.event, context={ - 'request': request + 'request': request, 'permissions': request.eventpermset }) return Response(s.data) @@ -701,7 +714,7 @@ class SeatFilter(FilterSet): class SeatViewSet(ConditionalListView, viewsets.ModelViewSet): serializer_class = SeatSerializer queryset = Seat.objects.none() - write_permission = 'can_change_event_settings' + write_permission = 'event.settings.general:write' filter_backends = (DjangoFilterBackend, ) filterset_class = SeatFilter diff --git a/src/pretix/api/views/exporters.py b/src/pretix/api/views/exporters.py index 9cbe4c59f7..66b5f10108 100644 --- a/src/pretix/api/views/exporters.py +++ b/src/pretix/api/views/exporters.py @@ -40,12 +40,12 @@ from pretix.api.serializers.exporters import ( ) from pretix.base.exporter import OrganizerLevelExportMixin from pretix.base.models import ( - CachedFile, Device, Event, ScheduledEventExport, ScheduledOrganizerExport, + CachedFile, Device, ScheduledEventExport, ScheduledOrganizerExport, TeamAPIToken, ) -from pretix.base.services.export import export, multiexport -from pretix.base.signals import ( - register_data_exporters, register_multievent_data_exporters, +from pretix.base.models.organizer import TeamQuerySet +from pretix.base.services.export import ( + export, init_event_exporters, init_organizer_exporters, multiexport, ) from pretix.helpers.http import ChunkBasedFileResponse @@ -111,7 +111,7 @@ class ExportersMixin: @action(detail=True, methods=['POST']) def run(self, *args, **kwargs): instance = self.get_object() - serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs()) + serializer = JobRunSerializer(exporter=instance, data=self.request.data) serializer.is_valid(raise_exception=True) cf = CachedFile(web_download=True) @@ -136,27 +136,34 @@ class ExportersMixin: class EventExportersViewSet(ExportersMixin, viewsets.ViewSet): - permission = 'can_view_orders' - - def get_serializer_kwargs(self): - return {} + permission = None @cached_property def exporters(self): + raw_exporters = list(init_event_exporters( + event=self.request.event, + user=self.request.user if self.request.user and self.request.user.is_authenticated else None, + token=self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None, + device=self.request.auth if isinstance(self.request.auth, Device) else None, + request=self.request, + )) exporters = [] - responses = register_data_exporters.send(self.request.event) - raw_exporters = [response(self.request.event, self.request.organizer) for r, response in responses if response] - raw_exporters = [ - ex for ex in raw_exporters - if ex.available_for_user(self.request.user if self.request.user and self.request.user.is_authenticated else None) - ] for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)): ex._serializer = JobRunSerializer(exporter=ex) exporters.append(ex) return exporters def do_export(self, cf, instance, data): - return export.apply_async(args=(self.request.event.id, str(cf.id), instance.identifier, data)) + return export.apply_async(args=( + self.request.event.id, + ), kwargs={ + 'user': self.request.user.pk if self.request.user and self.request.user.is_authenticated else None, + 'token': self.request.auth.pk if isinstance(self.request.auth, TeamAPIToken) else None, + 'device': self.request.auth.pk if isinstance(self.request.auth, Device) else None, + 'fileid': str(cf.id), + 'provider': instance.identifier, + 'form_data': data, + }) class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet): @@ -164,47 +171,23 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet): @cached_property def exporters(self): + raw_exporters = list(init_organizer_exporters( + organizer=self.request.organizer, + user=self.request.user if self.request.user and self.request.user.is_authenticated else None, + token=self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None, + device=self.request.auth if isinstance(self.request.auth, Device) else None, + request=self.request, + )) exporters = [] - if isinstance(self.request.auth, (Device, TeamAPIToken)): - perm_holder = self.request.auth - else: - perm_holder = self.request.user - events = perm_holder.get_events_with_permission('can_view_orders', request=self.request).filter( - organizer=self.request.organizer - ) - responses = register_multievent_data_exporters.send(self.request.organizer) - raw_exporters = [ - response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else events, self.request.organizer) - for r, response in responses - if response - ] - raw_exporters = [ - ex for ex in raw_exporters - if ( - not isinstance(ex, OrganizerLevelExportMixin) or - perm_holder.has_organizer_permission(self.request.organizer, ex.organizer_required_permission, self.request) - ) and ex.available_for_user(self.request.user if self.request.user and self.request.user.is_authenticated else None) - ] for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)): - ex._serializer = JobRunSerializer(exporter=ex, events=events) + ex._serializer = JobRunSerializer(exporter=ex) exporters.append(ex) return exporters - def get_serializer_kwargs(self): - if isinstance(self.request.auth, (Device, TeamAPIToken)): - perm_holder = self.request.auth - else: - perm_holder = self.request.user - return { - 'events': perm_holder.get_events_with_permission('can_view_orders', request=self.request).filter( - organizer=self.request.organizer - ) - } - def do_export(self, cf, instance, data): return multiexport.apply_async(kwargs={ 'organizer': self.request.organizer.id, - 'user': self.request.user.id if self.request.user.is_authenticated else None, + 'user': self.request.user.id if self.request.user and self.request.user.is_authenticated else None, 'token': self.request.auth.pk if isinstance(self.request.auth, TeamAPIToken) else None, 'device': self.request.auth.pk if isinstance(self.request.auth, Device) else None, 'fileid': str(cf.id), @@ -222,11 +205,11 @@ class ScheduledExportersViewSet(viewsets.ModelViewSet): class ScheduledEventExportViewSet(ScheduledExportersViewSet): serializer_class = ScheduledEventExportSerializer queryset = ScheduledEventExport.objects.none() - permission = 'can_view_orders' + permission = None def get_queryset(self): perm_holder = self.request.auth if isinstance(self.request.auth, (TeamAPIToken, Device)) else self.request.user - if not perm_holder.has_event_permission(self.request.organizer, self.request.event, 'can_change_event_settings', + if not perm_holder.has_event_permission(self.request.organizer, self.request.event, 'event.settings.general:write', request=self.request): if self.request.user.is_authenticated: qs = self.request.event.scheduled_exports.filter(owner=self.request.user) @@ -258,11 +241,28 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet): @cached_property def exporters(self): - responses = register_data_exporters.send(self.request.event) - exporters = [response(self.request.event, self.request.organizer) for r, response in responses if response] + exporters = list(init_event_exporters( + event=self.request.event, + user=self.request.user if self.request.user and self.request.user.is_authenticated else None, + token=self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None, + device=self.request.auth if isinstance(self.request.auth, Device) else None, + request=self.request, + )) return {e.identifier: e for e in exporters} def perform_update(self, serializer): + if not self.request.user.is_authenticated or self.request.user != serializer.instance.owner: + # This is to prevent a possible privilege escalation where user A creates a scheduled export and + # user B has settings permission (= they can see the export configuration), but not enough permission + # to run the export themselves. Without this check, user B could modify the export and add themselves + # as a recipient. Thereby, user B would gain access to data they can't have. + exporter = self.exporters.get(serializer.instance.export_identifier) + if not exporter: + raise PermissionDenied("No access to exporter.") + perm_holder = self.request.auth if isinstance(self.request.auth, (TeamAPIToken, Device)) else self.request.user + if not perm_holder.has_event_permission(self.request.organizer, self.request.event, exporter.get_required_event_permission()): + raise PermissionDenied("No permission to edit exports you could not run.") + serializer.save(event=self.request.event) serializer.instance.compute_next_run() serializer.instance.error_counter = 0 @@ -291,7 +291,7 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet): def get_queryset(self): perm_holder = self.request.auth if isinstance(self.request.auth, (TeamAPIToken, Device)) else self.request.user - if not perm_holder.has_organizer_permission(self.request.organizer, 'can_change_organizer_settings', + if not perm_holder.has_organizer_permission(self.request.organizer, 'organizer.settings.general:write', request=self.request): if self.request.user.is_authenticated: qs = self.request.organizer.scheduled_exports.filter(owner=self.request.user) @@ -321,26 +321,55 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet): ctx['exporters'] = self.exporters return ctx - @cached_property - def events(self): - if isinstance(self.request.auth, (TeamAPIToken, Device)): - return self.request.auth.get_events_with_permission('can_view_orders') - elif self.request.user.is_authenticated: - return self.request.user.get_events_with_permission('can_view_orders', self.request).filter( - organizer=self.request.organizer - ) - @cached_property def exporters(self): - responses = register_multievent_data_exporters.send(self.request.organizer) - exporters = [ - response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else self.events, - self.request.organizer) - for r, response in responses if response - ] + exporters = list(init_organizer_exporters( + organizer=self.request.organizer, + user=self.request.user if self.request.user and self.request.user.is_authenticated else None, + token=self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None, + device=self.request.auth if isinstance(self.request.auth, Device) else None, + request=self.request, + )) return {e.identifier: e for e in exporters} def perform_update(self, serializer): + if not self.request.user.is_authenticated or self.request.user != serializer.instance.owner: + # This is to prevent a possible privilege escalation where user A creates a scheduled export and + # user B has settings permission (= they can see the export configuration), but not enough permission + # to run the export themselves. Without this check, user B could modify the export and add themselves + # as a recipient. Thereby, user B would gain access to data they can't have. + exporter = self.exporters.get(serializer.instance.export_identifier) + if not exporter: + raise PermissionDenied("No access to exporter.") + perm_holder = (self.request.auth if isinstance(self.request.auth, (Device, TeamAPIToken)) + else self.request.user) + if isinstance(exporter, OrganizerLevelExportMixin): + if not perm_holder.has_organizer_permission( + self.request.organizer, exporter.get_required_organizer_permission(), request=self.request, + ): + raise PermissionDenied("No permission to edit exports you could not run.") + else: + if serializer.instance.export_form_data.get("all_events", False): + if isinstance(self.request.auth, Device): + if not self.request.auth.all_events: + raise PermissionDenied("No permission to edit exports you could not run.") + elif isinstance(self.request.auth, TeamAPIToken): + if not self.request.auth.team.all_events: + raise PermissionDenied("No permission to edit exports you could not run.") + elif self.request.user.is_authenticated: + if not self.request.user.teams.filter( + TeamQuerySet.event_permission_q(exporter.get_required_event_permission()), + all_events=True, + ).exists(): + raise PermissionDenied("No permission to edit exports you could not run.") + else: + events_selected = serializer.instance.export_form_data.get("events", []) + events_permission = set(perm_holder.get_events_with_permission( + exporter.get_required_event_permission(), request=self.request + ).values_list("pk", flat=True)) + if not all(e in events_permission for e in events_selected): + raise PermissionDenied("No permission to edit exports you could not run.") + serializer.save(organizer=self.request.organizer) serializer.instance.compute_next_run() serializer.instance.error_counter = 0 diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py index be66bc482d..f2f0949392 100644 --- a/src/pretix/api/views/item.py +++ b/src/pretix/api/views/item.py @@ -99,7 +99,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet): ordering = ('position', 'id') filterset_class = ItemFilter permission = None - write_permission = 'can_change_items' + write_permission = 'event.items:write' def get_queryset(self): return self.request.event.items.select_related('tax_rule').prefetch_related( @@ -163,7 +163,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet): ordering_fields = ('id', 'position') ordering = ('id',) permission = None - write_permission = 'can_change_items' + write_permission = 'event.items:write' @cached_property def item(self): @@ -234,7 +234,7 @@ class ItemBundleViewSet(viewsets.ModelViewSet): ordering_fields = ('id',) ordering = ('id',) permission = None - write_permission = 'can_change_items' + write_permission = 'event.items:write' @cached_property def item(self): @@ -286,7 +286,7 @@ class ItemProgramTimeViewSet(viewsets.ModelViewSet): ordering_fields = ('id',) ordering = ('id',) permission = None - write_permission = 'can_change_items' + write_permission = 'event.items:write' @cached_property def item(self): @@ -339,7 +339,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet): ordering_fields = ('id', 'position') ordering = ('id',) permission = None - write_permission = 'can_change_items' + write_permission = 'event.items:write' @cached_property def item(self): @@ -398,7 +398,7 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet): ordering_fields = ('id', 'position') ordering = ('position', 'id') permission = None - write_permission = 'can_change_items' + write_permission = 'event.items:write' def get_queryset(self): return self.request.event.categories.all() @@ -453,7 +453,7 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet): ordering_fields = ('id', 'position') ordering = ('position', 'id') permission = None - write_permission = 'can_change_items' + write_permission = 'event.items:write' def get_queryset(self): return self.request.event.questions.prefetch_related('options').all() @@ -497,7 +497,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet): ordering_fields = ('id', 'position') ordering = ('position',) permission = None - write_permission = 'can_change_items' + write_permission = 'event.items:write' def get_queryset(self): q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event) @@ -564,7 +564,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet): ordering_fields = ('id', 'size') ordering = ('id',) permission = None - write_permission = 'can_change_items' + write_permission = 'event.items:write' def get_queryset(self): return self.request.event.quotas.select_related('subevent').prefetch_related('items', 'variations').all() diff --git a/src/pretix/api/views/media.py b/src/pretix/api/views/media.py index 1569ee2895..7b3be781f6 100644 --- a/src/pretix/api/views/media.py +++ b/src/pretix/api/views/media.py @@ -62,8 +62,8 @@ with scopes_disabled(): class ReusableMediaViewSet(viewsets.ModelViewSet): serializer_class = ReusableMediaSerializer queryset = ReusableMedium.objects.none() - permission = 'can_manage_reusable_media' - write_permission = 'can_manage_reusable_media' + permission = 'organizer.reusablemedia:read' + write_permission = 'organizer.reusablemedia:write' filter_backends = (DjangoFilterBackend, OrderingFilter) ordering = ('-updated', '-id') ordering_fields = ('created', 'updated', 'identifier', 'type', 'id') @@ -95,6 +95,8 @@ class ReusableMediaViewSet(viewsets.ModelViewSet): def get_serializer_context(self): ctx = super().get_serializer_context() ctx['organizer'] = self.request.organizer + ctx['can_read_giftcards'] = 'organizer.giftcards:read' in self.request.orgapermset + ctx['can_read_customers'] = 'organizer.customers:read' in self.request.orgapermset return ctx @transaction.atomic() diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 10c9119946..6d5184db81 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -317,7 +317,7 @@ class OrderViewSetMixin: class OrganizerOrderViewSet(OrderViewSetMixin, viewsets.ReadOnlyModelViewSet): def get_base_queryset(self): - perm = "can_view_orders" if self.request.method in SAFE_METHODS else "can_change_orders" + perm = "event.orders:read" if self.request.method in SAFE_METHODS else "event.orders:write" if isinstance(self.request.auth, (TeamAPIToken, Device)): return Order.objects.filter( event__organizer=self.request.organizer, @@ -338,8 +338,8 @@ class OrganizerOrderViewSet(OrderViewSetMixin, viewsets.ReadOnlyModelViewSet): class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet): - permission = 'can_view_orders' - write_permission = 'can_change_orders' + permission = 'event.orders:read' + write_permission = 'event.orders:write' def get_serializer_context(self): ctx = super().get_serializer_context() @@ -1072,8 +1072,6 @@ class OrderPositionViewSetMixin: ordering = ('order__datetime', 'positionid') ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',) filterset_class = OrderPositionFilter - permission = 'can_view_orders' - write_permission = 'can_change_orders' ordering_custom = { 'attendee_name': { '_order': F('display_name').asc(nulls_first=True), @@ -1169,11 +1167,13 @@ class OrderPositionViewSetMixin: class OrganizerOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ReadOnlyModelViewSet): serializer_class = OrganizerOrderPositionSerializer + permission = None + write_permission = None def get_queryset(self): qs = super().get_queryset() - perm = self.permission if self.request.method in SAFE_METHODS else self.write_permission + perm = "event.orders:read" if self.request.method in SAFE_METHODS else "event.orders:write" if isinstance(self.request.auth, (TeamAPIToken, Device)): auth_obj = self.request.auth @@ -1193,6 +1193,8 @@ class OrganizerOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ReadOnly class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet): serializer_class = OrderPositionSerializer + permission = 'event.orders:read' + write_permission = 'event.orders:write' def get_serializer_context(self): ctx = super().get_serializer_context() @@ -1611,8 +1613,8 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): serializer_class = OrderPaymentSerializer queryset = OrderPayment.objects.none() - permission = 'can_view_orders' - write_permission = 'can_change_orders' + permission = 'event.orders:read' + write_permission = 'event.orders:write' lookup_field = 'local_id' def get_serializer_context(self): @@ -1784,8 +1786,8 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): serializer_class = OrderRefundSerializer queryset = OrderRefund.objects.none() - permission = 'can_view_orders' - write_permission = 'can_change_orders' + permission = 'event.orders:read' + write_permission = 'event.orders:write' lookup_field = 'local_id' def get_queryset(self): @@ -1942,13 +1944,18 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet): ordering = ('nr',) ordering_fields = ('nr', 'date') filterset_class = InvoiceFilter - permission = 'can_view_orders' lookup_url_kwarg = 'number' lookup_field = 'nr' - write_permission = 'can_change_orders' + + def _get_permission_name(self, request): + if 'event' in request.resolver_match.kwargs: + if request.method not in SAFE_METHODS: + return "event.orders:write" + return "event.orders:read" + return None # org-level is handled by event__in check def get_queryset(self): - perm = "can_view_orders" if self.request.method in SAFE_METHODS else "can_change_orders" + perm = "event.orders:read" if self.request.method in SAFE_METHODS else "event.orders:write" if getattr(self.request, 'event', None): qs = self.request.event.invoices elif isinstance(self.request.auth, (TeamAPIToken, Device)): @@ -2089,8 +2096,8 @@ class RevokedSecretViewSet(viewsets.ReadOnlyModelViewSet): ordering = ('-created',) ordering_fields = ('created', 'secret') filterset_class = RevokedSecretFilter - permission = 'can_view_orders' - write_permission = 'can_change_orders' + permission = 'event.orders:read' + write_permission = 'event.orders:write' def get_queryset(self): return RevokedTicketSecret.objects.filter(event=self.request.event) @@ -2111,8 +2118,8 @@ class BlockedSecretViewSet(viewsets.ReadOnlyModelViewSet): filter_backends = (DjangoFilterBackend, TotalOrderingFilter) ordering = ('-updated', '-pk') filterset_class = BlockedSecretFilter - permission = 'can_view_orders' - write_permission = 'can_change_orders' + permission = 'event.orders:read' + write_permission = 'event.orders:write' def get_queryset(self): return BlockedTicketSecret.objects.filter(event=self.request.event) @@ -2147,7 +2154,7 @@ class TransactionViewSet(viewsets.ReadOnlyModelViewSet): ordering = ('datetime', 'pk') ordering_fields = ('datetime', 'created', 'id',) filterset_class = TransactionFilter - permission = 'can_view_orders' + permission = 'event.orders:read' def get_queryset(self): return Transaction.objects.filter(order__event=self.request.event).select_related("order") @@ -2164,11 +2171,11 @@ class OrganizerTransactionViewSet(TransactionViewSet): if isinstance(self.request.auth, (TeamAPIToken, Device)): qs = qs.filter( - order__event__in=self.request.auth.get_events_with_permission("can_view_orders"), + order__event__in=self.request.auth.get_events_with_permission("event.orders:read"), ) elif self.request.user.is_authenticated: qs = qs.filter( - order__event__in=self.request.user.get_events_with_permission("can_view_orders", request=self.request) + order__event__in=self.request.user.get_events_with_permission("event.orders:read", request=self.request) ) else: raise PermissionDenied("Unknown authentication scheme") diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index 6ffb894caf..3551437b6f 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -70,7 +70,7 @@ class OrganizerViewSet(mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet): filter_backends = (TotalOrderingFilter,) ordering = ('slug',) ordering_fields = ('name', 'slug') - write_permission = "can_change_organizer_settings" + write_permission = "organizer.settings.general:write" def get_queryset(self): if self.request.user.is_authenticated: @@ -154,8 +154,8 @@ class OrganizerViewSet(mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet): class SeatingPlanViewSet(viewsets.ModelViewSet): serializer_class = SeatingPlanSerializer queryset = SeatingPlan.objects.none() - permission = 'can_change_organizer_settings' - write_permission = 'can_change_organizer_settings' + permission = None + write_permission = 'organizer.seatingplans:write' def get_queryset(self): return self.request.organizer.seating_plans.order_by('name') @@ -221,8 +221,8 @@ with scopes_disabled(): class GiftCardViewSet(viewsets.ModelViewSet): serializer_class = GiftCardSerializer queryset = GiftCard.objects.none() - permission = 'can_manage_gift_cards' - write_permission = 'can_manage_gift_cards' + permission = 'organizer.giftcards:read' + write_permission = 'organizer.giftcards:write' filter_backends = (DjangoFilterBackend,) filterset_class = GiftCardFilter @@ -344,8 +344,8 @@ class GiftCardViewSet(viewsets.ModelViewSet): class GiftCardTransactionViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = GiftCardTransactionSerializer queryset = GiftCardTransaction.objects.none() - permission = 'can_manage_gift_cards' - write_permission = 'can_manage_gift_cards' + permission = 'organizer.giftcards:read' + write_permission = 'organizer.giftcards:write' @cached_property def giftcard(self): @@ -362,8 +362,8 @@ class GiftCardTransactionViewSet(viewsets.ReadOnlyModelViewSet): class TeamViewSet(viewsets.ModelViewSet): serializer_class = TeamSerializer queryset = Team.objects.none() - permission = 'can_change_teams' - write_permission = 'can_change_teams' + permission = 'organizer.teams:write' + write_permission = 'organizer.teams:write' def get_queryset(self): return self.request.organizer.teams.order_by('pk') @@ -402,8 +402,8 @@ class TeamViewSet(viewsets.ModelViewSet): class TeamMemberViewSet(DestroyModelMixin, viewsets.ReadOnlyModelViewSet): serializer_class = TeamMemberSerializer queryset = User.objects.none() - permission = 'can_change_teams' - write_permission = 'can_change_teams' + permission = 'organizer.teams:write' + write_permission = 'organizer.teams:write' @cached_property def team(self): @@ -431,8 +431,8 @@ class TeamMemberViewSet(DestroyModelMixin, viewsets.ReadOnlyModelViewSet): class TeamInviteViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet): serializer_class = TeamInviteSerializer queryset = TeamInvite.objects.none() - permission = 'can_change_teams' - write_permission = 'can_change_teams' + permission = 'organizer.teams:write' + write_permission = 'organizer.teams:write' @cached_property def team(self): @@ -468,8 +468,8 @@ class TeamInviteViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyMo class TeamAPITokenViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet): serializer_class = TeamAPITokenSerializer queryset = TeamAPIToken.objects.none() - permission = 'can_change_teams' - write_permission = 'can_change_teams' + permission = 'organizer.teams:write' + write_permission = 'organizer.teams:write' @cached_property def team(self): @@ -532,8 +532,8 @@ class DeviceViewSet(mixins.CreateModelMixin, GenericViewSet): serializer_class = DeviceSerializer queryset = Device.objects.none() - permission = 'can_change_organizer_settings' - write_permission = 'can_change_organizer_settings' + permission = 'organizer.devices:read' + write_permission = 'organizer.devices:write' lookup_field = 'device_id' def get_queryset(self): @@ -542,6 +542,9 @@ class DeviceViewSet(mixins.CreateModelMixin, def get_serializer_context(self): ctx = super().get_serializer_context() ctx['organizer'] = self.request.organizer + ctx['can_see_tokens'] = ( + self.request.user if self.request.user and self.request.user.is_authenticated else self.request.auth + ).has_organizer_permission(self.request.organizer, 'organizer.devices:write', request=self.request) return ctx @transaction.atomic() @@ -568,11 +571,11 @@ class DeviceViewSet(mixins.CreateModelMixin, class OrganizerSettingsView(views.APIView): permission = None - write_permission = 'can_change_organizer_settings' + write_permission = 'organizer.settings.general:write' def get(self, request, *args, **kwargs): s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={ - 'request': request + 'request': request, 'permissions': request.orgapermset }) if 'explain' in request.GET: return Response({ @@ -589,7 +592,7 @@ class OrganizerSettingsView(views.APIView): s = OrganizerSettingsSerializer( instance=request.organizer.settings, data=request.data, partial=True, organizer=request.organizer, context={ - 'request': request + 'request': request, 'permissions': request.orgapermset } ) s.is_valid(raise_exception=True) @@ -601,7 +604,7 @@ class OrganizerSettingsView(views.APIView): } ) s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={ - 'request': request + 'request': request, 'permissions': request.orgapermset }) return Response(s.data) @@ -618,7 +621,8 @@ with scopes_disabled(): class CustomerViewSet(viewsets.ModelViewSet): serializer_class = CustomerSerializer queryset = Customer.objects.none() - permission = 'can_manage_customers' + permission = 'organizer.customers:read' + write_permission = 'organizer.customers:write' lookup_field = 'identifier' filter_backends = (DjangoFilterBackend,) filterset_class = CustomerFilter @@ -678,7 +682,7 @@ class CustomerViewSet(viewsets.ModelViewSet): class MembershipTypeViewSet(viewsets.ModelViewSet): serializer_class = MembershipTypeSerializer queryset = MembershipType.objects.none() - permission = 'can_change_organizer_settings' + permission = 'organizer.settings.general:write' def get_queryset(self): qs = self.request.organizer.membership_types.all() @@ -735,7 +739,8 @@ with scopes_disabled(): class MembershipViewSet(viewsets.ModelViewSet): serializer_class = MembershipSerializer queryset = Membership.objects.none() - permission = 'can_manage_customers' + permission = 'organizer.customers:read' + write_permission = 'organizer.customers:write' filter_backends = (DjangoFilterBackend,) filterset_class = MembershipFilter @@ -785,8 +790,8 @@ with scopes_disabled(): class SalesChannelViewSet(viewsets.ModelViewSet): serializer_class = SalesChannelSerializer queryset = SalesChannel.objects.none() - permission = 'can_change_organizer_settings' - write_permission = 'can_change_organizer_settings' + permission = 'organizer.settings.general:write' + write_permission = 'organizer.settings.general:write' filter_backends = (DjangoFilterBackend,) filterset_class = SalesChannelFilter lookup_field = 'identifier' diff --git a/src/pretix/api/views/shredders.py b/src/pretix/api/views/shredders.py index acb34fa83a..26925d9a92 100644 --- a/src/pretix/api/views/shredders.py +++ b/src/pretix/api/views/shredders.py @@ -204,7 +204,7 @@ class ShreddersMixin: class EventShreddersViewSet(ShreddersMixin, viewsets.ViewSet): - permission = 'can_change_orders' + permission = 'event.orders:write' def get_serializer_kwargs(self): return {} diff --git a/src/pretix/api/views/voucher.py b/src/pretix/api/views/voucher.py index 2d6243b248..5527a11e5e 100644 --- a/src/pretix/api/views/voucher.py +++ b/src/pretix/api/views/voucher.py @@ -62,8 +62,8 @@ class VoucherViewSet(viewsets.ModelViewSet): ordering = ('id',) ordering_fields = ('id', 'code', 'max_usages', 'valid_until', 'value') filterset_class = VoucherFilter - permission = 'can_view_vouchers' - write_permission = 'can_change_vouchers' + permission = 'event.vouchers:read' + write_permission = 'event.vouchers:write' @scopes_disabled() # we have an event check here, and we can save some performance on subqueries def get_queryset(self): diff --git a/src/pretix/api/views/waitinglist.py b/src/pretix/api/views/waitinglist.py index f645a5e56a..569c64b569 100644 --- a/src/pretix/api/views/waitinglist.py +++ b/src/pretix/api/views/waitinglist.py @@ -51,8 +51,8 @@ class WaitingListViewSet(viewsets.ModelViewSet): ordering = ('created', 'pk',) ordering_fields = ('id', 'created', 'email', 'item') filterset_class = WaitingListFilter - permission = 'can_view_orders' - write_permission = 'can_change_orders' + permission = 'event.orders:read' + write_permission = 'event.orders:write' def get_queryset(self): return self.request.event.waitinglistentries.all() diff --git a/src/pretix/api/views/webhooks.py b/src/pretix/api/views/webhooks.py index f0c7815231..b7b883906c 100644 --- a/src/pretix/api/views/webhooks.py +++ b/src/pretix/api/views/webhooks.py @@ -35,8 +35,8 @@ class WebhookFilter(FilterSet): class WebHookViewSet(viewsets.ModelViewSet): serializer_class = WebHookSerializer queryset = WebHook.objects.none() - permission = 'can_change_organizer_settings' - write_permission = 'can_change_organizer_settings' + permission = 'organizer.settings.general:write' + write_permission = 'organizer.settings.general:write' filter_backends = (DjangoFilterBackend,) filterset_class = WebhookFilter diff --git a/src/pretix/base/auth.py b/src/pretix/base/auth.py index f504729ee4..dd7e8201de 100644 --- a/src/pretix/base/auth.py +++ b/src/pretix/base/auth.py @@ -224,7 +224,7 @@ class HistoryPasswordValidator: ).delete() -def has_event_access_permission(request, permission='can_change_event_settings'): +def has_event_access_permission(request, permission='event.settings.general:write'): return ( request.user.is_authenticated and request.user.has_event_permission(request.organizer, request.event, permission, request=request) diff --git a/src/pretix/base/exporter.py b/src/pretix/base/exporter.py index 2a40dabc97..5182627a60 100644 --- a/src/pretix/base/exporter.py +++ b/src/pretix/base/exporter.py @@ -73,6 +73,9 @@ class BaseExporter: self.events = Event.objects.filter(pk=event.pk) self.timezone = event.timezone + if hasattr(self, 'organizer_required_permission'): + raise TypeError("Deprecated attribute organizer_required_permission no longer supported.") + def __str__(self): return self.identifier @@ -176,15 +179,30 @@ class BaseExporter: """ return True + @classmethod + def get_required_event_permission(cls) -> str: + """ + The permission level required to use this exporter for events. For multi-event-exports, this will be used + to limit the selection of events. Will be ignored if the ``OrganizerLevelExportMixin`` mixin is used. + The default implementation returns ``"event.orders:read"``. + """ + return 'event.orders:read' + class OrganizerLevelExportMixin: - @property - def organizer_required_permission(self) -> str: + @classmethod + def get_required_event_permission(cls): + raise TypeError("required_event_permission may not be called on OrganizerLevelExportMixin") + + @classmethod + def get_required_organizer_permission(cls) -> str: """ - The permission level required to use this exporter. Only useful for organizer-level exports, - not for event-level exports. + The permission level required to use this exporter. Must be set for organizer-level exports. Set to `None` to + allow everyone with any access to the organizer. + + ``get_required_event_permission`` will be ignored on this class. """ - return 'can_view_orders' + raise NotImplementedError() class ListExporter(BaseExporter): diff --git a/src/pretix/base/exporters/customers.py b/src/pretix/base/exporters/customers.py index 7aa748093b..b56f27cc1e 100644 --- a/src/pretix/base/exporters/customers.py +++ b/src/pretix/base/exporters/customers.py @@ -47,10 +47,13 @@ from ..signals import register_multievent_data_exporters class CustomerListExporter(OrganizerLevelExportMixin, ListExporter): identifier = 'customerlist' verbose_name = gettext_lazy('Customer accounts') - organizer_required_permission = 'can_manage_customers' category = pgettext_lazy('export_category', 'Customer accounts') description = gettext_lazy('Download a spreadsheet of all currently registered customer accounts.') + @classmethod + def get_required_organizer_permission(cls) -> str: + return 'organizer.customers:write' + @property def additional_form_fields(self): return OrderedDict( diff --git a/src/pretix/base/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py index b3471eac22..e05935898f 100644 --- a/src/pretix/base/exporters/orderlist.py +++ b/src/pretix/base/exporters/orderlist.py @@ -271,7 +271,7 @@ class OrderListExporter(MultiSheetListExporter): qs = self._date_filter(qs, form_data, rel='') - if form_data['paid_only']: + if form_data.get('paid_only'): qs = qs.filter(status=Order.STATUS_PAID) return qs @@ -458,7 +458,7 @@ class OrderListExporter(MultiSheetListExporter): ).annotate( payment_providers=Subquery(p_providers, output_field=CharField()), ).select_related('order', 'order__invoice_address', 'order__customer', 'tax_rule') - if form_data['paid_only']: + if form_data.get('paid_only'): qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False) if form_data.get('items'): @@ -562,7 +562,7 @@ class OrderListExporter(MultiSheetListExporter): qs = OrderPosition.all.filter( order__event__in=self.events, ) - if form_data['paid_only']: + if form_data.get('paid_only'): qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False) if form_data.get('items'): @@ -1239,11 +1239,14 @@ class QuotaListExporter(ListExporter): class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter): identifier = 'giftcardtransactionlist' verbose_name = gettext_lazy('Gift card transactions') - organizer_required_permission = 'can_manage_gift_cards' category = pgettext_lazy('export_category', 'Gift cards') description = gettext_lazy('Download a spreadsheet of all gift card transactions.') repeatable_read = False + @classmethod + def get_required_organizer_permission(cls) -> str: + return 'organizer.giftcards:read' + @property def additional_form_fields(self): d = [ @@ -1346,10 +1349,13 @@ class GiftcardRedemptionListExporter(ListExporter): class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter): identifier = 'giftcardlist' verbose_name = gettext_lazy('Gift cards') - organizer_required_permission = 'can_manage_gift_cards' category = pgettext_lazy('export_category', 'Gift cards') description = gettext_lazy('Download a spreadsheet of all gift cards including their current value.') + @classmethod + def get_required_organizer_permission(cls) -> str: + return 'organizer.giftcards:read' + @property def additional_form_fields(self): return OrderedDict( diff --git a/src/pretix/base/exporters/reusablemedia.py b/src/pretix/base/exporters/reusablemedia.py index 83182c2df7..fbc6005908 100644 --- a/src/pretix/base/exporters/reusablemedia.py +++ b/src/pretix/base/exporters/reusablemedia.py @@ -36,6 +36,10 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter): description = _('Download a spread sheet with the data of all reusable medias on your account.') repeatable_read = False + @classmethod + def get_required_organizer_permission(cls) -> str: + return "organizer.reusablemedia:read" + def iterate_list(self, form_data): media = ReusableMedium.objects.filter( organizer=self.organizer, diff --git a/src/pretix/base/migrations/0298_pluggable_permissions.py b/src/pretix/base/migrations/0298_pluggable_permissions.py new file mode 100644 index 0000000000..cb46eee9a7 --- /dev/null +++ b/src/pretix/base/migrations/0298_pluggable_permissions.py @@ -0,0 +1,137 @@ +from django.db import migrations, models + +from pretix.helpers.permission_migration import ( + OLD_TO_NEW_EVENT_MIGRATION, OLD_TO_NEW_ORGANIZER_MIGRATION, +) + + +def migrate_teams_forward(apps, schema_editor): + Team = apps.get_model("pretixbase", "Team") + + for team in Team.objects.iterator(): + if all(getattr(team, k) for k in OLD_TO_NEW_EVENT_MIGRATION.keys() if k != "can_checkin_orders"): + team.all_event_permissions = True + team.limit_event_permissions = {} + else: + team.all_event_permissions = False + for k, v in OLD_TO_NEW_EVENT_MIGRATION.items(): + if getattr(team, k): + team.limit_event_permissions.update({kk: True for kk in v}) + + # Prevent combinations that were possible previously but no longer make sense + if team.limit_event_permissions.get("event.orders:checkin") and team.limit_event_permissions.get("event.orders:write"): + team.limit_event_permissions.pop("event.orders:checkin") + if team.limit_event_permissions.get("event.orders:write") and not team.limit_event_permissions.get("event.orders:read"): + team.limit_event_permissions.pop("event.orders:write") + if team.limit_event_permissions.get("event.vouchers:write") and not team.limit_event_permissions.get("event.vouchers:read"): + team.limit_event_permissions.pop("event.vouchers:write") + + if all(getattr(team, k) for k in OLD_TO_NEW_ORGANIZER_MIGRATION.keys()): + team.all_organizer_permissions = True + team.limit_organizer_permissions = {} + else: + team.all_organizer_permissions = False + for k, v in OLD_TO_NEW_ORGANIZER_MIGRATION.items(): + if getattr(team, k): + team.limit_organizer_permissions.update({kk: True for kk in v}) + + team.save(update_fields=[ + "all_event_permissions", "limit_event_permissions", "all_organizer_permissions", "limit_organizer_permissions" + ]) + + +def migrate_teams_backward(apps, schema_editor): + Team = apps.get_model("pretixbase", "Team") + + for team in Team.objects.iterator(): + for k, v in OLD_TO_NEW_EVENT_MIGRATION.items(): + setattr(team, k, team.all_event_permissions or all(team.limit_event_permissions.get(kk) for kk in v)) + for k, v in OLD_TO_NEW_ORGANIZER_MIGRATION.items(): + setattr(team, k, team.all_organizer_permissions or all(team.limit_organizer_permissions.get(kk) for kk in v)) + team.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0297_outgoingmail"), + ] + + operations = [ + migrations.AddField( + model_name="team", + name="all_event_permissions", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="team", + name="all_organizer_permissions", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="team", + name="limit_event_permissions", + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name="team", + name="limit_organizer_permissions", + field=models.JSONField(default=dict), + ), + migrations.RunPython( + migrate_teams_forward, + migrate_teams_backward, + ), + migrations.RemoveField( + model_name="team", + name="can_change_event_settings", + ), + migrations.RemoveField( + model_name="team", + name="can_change_items", + ), + migrations.RemoveField( + model_name="team", + name="can_change_orders", + ), + migrations.RemoveField( + model_name="team", + name="can_change_organizer_settings", + ), + migrations.RemoveField( + model_name="team", + name="can_change_teams", + ), + migrations.RemoveField( + model_name="team", + name="can_change_vouchers", + ), + migrations.RemoveField( + model_name="team", + name="can_checkin_orders", + ), + migrations.RemoveField( + model_name="team", + name="can_create_events", + ), + migrations.RemoveField( + model_name="team", + name="can_manage_customers", + ), + migrations.RemoveField( + model_name="team", + name="can_manage_gift_cards", + ), + migrations.RemoveField( + model_name="team", + name="can_manage_reusable_media", + ), + migrations.RemoveField( + model_name="team", + name="can_view_orders", + ), + migrations.RemoveField( + model_name="team", + name="can_view_vouchers", + ), + ] diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py index 83d82a2874..a14f2e4b74 100644 --- a/src/pretix/base/models/auth.py +++ b/src/pretix/base/models/auth.py @@ -213,6 +213,28 @@ class SuperuserPermissionSet: return True +class EventPermissionSet(set): + def __contains__(self, item): + from pretix.base.permissions import assert_valid_event_permission + + if super().__contains__(item): + return True + + assert_valid_event_permission(item, allow_tuple=False) + return False + + +class OrganizerPermissionSet(set): + def __contains__(self, item): + from pretix.base.permissions import assert_valid_organizer_permission + + if super().__contains__(item): + return True + + assert_valid_organizer_permission(item, allow_tuple=False) + return False + + class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): """ This is the user model used by pretix for authentication. @@ -473,7 +495,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): :return: set """ teams = self._get_teams_for_event(organizer, event) - sets = [t.permission_set() for t in teams] + sets = [t.event_permission_set() for t in teams] if sets: return set.union(*sets) else: @@ -487,7 +509,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): :return: set """ teams = self._get_teams_for_organizer(organizer) - sets = [t.permission_set() for t in teams] + sets = [t.organizer_permission_set() for t in teams] if sets: return set.union(*sets) else: @@ -502,7 +524,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): :param organizer: The organizer of the event :param event: The event to check - :param perm_name: The permission, e.g. ``can_change_teams`` + :param perm_name: The permission, e.g. ``event.orders:read`` :param request: The current request (optional) :param session_key: The current session key (optional) :return: bool @@ -514,8 +536,8 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): if teams: self._teamcache['e{}'.format(event.pk)] = teams if isinstance(perm_name, (tuple, list)): - return any([any(team.has_permission(p) for team in teams) for p in perm_name]) - if not perm_name or any([team.has_permission(perm_name) for team in teams]): + return any([any(team.has_event_permission(p) for team in teams) for p in perm_name]) + if not perm_name or any([team.has_event_permission(perm_name) for team in teams]): return True return False @@ -525,7 +547,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): to the organizer ``organizer``. :param organizer: The organizer to check - :param perm_name: The permission, e.g. ``can_change_teams`` + :param perm_name: The permission, e.g. ``organizer.events:create`` :param request: The current request (optional). Required to detect staff sessions properly. :return: bool """ @@ -534,8 +556,8 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): teams = self._get_teams_for_organizer(organizer) if teams: if isinstance(perm_name, (tuple, list)): - return any([any(team.has_permission(p) for team in teams) for p in perm_name]) - if not perm_name or any([team.has_permission(perm_name) for team in teams]): + return any([any(team.has_organizer_permission(p) for team in teams) for p in perm_name]) + if not perm_name or any([team.has_organizer_permission(perm_name) for team in teams]): return True return False @@ -566,14 +588,15 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): :return: Iterable of Events """ from .event import Event + from .organizer import TeamQuerySet if request and self.has_active_staff_session(request.session.session_key): return Event.objects.all() if isinstance(permission, (tuple, list)): - q = reduce(operator.or_, [Q(**{p: True}) for p in permission]) + q = reduce(operator.or_, [TeamQuerySet.event_permission_q(p) for p in permission]) else: - q = Q(**{permission: True}) + q = TeamQuerySet.event_permission_q(permission) return Event.objects.filter( Q(organizer_id__in=self.teams.filter(q, all_events=True).values_list('organizer', flat=True)) @@ -606,14 +629,13 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): :return: Iterable of Organizers """ from .event import Organizer + from .organizer import TeamQuerySet if request and self.has_active_staff_session(request.session.session_key): return Organizer.objects.all() - kwargs = {permission: True} - return Organizer.objects.filter( - id__in=self.teams.filter(**kwargs).values_list('organizer', flat=True) + id__in=self.teams.filter(TeamQuerySet.organizer_permission_q(permission)).values_list('organizer', flat=True) ) def has_active_staff_session(self, session_key=None): diff --git a/src/pretix/base/models/devices.py b/src/pretix/base/models/devices.py index c94cf6827d..a19f8e8402 100644 --- a/src/pretix/base/models/devices.py +++ b/src/pretix/base/models/devices.py @@ -29,6 +29,9 @@ from django.utils.translation import gettext_lazy as _ from django_scopes import ScopedManager, scopes_disabled from pretix.base.models import LoggedModel +from pretix.base.permissions import ( + AnyPermissionOf, assert_valid_event_permission, +) @scopes_disabled() @@ -189,13 +192,19 @@ class Device(LoggedModel): kwargs['update_fields'] = {'device_id'}.union(kwargs['update_fields']) super().save(*args, **kwargs) - def permission_set(self) -> set: + def _event_permission_set(self) -> set: return { - 'can_view_orders', - 'can_change_orders', - 'can_view_vouchers', - 'can_manage_gift_cards', - 'can_manage_reusable_media', + 'event.orders:read', + 'event.orders:write', + 'event.vouchers:read', + } + + def _organizer_permission_set(self) -> set: + return { + 'organizer.giftcards:read', + 'organizer.giftcards:write', + 'organizer.reusablemedia:read', + 'organizer.reusablemedia:write', } def get_event_permission_set(self, organizer, event) -> set: @@ -209,7 +218,7 @@ class Device(LoggedModel): has_event_access = (self.all_events and organizer == self.organizer) or ( event in self.limit_events.all() ) - return self.permission_set() if has_event_access else set() + return self._event_permission_set() if has_event_access else set() def get_organizer_permission_set(self, organizer) -> set: """ @@ -218,7 +227,7 @@ class Device(LoggedModel): :param organizer: The organizer of the event :return: set of permissions """ - return self.permission_set() if self.organizer == organizer else set() + return self._organizer_permission_set() if self.organizer == organizer else set() def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool: """ @@ -227,7 +236,7 @@ class Device(LoggedModel): :param organizer: The organizer of the event :param event: The event to check - :param perm_name: The permission, e.g. ``can_change_teams`` + :param perm_name: The permission, e.g. ``event.orders:read`` :param request: This parameter is ignored and only defined for compatibility reasons. :return: bool """ @@ -235,8 +244,8 @@ class Device(LoggedModel): event in self.limit_events.all() ) if isinstance(perm_name, (tuple, list)): - return has_event_access and any(p in self.permission_set() for p in perm_name) - return has_event_access and (not perm_name or perm_name in self.permission_set()) + return has_event_access and any(p in self._event_permission_set() for p in perm_name) + return has_event_access and (not perm_name or perm_name in self._event_permission_set()) def has_organizer_permission(self, organizer, perm_name=None, request=None): """ @@ -244,13 +253,13 @@ class Device(LoggedModel): to the organizer ``organizer``. :param organizer: The organizer to check - :param perm_name: The permission, e.g. ``can_change_teams`` + :param perm_name: The permission, e.g. ``organizer.events:create`` :param request: This parameter is ignored and only defined for compatibility reasons. :return: bool """ if isinstance(perm_name, (tuple, list)): - return organizer == self.organizer and any(p in self.permission_set() for p in perm_name) - return organizer == self.organizer and (not perm_name or perm_name in self.permission_set()) + return organizer == self.organizer and any(p in self._organizer_permission_set() for p in perm_name) + return organizer == self.organizer and (not perm_name or perm_name in self._organizer_permission_set()) def get_events_with_any_permission(self): """ @@ -270,9 +279,10 @@ class Device(LoggedModel): :param request: Ignored, for compatibility with User model :return: Iterable of Events """ + assert_valid_event_permission(permission) if ( - isinstance(permission, (list, tuple)) and any(p in self.permission_set() for p in permission) - ) or (isinstance(permission, str) and permission in self.permission_set()): + isinstance(permission, (AnyPermissionOf, list, tuple)) and any(p in self._event_permission_set() for p in permission) + ) or (isinstance(permission, str) and permission in self._event_permission_set()): return self.get_events_with_any_permission() else: return self.organizer.events.none() diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index dfe1616cb6..aee0df1a7b 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -843,6 +843,33 @@ class Event(EventMixin, LoggedModel): time(hour=23, minute=59, second=59) ), tz) + def allow_copy_data(self, new_organizer, auth) -> bool: + """ + Returns whether it is allowed to copy the event to the target organizer. Auth can be TeamAPIToken or User. + """ + from ..permissions import get_all_event_permissions + from .auth import User + + if self.organizer == new_organizer: + # Copying in the same organizer is always okay with any read access, we just need to ensure it does not + # grant more permissions than I had before, but that is handled by the view logic + return auth.has_event_permission(self.organizer, self, None) + + if isinstance(auth, User): + # Cross-organizer copying requires almost full permission of source to prevent settings extraction + required_permissions = get_all_event_permissions() - { + # We do not require these, as this data is not copied + "event.orders:read", "event.orders:write", "event.vouchers:read", "event.vouchers:write", + "event.subevents:write", + } + given_permission = auth.get_event_permission_set(self.organizer, self) + return all(p in given_permission for p in required_permissions if ":" in p) + + else: + # Tokens or devices can never copy between organizers, as they are organizer-bound. Kept for future + # compatibility and easier calling + return False + def copy_data_from(self, other, skip_meta_data=False): from ..signals import event_copy_data from . import ( @@ -1386,14 +1413,13 @@ class Event(EventMixin, LoggedModel): from .auth import User if permission: - kwargs = {permission: True} + qs = Team.objects.with_event_permission(permission) else: - kwargs = {} + qs = Team.objects.all() - team_with_perm = Team.objects.filter( + team_with_perm = qs.filter( members__pk=OuterRef('pk'), organizer=self.organizer, - **kwargs ).filter( Q(all_events=True) | Q(limit_events__pk=self.pk) ) diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py index 71cc083f45..5a9ef8c51d 100644 --- a/src/pretix/base/models/organizer.py +++ b/src/pretix/base/models/organizer.py @@ -31,9 +31,10 @@ # 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. - +import operator import string from datetime import date, datetime, time +from functools import reduce import pytz_deprecation_shim from django.conf import settings @@ -53,6 +54,10 @@ from i18nfield.strings import LazyI18nString from pretix.base.models.base import LoggedModel from pretix.base.validators import OrganizerSlugBanlistValidator +from ...helpers.permission_migration import ( + OLD_TO_NEW_EVENT_COMPAT, OLD_TO_NEW_ORGANIZER_COMPAT, + LegacyPermissionProperty, +) from ..settings import settings_hierarkey from .auth import User @@ -309,6 +314,38 @@ def generate_api_token(): return get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits) +class TeamQuerySet(models.QuerySet): + @classmethod + def event_permission_q(cls, perm_name): + from ..permissions import assert_valid_event_permission + + if perm_name.startswith('can_') and perm_name in OLD_TO_NEW_EVENT_COMPAT: # legacy + return reduce(operator.and_, [cls.event_permission_q(p) for p in OLD_TO_NEW_EVENT_COMPAT[perm_name]]) + assert_valid_event_permission(perm_name, allow_legacy=False) + return ( + Q(all_event_permissions=True) | + Q(**{f'limit_event_permissions__{perm_name}': True}) + ) + + @classmethod + def organizer_permission_q(cls, perm_name): + from ..permissions import assert_valid_organizer_permission + + if perm_name.startswith('can_') and perm_name in OLD_TO_NEW_ORGANIZER_COMPAT: # legacy + return reduce(operator.and_, [cls.organizer_permission_q(p) for p in OLD_TO_NEW_ORGANIZER_COMPAT[perm_name]]) + assert_valid_organizer_permission(perm_name, allow_legacy=False) + return ( + Q(all_organizer_permissions=True) | + Q(**{f'limit_organizer_permissions__{perm_name}': True}) + ) + + def with_event_permission(self, perm_name): + return self.filter(self.event_permission_q(perm_name)) + + def with_organizer_permission(self, perm_name): + return self.filter(self.organizer_permission_q(perm_name)) + + class Team(LoggedModel): """ A team is a collection of people given certain access rights to one or more events of an organizer. @@ -321,36 +358,10 @@ class Team(LoggedModel): :param all_events: Whether this team has access to all events of this organizer :type all_events: bool :param limit_events: A set of events this team has access to. Irrelevant if ``all_events`` is ``True``. - :param can_create_events: Whether or not the members can create new events with this organizer account. - :type can_create_events: bool - :param can_change_teams: If ``True``, the members can change the teams of this organizer account. - :type can_change_teams: bool - :param can_manage_customers: If ``True``, the members can view and change organizer-level customer accounts. - :type can_manage_customers: bool - :param can_manage_reusable_media: If ``True``, the members can view and change organizer-level reusable media. - :type can_manage_reusable_media: bool - :param can_change_organizer_settings: If ``True``, the members can change the settings of this organizer account. - :type can_change_organizer_settings: bool - :param can_change_event_settings: If ``True``, the members can change the settings of the associated events. - :type can_change_event_settings: bool - :param can_change_items: If ``True``, the members can change and add items and related objects for the associated events. - :type can_change_items: bool - :param can_view_orders: If ``True``, the members can inspect details of all orders of the associated events. - :type can_view_orders: bool - :param can_change_orders: If ``True``, the members can change details of orders of the associated events. - :type can_change_orders: bool - :param can_checkin_orders: If ``True``, the members can perform check-in related actions. - :type can_checkin_orders: bool - :param can_view_vouchers: If ``True``, the members can inspect details of all vouchers of the associated events. - :type can_view_vouchers: bool - :param can_change_vouchers: If ``True``, the members can change and create vouchers for the associated events. - :type can_change_vouchers: bool """ organizer = models.ForeignKey(Organizer, related_name="teams", on_delete=models.CASCADE) name = models.CharField(max_length=190, verbose_name=_("Team name")) members = models.ManyToManyField(User, related_name="teams", verbose_name=_("Team members")) - all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)")) - limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True) require_2fa = models.BooleanField( default=False, verbose_name=_("Require all members of this team to use two-factor authentication"), help_text=_("If you turn this on, all members of the team will be required to either set up two-factor " @@ -358,62 +369,33 @@ class Team(LoggedModel): "all users.") ) - can_create_events = models.BooleanField( - default=False, - verbose_name=_("Can create events"), - ) - can_change_teams = models.BooleanField( - default=False, - verbose_name=_("Can change teams and permissions"), - ) - can_change_organizer_settings = models.BooleanField( - default=False, - verbose_name=_("Can change organizer settings"), - help_text=_('Someone with this setting can get access to most data of all of your events, i.e. via privacy ' - 'reports, so be careful who you add to this team!') - ) - can_manage_customers = models.BooleanField( - default=False, - verbose_name=_("Can manage customer accounts") - ) - can_manage_reusable_media = models.BooleanField( - default=False, - verbose_name=_("Can manage reusable media") - ) - can_manage_gift_cards = models.BooleanField( - default=False, - verbose_name=_("Can manage gift cards") - ) - can_change_event_settings = models.BooleanField( - default=False, - verbose_name=_("Can change event settings") - ) - can_change_items = models.BooleanField( - default=False, - verbose_name=_("Can change product settings") - ) - can_view_orders = models.BooleanField( - default=False, - verbose_name=_("Can view orders") - ) - can_change_orders = models.BooleanField( - default=False, - verbose_name=_("Can change orders") - ) - can_checkin_orders = models.BooleanField( - default=False, - verbose_name=_("Can perform check-ins"), - help_text=_('This includes searching for attendees, which can be used to obtain personal information about ' - 'attendees. Users with "can change orders" can also perform check-ins.') - ) - can_view_vouchers = models.BooleanField( - default=False, - verbose_name=_("Can view vouchers") - ) - can_change_vouchers = models.BooleanField( - default=False, - verbose_name=_("Can change vouchers") - ) + # Scope + all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)")) + limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True) + + # Permissions + # We store them as {key: True} instead of [key] because otherwise not all lookups we need are supported on SQLite + all_event_permissions = models.BooleanField(default=False, verbose_name=_("All event permissions")) + limit_event_permissions = models.JSONField(default=dict, verbose_name=_("Event permissions")) + all_organizer_permissions = models.BooleanField(default=False, verbose_name=_("All organizer permissions")) + limit_organizer_permissions = models.JSONField(default=dict, verbose_name=_("Organizer permissions")) + + # Legacy lookups for plugin compatibility + can_change_event_settings = LegacyPermissionProperty() + can_change_items = LegacyPermissionProperty() + can_view_orders = LegacyPermissionProperty() + can_change_orders = LegacyPermissionProperty() + can_checkin_orders = LegacyPermissionProperty() + can_view_vouchers = LegacyPermissionProperty() + can_change_vouchers = LegacyPermissionProperty() + can_create_events = LegacyPermissionProperty() + can_change_organizer_settings = LegacyPermissionProperty() + can_change_teams = LegacyPermissionProperty() + can_manage_gift_cards = LegacyPermissionProperty() + can_manage_customers = LegacyPermissionProperty() + can_manage_reusable_media = LegacyPermissionProperty() + + objects = TeamQuerySet.as_manager() def __str__(self) -> str: return _("%(name)s on %(object)s") % { @@ -421,21 +403,62 @@ class Team(LoggedModel): 'object': str(self.organizer), } - def permission_set(self) -> set: - attribs = dir(self) - return { - a for a in attribs if a.startswith('can_') and self.has_permission(a) - } + def event_permission_set(self, include_legacy=True) -> set: + from ..permissions import get_all_event_permission_groups + + result = set() + for pg in get_all_event_permission_groups().values(): + for action in pg.actions: + if self.all_event_permissions or self.limit_event_permissions.get(f"{pg.name}:{action}"): + result.add(f"{pg.name}:{action}") + + if include_legacy: + # Add legacy permissions as well for plugin compatibility + for k, v in OLD_TO_NEW_EVENT_COMPAT.items(): + if self.all_event_permissions or all(self.limit_event_permissions.get(kk) for kk in v): + result.add(k) + + if "can_change_event_settings" in result: + result.add("can_change_settings") + + return result + + def organizer_permission_set(self, include_legacy=True) -> set: + from ..permissions import get_all_organizer_permission_groups + + result = set() + for pg in get_all_organizer_permission_groups().values(): + for action in pg.actions: + if self.all_organizer_permissions or self.limit_organizer_permissions.get(f"{pg.name}:{action}"): + result.add(f"{pg.name}:{action}") + + if include_legacy: + # Add legacy permissions as well for plugin compatibility + for k, v in OLD_TO_NEW_ORGANIZER_COMPAT.items(): + if self.all_organizer_permissions or all(self.limit_organizer_permissions.get(kk) for kk in v): + result.add(k) + + return result @property - def can_change_settings(self): # Legacy compatiblilty + def can_change_settings(self): # Legacy compatibility return self.can_change_event_settings - def has_permission(self, perm_name): - try: + def has_event_permission(self, perm_name): + from ..permissions import assert_valid_event_permission + + if perm_name.startswith('can_') and hasattr(self, perm_name): # legacy return getattr(self, perm_name) - except AttributeError: - raise ValueError('Invalid required permission: %s' % perm_name) + assert_valid_event_permission(perm_name, allow_legacy=False) + return self.all_event_permissions or self.limit_event_permissions.get(perm_name, False) + + def has_organizer_permission(self, perm_name): + from ..permissions import assert_valid_organizer_permission + + if perm_name.startswith('can_') and hasattr(self, perm_name): # legacy + return getattr(self, perm_name) + assert_valid_organizer_permission(perm_name, allow_legacy=False) + return self.all_organizer_permissions or self.limit_organizer_permissions.get(perm_name, False) def permission_for_event(self, event): if self.all_events: @@ -447,6 +470,19 @@ class Team(LoggedModel): def active_tokens(self): return self.tokens.filter(active=True) + def save(self, **kwargs): + if not isinstance(self.limit_event_permissions, dict): + raise TypeError("Permissions must be a dictionary") + if not isinstance(self.limit_organizer_permissions, dict): + raise TypeError("Permissions must be a dictionary") + for k in self.limit_event_permissions.values(): + if k is not True: + raise TypeError("Permissions must only contain True values") + for k in self.limit_organizer_permissions.values(): + if k is not True: + raise TypeError("Permissions must only contain True values") + return super().save(**kwargs) + class Meta: verbose_name = _("Team") verbose_name_plural = _("Teams") @@ -503,7 +539,7 @@ class TeamAPIToken(models.Model): has_event_access = (self.team.all_events and organizer == self.team.organizer) or ( event in self.team.limit_events.all() ) - return self.team.permission_set() if has_event_access else set() + return self.team.event_permission_set() if has_event_access else set() def get_organizer_permission_set(self, organizer) -> set: """ @@ -512,7 +548,7 @@ class TeamAPIToken(models.Model): :param organizer: The organizer of the event :return: set of permissions """ - return self.team.permission_set() if self.team.organizer == organizer else set() + return self.team.organizer_permission_set() if self.team.organizer == organizer else set() def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool: """ @@ -521,7 +557,7 @@ class TeamAPIToken(models.Model): :param organizer: The organizer of the event :param event: The event to check - :param perm_name: The permission, e.g. ``can_change_teams`` + :param perm_name: The permission, e.g. ``event.orders:read`` :param request: This parameter is ignored and only defined for compatibility reasons. :return: bool """ @@ -529,8 +565,8 @@ class TeamAPIToken(models.Model): event in self.team.limit_events.all() ) if isinstance(perm_name, (tuple, list)): - return has_event_access and any(self.team.has_permission(p) for p in perm_name) - return has_event_access and (not perm_name or self.team.has_permission(perm_name)) + return has_event_access and any(self.team.has_event_permission(p) for p in perm_name) + return has_event_access and (not perm_name or self.team.has_event_permission(perm_name)) def has_organizer_permission(self, organizer, perm_name=None, request=None): """ @@ -538,13 +574,13 @@ class TeamAPIToken(models.Model): to the organizer ``organizer``. :param organizer: The organizer to check - :param perm_name: The permission, e.g. ``can_change_teams`` + :param perm_name: The permission, e.g. ``organizer.events:create`` :param request: This parameter is ignored and only defined for compatibility reasons. :return: bool """ if isinstance(perm_name, (tuple, list)): - return organizer == self.team.organizer and any(self.team.has_permission(p) for p in perm_name) - return organizer == self.team.organizer and (not perm_name or self.team.has_permission(perm_name)) + return organizer == self.team.organizer and any(self.team.has_organizer_permission(p) for p in perm_name) + return organizer == self.team.organizer and (not perm_name or self.team.has_organizer_permission(perm_name)) def get_events_with_any_permission(self): """ @@ -564,9 +600,11 @@ class TeamAPIToken(models.Model): :param request: Ignored, for compatibility with User model :return: Iterable of Events """ + from pretix.base.permissions import AnyPermissionOf + if ( - isinstance(permission, (list, tuple)) and any(getattr(self.team, p, False) for p in permission) - ) or (isinstance(permission, str) and getattr(self.team, permission, False)): + isinstance(permission, (AnyPermissionOf, list, tuple)) and any(self.team.has_event_permission(p) for p in permission) + ) or (isinstance(permission, str) and self.team.has_event_permission(permission)): return self.get_events_with_any_permission() else: return self.team.organizer.events.none() diff --git a/src/pretix/base/notifications.py b/src/pretix/base/notifications.py index b95d484063..e29b947235 100644 --- a/src/pretix/base/notifications.py +++ b/src/pretix/base/notifications.py @@ -151,7 +151,7 @@ def get_all_notification_types(event=None): class ParametrizedOrderNotificationType(NotificationType): - required_permission = "can_view_orders" + required_permission = "event.orders:read" def __init__(self, event, action_type, verbose_name, title): self._action_type = action_type diff --git a/src/pretix/base/permissions.py b/src/pretix/base/permissions.py new file mode 100644 index 0000000000..1257870d1f --- /dev/null +++ b/src/pretix/base/permissions.py @@ -0,0 +1,334 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-today pretix 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 +# . +# +import functools +import logging +import warnings +from collections import OrderedDict +from typing import Callable, Dict, List, NamedTuple, Set, Tuple + +from django.apps import apps +from django.dispatch import receiver +from django.utils.functional import Promise +from django.utils.translation import gettext_lazy as _, pgettext_lazy + +from pretix.base.signals import ( + register_event_permission_groups, register_organizer_permission_groups, +) + +logger = logging.getLogger(__name__) + + +def cache_until_change(input_value: Callable): + def decorator(func): + old_input_value = None + cached_result = None + + @functools.wraps(func) + def wrapper(): + nonlocal cached_result, old_input_value + if cached_result is None or old_input_value != input_value(): + cached_result = func() + old_input_value = input_value() + return cached_result + return wrapper + return decorator + + +class PermissionOption(NamedTuple): + actions: Tuple[str, ...] + label: str | Promise + help_text: str | Promise = None + + +class PermissionGroup(NamedTuple): + name: str + label: str | Promise + actions: List[str] + options: List[PermissionOption] + help_text: str | Promise = None + + +@cache_until_change(input_value=lambda: apps.ready) +def get_all_event_permission_groups() -> Dict[str, PermissionGroup]: + types = OrderedDict() + for recv, ret in register_event_permission_groups.send(None): + if isinstance(ret, (list, tuple)): + for r in ret: + types[r.name] = r + else: + types[ret.name] = ret + return types + + +@cache_until_change(input_value=lambda: apps.ready) +def get_all_organizer_permission_groups() -> Dict[str, PermissionGroup]: + types = OrderedDict() + for recv, ret in register_organizer_permission_groups.send(None): + if isinstance(ret, (list, tuple)): + for r in ret: + types[r.name] = r + else: + types[ret.name] = ret + return types + + +@cache_until_change(input_value=lambda: apps.ready) +def get_all_event_permissions() -> Set[str]: + from pretix.helpers.permission_migration import OLD_TO_NEW_EVENT_COMPAT + + res = set(OLD_TO_NEW_EVENT_COMPAT.keys()) + for pg in get_all_event_permission_groups().values(): + for a in pg.actions: + res.add(f"{pg.name}:{a}") + return res + + +@cache_until_change(input_value=lambda: apps.ready) +def get_all_organizer_permissions() -> Set[str]: + from pretix.helpers.permission_migration import OLD_TO_NEW_ORGANIZER_COMPAT + + res = set(OLD_TO_NEW_ORGANIZER_COMPAT.keys()) + for pg in get_all_organizer_permission_groups().values(): + for a in pg.actions: + res.add(f"{pg.name}:{a}") + + return res + + +def assert_valid_event_permission(permission, allow_legacy=True, allow_tuple=True): + if not apps.ready: + # can't really check yet + return + if allow_legacy and permission == "can_change_settings": + permission = "can_change_event_settings" + if permission is None: + return + if isinstance(permission, (AnyPermissionOf, list, tuple)) and allow_tuple: + for p in permission: + assert_valid_event_permission(p) + return + if not allow_legacy and ':' not in permission: + raise ValueError(f"Not allowed to use legacy permission '{permission}'") + all_permissions = get_all_event_permissions() + if permission not in all_permissions: + # Warning *and* exception because warning is silently caught when used in if statements in Django templates + warnings.warn(f"Use of undefined permission '{permission}'") + raise Exception(f"Undefined permission '{permission}'") + + +def assert_valid_organizer_permission(permission, allow_legacy=True, allow_tuple=True): + if not apps.ready: + # can't really check yet + return + if permission is None: + return + if isinstance(permission, (AnyPermissionOf, list, tuple)) and allow_tuple: + for p in permission: + assert_valid_organizer_permission(p) + return + if not allow_legacy and ':' not in permission: + raise ValueError(f"Not allowed to use legacy permission '{permission}'") + all_permissions = get_all_organizer_permissions() + if permission not in all_permissions: + # Warning *and* exception because warning is silently caught when used in if statements in Django templates + warnings.warn(f"Use of undefined permission '{permission}'") + raise Exception(f"Undefined permission '{permission}'") + + +class AnyPermissionOf(list): + def __init__(self, *items): + super().__init__(items) + + +OPTS_ALL_READ = [ + PermissionOption(actions=tuple(), label=pgettext_lazy("permission_level", "View")), + PermissionOption(actions=("write",), label=pgettext_lazy("permission_level", "View and change")), +] +OPTS_ALL_READ_SETTINGS_API = [ + PermissionOption(actions=tuple(), label=pgettext_lazy("permission_level", "View"), + help_text=_("API only")), + PermissionOption(actions=("write",), label=pgettext_lazy("permission_level", "View and change")), +] +OPTS_ALL_READ_SETTINGS_PARENT = [ + PermissionOption(actions=tuple(), label=pgettext_lazy("permission_level", "View"), + help_text=_("Menu item will only show up if the user has permission for general settings.")), + PermissionOption(actions=("write",), label=pgettext_lazy("permission_level", "View and change")), +] +OPTS_READ_WRITE = [ + PermissionOption(actions=tuple(), label=pgettext_lazy("permission_level", "No access")), + PermissionOption(actions=("read",), label=pgettext_lazy("permission_level", "View")), + PermissionOption(actions=("read", "write"), label=pgettext_lazy("permission_level", "View and change")), +] + + +@receiver(register_event_permission_groups, dispatch_uid="base_register_default_event_permissions") +def register_default_event_permissions(sender, **kwargs): + return [ + PermissionGroup( + name="event.settings.general", + label=_("General settings"), + actions=["write"], + options=OPTS_ALL_READ_SETTINGS_API, + help_text=_( + "This includes access to all settings not listed explicitly below, including plugin settings." + ), + ), + PermissionGroup( + name="event.settings.payment", + label=_("Payment settings"), + actions=["write"], + options=OPTS_ALL_READ_SETTINGS_PARENT, + ), + PermissionGroup( + name="event.settings.tax", + label=_("Tax settings"), + actions=["write"], + options=OPTS_ALL_READ_SETTINGS_PARENT, + ), + PermissionGroup( + name="event.settings.invoicing", + label=_("Invoicing settings"), + actions=["write"], + options=OPTS_ALL_READ_SETTINGS_PARENT, + ), + PermissionGroup( + name="event.subevents", + label=_("Event series dates"), + actions=["write"], + options=OPTS_ALL_READ, + ), + PermissionGroup( + name="event.items", + label=_("Products, quotas and questions"), + actions=["write"], + options=OPTS_ALL_READ, + help_text=_("Also includes related objects like categories or discounts."), + ), + PermissionGroup( + name="event.orders", + label=_("Orders"), + actions=["read", "write", "checkin"], + options=[ + PermissionOption(actions=tuple(), label=pgettext_lazy("permission_level", "No access")), + PermissionOption(actions=("checkin",), label=pgettext_lazy("permission_level", "Only check-in")), + PermissionOption(actions=("read",), label=pgettext_lazy("permission_level", "View all")), + PermissionOption(actions=("read", "checkin"), label=pgettext_lazy("permission_level", "View all and check-in")), + PermissionOption(actions=("read", "write"), label=pgettext_lazy("permission_level", "View all and change"), + help_text=_("Includes the ability to cancel and refund individual orders.")), + ], + help_text=_("Also includes related objects like the waiting list."), + ), + PermissionGroup( + name="event.vouchers", + label=_("Vouchers"), + actions=["read", "write"], + options=OPTS_READ_WRITE, + ), + PermissionGroup( + name="event", + label=_("Full event or date cancellation"), + actions=["cancel"], + options=[ + # If we ever add more actions, we need a new UI idea here + PermissionOption(actions=tuple(), label=pgettext_lazy("permission_level", "Not allowed")), + PermissionOption(actions=("cancel",), label=pgettext_lazy("permission_level", "Allowed")), + ], + help_text="", + ), + ] + + +@receiver(register_organizer_permission_groups, dispatch_uid="base_register_default_organizer_permissions") +def register_default_organizer_permissions(sender, **kwargs): + return [ + PermissionGroup( + name="organizer.events", + label=_("Events"), + actions=["create"], + options=[ + PermissionOption(actions=tuple(), label=pgettext_lazy("permission_level", "Access existing events")), + PermissionOption(actions=("create",), label=pgettext_lazy("permission_level", "Access existing and create new events")), + ], + help_text=_("The level of access to events is determined in detail by the settings below."), + ), + PermissionGroup( + name="organizer.settings.general", + label=_("Settings"), + actions=["write"], + options=OPTS_ALL_READ_SETTINGS_API, + help_text=_("This includes access to all organizer-level functionality not listed explicitly below, including plugin settings."), + ), + PermissionGroup( + name="organizer.teams", + label=_("Teams"), + actions=["write"], + options=[ + PermissionOption(actions=tuple(), label=pgettext_lazy("permission_level", "No access")), + PermissionOption(actions=("write",), label=pgettext_lazy("permission_level", "View and change"), + help_text=_("Includes the ability to give someone (including oneself) additional permissions.")), + ], + ), + PermissionGroup( + name="organizer.giftcards", + label=_("Gift cards"), + actions=["read", "write"], + options=OPTS_READ_WRITE, + ), + PermissionGroup( + name="organizer.customers", + label=_("Customers"), + actions=["read", "write"], + options=OPTS_READ_WRITE, + ), + PermissionGroup( + name="organizer.reusablemedia", + label=_("Reusable media"), + actions=["read", "write"], + options=OPTS_READ_WRITE, + ), + PermissionGroup( + name="organizer.devices", + label=_("Devices"), + actions=["read", "write"], + options=[ + PermissionOption(actions=tuple(), label=pgettext_lazy("permission_level", "No access")), + PermissionOption(actions=("read",), label=pgettext_lazy("permission_level", "View")), + PermissionOption(actions=("read", "write"), label=pgettext_lazy("permission_level", "View and change"), + help_text=_("Includes the ability to give access to events and data oneself does not have access to.")), + ], + ), + PermissionGroup( + name="organizer.seatingplans", + label=_("Seating plans"), + actions=["write"], + options=OPTS_ALL_READ, + ), + PermissionGroup( + name="organizer.outgoingmails", + label=_("Outgoing emails"), + actions=["read"], + options=[ + PermissionOption(actions=tuple(), label=pgettext_lazy("permission_level", "No access")), + PermissionOption(actions=("read",), label=pgettext_lazy("permission_level", "View")), + ], + ), + ] diff --git a/src/pretix/base/services/export.py b/src/pretix/base/services/export.py index c1fbe0664b..2b4be717dd 100644 --- a/src/pretix/base/services/export.py +++ b/src/pretix/base/services/export.py @@ -34,7 +34,7 @@ from django_scopes import scopes_disabled from i18nfield.strings import LazyI18nString from pretix.base.email import get_email_context -from pretix.base.exporter import OrganizerLevelExportMixin +from pretix.base.exporter import BaseExporter, OrganizerLevelExportMixin from pretix.base.i18n import LazyLocaleException, language from pretix.base.models import ( CachedFile, Device, Event, Organizer, ScheduledEventExport, TeamAPIToken, @@ -64,7 +64,15 @@ class ExportEmptyError(ExportError): @app.task(base=ProfiledEventTask, throws=(ExportError, ExportEmptyError), bind=True) -def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str, Any]) -> None: +def export(self, event: Event, user: User, device: int, token: int, fileid: str, provider: str, + form_data: Dict[str, Any], staff_session=False) -> None: + if user: + user = User.objects.get(pk=user) + if device: + device = Device.objects.get(pk=device) + if token: + device = TeamAPIToken.objects.get(pk=token) + def set_progress(val): if not self.request.called_directly: self.update_state( @@ -72,30 +80,38 @@ def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str, meta={'value': val} ) + ex = init_event_exporter( + identifier=provider, + event=event, + user=user, + token=token, + device=device, + staff_session=staff_session, + progress_callback=set_progress, + ) + if not ex: + raise ExportError( + gettext('Export not found or you do not have sufficient permission to perform this export.') + ) + file = CachedFile.objects.get(id=fileid) with language(event.settings.locale, event.settings.region), override(event.settings.timezone): - responses = register_data_exporters.send(event) - for recv, response in responses: - if not response: - continue - ex = response(event, event.organizer, set_progress) - if ex.identifier == provider: - if ex.repeatable_read: - with repeatable_reads_transaction(): - d = ex.render(form_data) - else: - d = ex.render(form_data) + if ex.repeatable_read: + with repeatable_reads_transaction(): + d = ex.render(form_data) + else: + d = ex.render(form_data) - if d is None: - raise ExportError( - gettext('Your export did not contain any data.') - ) - file.filename, file.type, data = d + if d is None: + raise ExportError( + gettext('Your export did not contain any data.') + ) + file.filename, file.type, data = d - close_old_connections() # This task can run very long, we might need a new DB connection + close_old_connections() # This task can run very long, we might need a new DB connection - f = ContentFile(data) - file.file.save(cachedfile_name(file, file.filename), f) + f = ContentFile(data) + file.file.save(cachedfile_name(file, file.filename), f) return str(file.pk) @@ -105,10 +121,7 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int, if device: device = Device.objects.get(pk=device) if token: - device = TeamAPIToken.objects.get(pk=token) - allowed_events = (device or token or user).get_events_with_permission('can_view_orders') - if user and staff_session: - allowed_events = organizer.events.all() + token = TeamAPIToken.objects.get(pk=token) def set_progress(val): if not self.request.called_directly: @@ -118,12 +131,35 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int, ) file = CachedFile.objects.get(id=fileid) + + event_qs = organizer.events.all() + if form_data.get('events') is not None and not form_data.get('all_events'): + if form_data['events'] and isinstance(form_data['events'][0], str): # legacy API-created schedules + event_qs = event_qs.filter(slug__in=form_data.get('events')) + else: + event_qs = event_qs.filter(pk__in=form_data.get('events')) + + ex = init_organizer_exporter( + identifier=provider, + organizer=organizer, + user=user, + token=token, + device=device, + staff_session=staff_session, + progress_callback=set_progress, + event_qs=event_qs, + ) + if not ex: + raise ExportError( + gettext('Export not found or you do not have sufficient permission to perform this export.') + ) + if user: locale = user.locale timezone = user.timezone region = None # todo: add to user? else: - e = allowed_events.first() + e = ex.events.first() if e: locale = e.settings.locale timezone = e.settings.timezone @@ -133,47 +169,140 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int, timezone = organizer.settings.timezone or settings.TIME_ZONE region = organizer.settings.region with language(locale, region), override(timezone): - if form_data.get('events') is not None and not form_data.get('all_events'): - if isinstance(form_data['events'][0], str): - events = allowed_events.filter(slug__in=form_data.get('events'), organizer=organizer) - else: - events = allowed_events.filter(pk__in=form_data.get('events'), organizer=organizer) + if ex.repeatable_read: + with repeatable_reads_transaction(): + d = ex.render(form_data) else: - events = allowed_events.filter(organizer=organizer) - responses = register_multievent_data_exporters.send(organizer) + d = ex.render(form_data) + if d is None: + raise ExportError( + gettext('Your export did not contain any data.') + ) + file.filename, file.type, data = d - for recv, response in responses: - if not response: - continue - ex = response(events, organizer, set_progress) - if ex.identifier == provider: - if ( - isinstance(ex, OrganizerLevelExportMixin) and - not staff_session and - not (device or token or user).has_organizer_permission(organizer, ex.organizer_required_permission) - ): - raise ExportError( - gettext('You do not have sufficient permission to perform this export.') - ) + close_old_connections() # This task can run very long, we might need a new DB connection - if ex.repeatable_read: - with repeatable_reads_transaction(): - d = ex.render(form_data) - else: - d = ex.render(form_data) - if d is None: - raise ExportError( - gettext('Your export did not contain any data.') - ) - file.filename, file.type, data = d - - close_old_connections() # This task can run very long, we might need a new DB connection - - f = ContentFile(data) - file.file.save(cachedfile_name(file, file.filename), f) + f = ContentFile(data) + file.file.save(cachedfile_name(file, file.filename), f) return str(file.pk) +def init_event_exporter(identifier, **kwargs): + for ex in init_event_exporters(**kwargs): + if ex.identifier == identifier: + return ex + return None + + +def init_event_exporters(event, user=None, token=None, device=None, request=None, staff_session=False, **kwargs): + if not user and not token and not device: + raise ValueError("No auth source given.") + perm_holder = device or token or user + + responses = register_data_exporters.send(event) + for r, response in responses: + if not response: + continue + + if issubclass(response, OrganizerLevelExportMixin): + raise TypeError("Cannot user organizer-level exporter on event level") + + permission_name = response.get_required_event_permission() + if not perm_holder.has_event_permission(event.organizer, event, permission_name, request) and not staff_session: + continue + + exporter: BaseExporter = response(event=event, organizer=event.organizer, **kwargs) + + if not exporter.available_for_user(user if user and user.is_authenticated else None): + continue + + yield exporter + + +def init_organizer_exporter(identifier, **kwargs): + for ex in init_organizer_exporters(**kwargs): + if ex.identifier == identifier: + return ex + return None + + +def init_organizer_exporters( + organizer, user=None, token=None, device=None, request=None, staff_session=False, event_qs=None, **kwargs +): + if not user and not token and not device: + raise ValueError("No auth source given.") + perm_holder = device or token or user + + _event_list_cache = {} + _has_permission_on_any_team_cache = {} + _team_cache = None + + responses = register_multievent_data_exporters.send(organizer) + for r, response in responses: + if not response: + continue + + if issubclass(response, OrganizerLevelExportMixin): + exporter: BaseExporter = response(event=Event.objects.none(), organizer=organizer, **kwargs) + + try: + if not perm_holder.has_organizer_permission(organizer, response.get_required_organizer_permission(), request) and not staff_session: + continue + except NotImplementedError: + logger.error(f"Not showing export {response} because get_required_organizer_permission() is not implemented.") + continue + + else: + permission_name = response.get_required_event_permission() + + if permission_name not in _event_list_cache: + if staff_session: + events = event_qs.all() + elif event_qs is not None: + events = event_qs.filter( + pk__in=perm_holder.get_events_with_permission( + permission_name, request=request + ).filter( + organizer=organizer + ).values("id") + ) + else: + events = perm_holder.get_events_with_permission( + permission_name, request=request + ).filter( + organizer=organizer + ) + + _event_list_cache[permission_name] = events + + if permission_name not in _has_permission_on_any_team_cache: + # Check if the user has this event permission on any teams they are part of to decide whether to show + # the export at all. + # This is different from _event_list_cache[permission_name].exists() for the case of an organizer with + # zero events in total, or a team with zero events. In these cases, we still want people to be able + # to see waht exports they'll get once they have events. + if user: + if _team_cache is None: + _team_cache = list(user.teams.filter(organizer=organizer)) + _has_permission_on_any_team_cache[permission_name] = staff_session or any( + t.has_event_permission(permission_name) for t in _team_cache + ) + elif token: + _has_permission_on_any_team_cache[permission_name] = token.team.has_event_permission(permission_name) + elif device: + _has_permission_on_any_team_cache[permission_name] = device.has_event_permission(permission_name) + + if not _has_permission_on_any_team_cache[permission_name]: + continue + + exporter: BaseExporter = response(event=_event_list_cache[permission_name], organizer=organizer, **kwargs) + + if not exporter.available_for_user(user if user and user.is_authenticated else None): + continue + + yield exporter + + def _run_scheduled_export(schedule, context: Union[Event, Organizer], exporter, config_url, retry_func, has_permission): with language(schedule.locale, context.settings.region), override(schedule.tz): file = CachedFile(web_download=False) @@ -217,7 +346,7 @@ def _run_scheduled_export(schedule, context: Union[Event, Organizer], exporter, try: if not exporter: - raise ExportError("Export type not found.") + raise ExportError("Export type not found or permission denied.") if exporter.repeatable_read: with repeatable_reads_transaction(): d = exporter.render(schedule.export_form_data) @@ -291,31 +420,20 @@ def _run_scheduled_export(schedule, context: Union[Event, Organizer], exporter, def scheduled_organizer_export(self, organizer: Organizer, schedule: int) -> None: schedule = organizer.scheduled_exports.get(pk=schedule) - allowed_events = schedule.owner.get_events_with_permission('can_view_orders') + event_qs = organizer.events.all() if schedule.export_form_data.get('events') is not None and not schedule.export_form_data.get('all_events'): if isinstance(schedule.export_form_data['events'][0], str): - events = allowed_events.filter(slug__in=schedule.export_form_data.get('events'), organizer=organizer) + event_qs = event_qs.filter(slug__in=schedule.export_form_data.get('events')) else: - events = allowed_events.filter(pk__in=schedule.export_form_data.get('events'), organizer=organizer) - else: - events = allowed_events.filter(organizer=organizer) - - responses = register_multievent_data_exporters.send(organizer) - exporter = None - for recv, response in responses: - if not response: - continue - ex = response(events, organizer) - if ex.identifier == schedule.export_identifier: - exporter = ex - break + event_qs = event_qs.filter(pk__in=schedule.export_form_data.get('events')) + exporter = init_organizer_exporter( + identifier=schedule.export_identifier, + organizer=organizer, + user=schedule.owner, + event_qs=event_qs, + ) has_permission = schedule.owner.is_active - if isinstance(exporter, OrganizerLevelExportMixin): - if not schedule.owner.has_organizer_permission(organizer, exporter.organizer_required_permission): - has_permission = False - if exporter and not exporter.available_for_user(schedule.owner): - has_permission = False _run_scheduled_export( schedule, @@ -336,17 +454,12 @@ def scheduled_organizer_export(self, organizer: Organizer, schedule: int) -> Non def scheduled_event_export(self, event: Event, schedule: int) -> None: schedule = event.scheduled_exports.get(pk=schedule) - responses = register_data_exporters.send(event) - exporter = None - for recv, response in responses: - if not response: - continue - ex = response(event, event.organizer) - if ex.identifier == schedule.export_identifier: - exporter = ex - break - - has_permission = schedule.owner.is_active and schedule.owner.has_event_permission(event.organizer, event, 'can_view_orders') + exporter = init_event_exporter( + identifier=schedule.export_identifier, + event=event, + user=schedule.owner, + ) + has_permission = schedule.owner.is_active _run_scheduled_export( schedule, diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 4758f33936..0c801fac75 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -345,6 +345,7 @@ DEFAULTS = { 'type': bool, 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.tax:write', 'form_kwargs': dict( label=_("Show net prices instead of gross prices in the product list"), help_text=_("Independent of your choice, the cart will show gross prices as this is the price that needs to be " @@ -492,6 +493,7 @@ DEFAULTS = { 'type': str, 'form_class': forms.ChoiceField, 'serializer_class': serializers.ChoiceField, + 'write_permission': 'event.settings.tax:write', 'form_kwargs': dict( label=_("Rounding of taxes"), widget=forms.RadioSelect, @@ -511,15 +513,17 @@ DEFAULTS = { 'type': bool, 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Ask for invoice address"), - ) + ), }, 'invoice_address_not_asked_free': { 'default': 'False', 'type': bool, 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_('Do not ask for invoice address if an order is free'), ) @@ -529,6 +533,7 @@ DEFAULTS = { 'type': bool, 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Require customer name"), ) @@ -538,6 +543,7 @@ DEFAULTS = { 'type': bool, 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Show attendee names on invoices"), ) @@ -547,6 +553,7 @@ DEFAULTS = { 'type': bool, 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Show event location on invoices"), help_text=_("The event location will be shown below the list of products if it is the same for all " @@ -558,6 +565,7 @@ DEFAULTS = { 'type': str, 'form_class': forms.ChoiceField, 'serializer_class': serializers.ChoiceField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Show exchange rates"), widget=forms.RadioSelect, @@ -581,6 +589,7 @@ DEFAULTS = { 'default': 'False', 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.invoicing:write', 'type': bool, 'form_kwargs': dict( label=_("Require invoice address"), @@ -591,6 +600,7 @@ DEFAULTS = { 'default': 'False', 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.invoicing:write', 'type': bool, 'form_kwargs': dict( label=_("Require a business address"), @@ -603,6 +613,7 @@ DEFAULTS = { 'type': bool, 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Ask for beneficiary"), widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}), @@ -613,6 +624,7 @@ DEFAULTS = { 'type': LazyI18nString, 'form_class': I18nFormField, 'serializer_class': I18nField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Custom recipient field label"), widget=I18nTextInput, @@ -628,6 +640,7 @@ DEFAULTS = { 'type': LazyI18nString, 'form_class': I18nFormField, 'serializer_class': I18nField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Custom recipient field help text"), widget=I18nTextInput, @@ -640,6 +653,7 @@ DEFAULTS = { 'type': bool, 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Ask for VAT ID"), help_text=format_lazy( @@ -655,6 +669,7 @@ DEFAULTS = { 'type': list, 'form_class': forms.MultipleChoiceField, 'serializer_class': serializers.MultipleChoiceField, + 'write_permission': 'event.settings.invoicing:write', 'serializer_kwargs': dict( choices=lazy( lambda *args: sorted([(cc, gettext(Country(cc).name)) for cc in VAT_ID_COUNTRIES], key=lambda c: c[1]), @@ -682,6 +697,7 @@ DEFAULTS = { 'type': LazyI18nString, 'form_class': I18nFormField, 'serializer_class': I18nField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Invoice address explanation"), widget=I18nMarkdownTextarea, @@ -694,6 +710,7 @@ DEFAULTS = { 'type': bool, 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Show paid amount on partially paid invoices"), help_text=_("If an invoice has already been paid partially, this option will add the paid and pending " @@ -705,6 +722,7 @@ DEFAULTS = { 'type': bool, 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Show free products on invoices"), help_text=_("Note that invoices will never be generated for orders that contain only free " @@ -716,6 +734,7 @@ DEFAULTS = { 'type': bool, 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Show expiration date of order"), help_text=_("The expiration date will not be shown if the invoice is generated after the order is paid."), @@ -727,6 +746,7 @@ DEFAULTS = { 'form_class': forms.IntegerField, 'serializer_class': serializers.IntegerField, 'serializer_kwargs': dict(), + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Minimum length of invoice number after prefix"), help_text=_("The part of your invoice number after your prefix will be filled up with leading zeros up to this length, e.g. INV-001 or INV-00001."), @@ -740,6 +760,7 @@ DEFAULTS = { 'type': bool, 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Generate invoices with consecutive numbers"), help_text=_("If deactivated, the order code will be used in the invoice number."), @@ -750,6 +771,7 @@ DEFAULTS = { 'type': str, 'form_class': forms.CharField, 'serializer_class': serializers.CharField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Invoice number prefix"), help_text=_("This will be prepended to invoice numbers. If you leave this field empty, your event slug will " @@ -777,6 +799,7 @@ DEFAULTS = { 'type': str, 'form_class': forms.CharField, 'serializer_class': serializers.CharField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Invoice number prefix for cancellations"), help_text=_("This will be prepended to invoice numbers of cancellations. If you leave this field empty, " @@ -800,6 +823,7 @@ DEFAULTS = { 'type': bool, 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Highlight order code to make it stand out visibly"), help_text=_("Only respected by some invoice renderers."), @@ -811,6 +835,7 @@ DEFAULTS = { 'form_class': forms.ChoiceField, 'serializer_class': serializers.ChoiceField, 'serializer_kwargs': lambda: dict(**invoice_font_kwargs()), + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': lambda: dict( label=_('Font'), help_text=_("Only respected by some invoice renderers."), @@ -821,6 +846,7 @@ DEFAULTS = { 'invoice_renderer': { 'default': 'classic', # default for new events is 'modern1' 'type': str, + 'write_permission': 'event.settings.invoicing:write', }, 'ticket_secret_generator': { 'default': 'random', @@ -897,6 +923,7 @@ DEFAULTS = { 'type': LazyI18nString, 'form_class': I18nFormField, 'serializer_class': I18nField, + 'write_permission': 'event.settings.payment:write', 'form_kwargs': dict( widget=I18nMarkdownTextarea, widget_kwargs={'attrs': { @@ -918,6 +945,7 @@ DEFAULTS = { ('minutes', _("in minutes")) ), ), + 'write_permission': 'event.settings.payment:write', 'form_kwargs': dict( label=_("Set payment term"), widget=forms.RadioSelect, @@ -935,6 +963,7 @@ DEFAULTS = { 'type': int, 'form_class': forms.IntegerField, 'serializer_class': serializers.IntegerField, + 'write_permission': 'event.settings.payment:write', 'form_kwargs': dict( label=_('Payment term in days'), widget=forms.NumberInput( @@ -960,6 +989,7 @@ DEFAULTS = { 'type': bool, 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.payment:write', 'form_kwargs': dict( label=_('Only end payment terms on weekdays'), help_text=_("If this is activated and the payment term of any order ends on a Saturday or Sunday, it will be " @@ -977,6 +1007,7 @@ DEFAULTS = { 'type': int, 'form_class': forms.IntegerField, 'serializer_class': serializers.IntegerField, + 'write_permission': 'event.settings.payment:write', 'form_kwargs': dict( label=_('Payment term in minutes'), help_text=_("The number of minutes after placing an order the user has to pay to preserve their reservation. " @@ -1001,6 +1032,7 @@ DEFAULTS = { 'type': RelativeDateWrapper, 'form_class': RelativeDateField, 'serializer_class': SerializerRelativeDateField, + 'write_permission': 'event.settings.payment:write', 'form_kwargs': dict( label=_('Last date of payments'), help_text=_("The last date any payments are accepted. This has precedence over the terms " @@ -1013,6 +1045,7 @@ DEFAULTS = { 'type': bool, 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.payment:write', 'form_kwargs': dict( label=_('Automatically expire unpaid orders'), help_text=_("If checked, all unpaid orders will automatically go from 'pending' to 'expired' " @@ -1025,6 +1058,7 @@ DEFAULTS = { 'type': int, 'form_class': forms.IntegerField, 'serializer_class': serializers.IntegerField, + 'write_permission': 'event.settings.payment:write', 'form_kwargs': dict( label=_('Expiration delay'), help_text=_("The order will only actually expire this many days after the expiration date communicated " @@ -1047,6 +1081,7 @@ DEFAULTS = { 'type': bool, 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.payment:write', 'form_kwargs': dict( label=_('Hide "payment pending" state on customer-facing pages'), help_text=_("The payment instructions panel will still be shown to the primary customer, but no indication " @@ -1058,9 +1093,11 @@ DEFAULTS = { 'default': 'True', 'type': bool, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.payment:write', }, 'payment_giftcard_public_name': { 'default': LazyI18nString.from_gettext(gettext_noop('Gift card')), + 'write_permission': 'event.settings.payment:write', 'type': LazyI18nString }, 'payment_giftcard_public_description': { @@ -1069,10 +1106,12 @@ DEFAULTS = { 'enough credit to pay for the full order, you will be shown this page again and you can either ' 'redeem another gift card or select a different payment method for the difference.' )), + 'write_permission': 'event.settings.payment:write', 'type': LazyI18nString }, 'payment_resellers__restrict_to_sales_channels': { 'default': ['resellers'], + 'write_permission': 'event.settings.payment:write', 'type': list }, 'payment_term_accept_late': { @@ -1080,6 +1119,7 @@ DEFAULTS = { 'type': bool, 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.payment:write', 'form_kwargs': dict( label=_('Accept late payments'), help_text=_("Accept payments for orders even when they are in 'expired' state as long as enough " @@ -1109,6 +1149,7 @@ DEFAULTS = { ('none', _('Charge no taxes')), ), ), + 'write_permission': 'event.settings.payment:write', 'form_kwargs': dict( label=_("Tax handling on payment fees"), widget=forms.RadioSelect, @@ -1155,6 +1196,7 @@ DEFAULTS = { ('paid', _('Automatically on payment or when required by payment method')), ), ), + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Generate invoices"), widget=forms.RadioSelect, @@ -1183,6 +1225,7 @@ DEFAULTS = { ('invoice_date', _('Invoice date')), ), ), + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Date of service"), widget=forms.RadioSelect, @@ -1203,6 +1246,7 @@ DEFAULTS = { 'type': bool, 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Automatically cancel and reissue invoice on address changes"), help_text=_("If customers change their invoice address on an existing order, the invoice will " @@ -1215,6 +1259,7 @@ DEFAULTS = { 'type': bool, 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Allow to update existing invoices"), help_text=_("By default, invoices can never again be changed once they are issued. In most countries, we " @@ -1224,6 +1269,7 @@ DEFAULTS = { }, 'invoice_generate_sales_channels': { 'default': json.dumps(['web']), + 'write_permission': 'event.settings.invoicing:write', 'type': list }, 'invoice_generate_only_business': { @@ -1240,6 +1286,7 @@ DEFAULTS = { 'type': str, 'form_class': forms.CharField, 'serializer_class': serializers.CharField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Address line"), widget=forms.Textarea(attrs={ @@ -1255,6 +1302,7 @@ DEFAULTS = { 'type': str, 'form_class': forms.CharField, 'serializer_class': serializers.CharField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( max_length=190, label=_("Company name"), @@ -1265,6 +1313,7 @@ DEFAULTS = { 'type': str, 'form_class': forms.CharField, 'serializer_class': serializers.CharField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( widget=forms.TextInput(attrs={ 'placeholder': '12345' @@ -1278,6 +1327,7 @@ DEFAULTS = { 'type': str, 'form_class': forms.CharField, 'serializer_class': serializers.CharField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( widget=forms.TextInput(attrs={ 'placeholder': _('Random City') @@ -1294,6 +1344,7 @@ DEFAULTS = { 'serializer_kwargs': { 'choices': [('', '')], }, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': { "label": pgettext_lazy('address', 'State'), 'choices': [('', '')], @@ -1305,6 +1356,7 @@ DEFAULTS = { 'form_class': forms.ChoiceField, 'serializer_class': serializers.ChoiceField, 'serializer_kwargs': lambda: dict(**country_choice_kwargs()), + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': lambda: dict( label=_('Country'), widget=forms.Select(attrs={ @@ -1318,6 +1370,7 @@ DEFAULTS = { 'type': str, 'form_class': forms.CharField, 'serializer_class': serializers.CharField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Domestic tax ID"), help_text=_("e.g. tax number in Germany, ABN in Australia, …"), @@ -1329,6 +1382,7 @@ DEFAULTS = { 'type': str, 'form_class': forms.CharField, 'serializer_class': serializers.CharField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("EU VAT ID"), max_length=190, @@ -1339,6 +1393,7 @@ DEFAULTS = { 'type': LazyI18nString, 'form_class': I18nFormField, 'serializer_class': I18nField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( widget=I18nTextarea, widget_kwargs={'attrs': { @@ -1356,6 +1411,7 @@ DEFAULTS = { 'type': LazyI18nString, 'form_class': I18nFormField, 'serializer_class': I18nField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( widget=I18nTextarea, widget_kwargs={'attrs': { @@ -1373,6 +1429,7 @@ DEFAULTS = { 'type': LazyI18nString, 'form_class': I18nFormField, 'serializer_class': I18nField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( widget=I18nTextarea, widget_kwargs={'attrs': { @@ -1387,6 +1444,7 @@ DEFAULTS = { }, 'invoice_language': { 'default': '__user__', + 'write_permission': 'event.settings.invoicing:write', 'type': str }, 'invoice_email_attachment': { @@ -1394,6 +1452,7 @@ DEFAULTS = { 'type': bool, 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Attach invoices to emails"), help_text=_("If invoices are automatically generated for all orders, they will be attached to the order " @@ -1407,6 +1466,7 @@ DEFAULTS = { 'type': str, 'form_class': forms.CharField, 'serializer_class': serializers.CharField, + 'write_permission': 'event.settings.invoicing:write', 'form_kwargs': dict( label=_("Email address to receive a copy of each invoice"), help_text=_("Each newly created invoice will be sent to this email address shortly after creation. You can " @@ -3260,7 +3320,8 @@ Your {organizer} team""")) # noqa: W291 'image/png', 'image/jpeg', 'image/gif' ], max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE, - ) + ), + 'write_permission': 'event.settings.invoicing:write', }, 'frontpage_text': { 'default': '', diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index c917f318bd..a4e82487e2 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -561,6 +561,18 @@ however for this signal, the ``sender`` **may also be None** to allow creating t notification settings! """ +register_event_permission_groups = GlobalSignal() +""" +This signal is sent out to get all known permissions. Receivers should return an +instance of pretix.base.permissions.PermissionGroup or a list of such instances. +""" + +register_organizer_permission_groups = GlobalSignal() +""" +This signal is sent out to get all known permissions. Receivers should return an +instance of pretix.base.permissions.PermissionGroup or a list of such instances. +""" + notification = EventPluginSignal() """ Arguments: ``logentry_id``, ``notification_type`` @@ -1106,6 +1118,9 @@ api_event_settings_fields = EventPluginSignal() This signal is sent out to collect serializable settings fields for the API. You are expected to return a dictionary mapping names of attributes in the settings store to DRF serializer field instances. +These are readable for all users with access to the events, therefore secrets stored in the settings store +should not be included! + As with all event-plugin signals, the ``sender`` keyword argument will contain the event. """ diff --git a/src/pretix/base/timeline.py b/src/pretix/base/timeline.py index 929422f9fe..bf222f35a4 100644 --- a/src/pretix/base/timeline.py +++ b/src/pretix/base/timeline.py @@ -32,7 +32,11 @@ from pretix.base.models import ItemVariation from pretix.base.reldate import RelativeDateWrapper from pretix.base.signals import timeline_events -TimelineEvent = namedtuple('TimelineEvent', ('event', 'subevent', 'datetime', 'description', 'edit_url')) +TimelineEvent = namedtuple( + 'TimelineEvent', + ('event', 'subevent', 'datetime', 'description', 'edit_url', 'edit_permission'), + defaults=(None, None, None, None, None, 'event.settings.general:write') +) def timeline_for_event(event, subevent=None): @@ -46,6 +50,7 @@ def timeline_for_event(event, subevent=None): 'subevent': subevent.pk } ) + ev_edit_permission = 'event.subevents:write' else: ev_edit_url = reverse( 'control:event.settings', kwargs={ @@ -53,12 +58,14 @@ def timeline_for_event(event, subevent=None): 'organizer': event.organizer.slug } ) + ev_edit_permission = 'event.settings.general:write' tl.append(TimelineEvent( event=event, subevent=subevent, datetime=ev.date_from, description=pgettext_lazy('timeline', 'Your event starts'), - edit_url=ev_edit_url + '#id_date_from_0' + edit_url=ev_edit_url + '#id_date_from_0', + edit_permission=ev_edit_permission, )) if ev.date_to: @@ -66,7 +73,8 @@ def timeline_for_event(event, subevent=None): event=event, subevent=subevent, datetime=ev.date_to, description=pgettext_lazy('timeline', 'Your event ends'), - edit_url=ev_edit_url + '#id_date_to_0' + edit_url=ev_edit_url + '#id_date_to_0', + edit_permission=ev_edit_permission, )) if ev.date_admission: @@ -74,7 +82,8 @@ def timeline_for_event(event, subevent=None): event=event, subevent=subevent, datetime=ev.date_admission, description=pgettext_lazy('timeline', 'Admissions for your event start'), - edit_url=ev_edit_url + '#id_date_admission_0' + edit_url=ev_edit_url + '#id_date_admission_0', + edit_permission=ev_edit_permission, )) if ev.presale_start: @@ -82,7 +91,8 @@ def timeline_for_event(event, subevent=None): event=event, subevent=subevent, datetime=ev.presale_start, description=pgettext_lazy('timeline', 'Start of ticket sales'), - edit_url=ev_edit_url + '#id_presale_start_0' + edit_url=ev_edit_url + '#id_presale_start_0', + edit_permission=ev_edit_permission, )) tl.append(TimelineEvent( @@ -97,7 +107,8 @@ def timeline_for_event(event, subevent=None): ) if not ev.presale_end else ( pgettext_lazy('timeline', 'End of ticket sales') ), - edit_url=ev_edit_url + '#id_presale_end_0' + edit_url=ev_edit_url + '#id_presale_end_0', + edit_permission=ev_edit_permission, )) rd = event.settings.get('last_order_modification_date', as_type=RelativeDateWrapper) @@ -106,7 +117,8 @@ def timeline_for_event(event, subevent=None): event=event, subevent=subevent, datetime=rd.datetime(ev), description=pgettext_lazy('timeline', 'Customers can no longer modify their order information'), - edit_url=ev_edit_url + '#id_settings-last_order_modification_date_0_0' + edit_url=ev_edit_url + '#id_settings-last_order_modification_date_0_0', + edit_permission='event.settings.general:write', )) rd = event.settings.get('payment_term_last', as_type=RelativeDateWrapper) @@ -122,7 +134,8 @@ def timeline_for_event(event, subevent=None): edit_url=reverse('control:event.settings.payment', kwargs={ 'event': event.slug, 'organizer': event.organizer.slug - }) + }), + edit_permission='event.settings.payment:write', )) rd = event.settings.get('ticket_download_date', as_type=RelativeDateWrapper) @@ -134,7 +147,8 @@ def timeline_for_event(event, subevent=None): edit_url=reverse('control:event.settings.tickets', kwargs={ 'event': event.slug, 'organizer': event.organizer.slug - }) + }), + edit_permission='event.settings.general:write', )) rd = event.settings.get('cancel_allow_user_until', as_type=RelativeDateWrapper) @@ -146,7 +160,8 @@ def timeline_for_event(event, subevent=None): edit_url=reverse('control:event.settings.cancel', kwargs={ 'event': event.slug, 'organizer': event.organizer.slug - }) + }), + edit_permission='event.settings.general:write', )) rd = event.settings.get('cancel_allow_user_paid_until', as_type=RelativeDateWrapper) @@ -158,7 +173,8 @@ def timeline_for_event(event, subevent=None): edit_url=reverse('control:event.settings.cancel', kwargs={ 'event': event.slug, 'organizer': event.organizer.slug - }) + }), + edit_permission='event.settings.general:write', )) rd = event.settings.get('change_allow_user_until', as_type=RelativeDateWrapper) @@ -170,7 +186,8 @@ def timeline_for_event(event, subevent=None): edit_url=reverse('control:event.settings.cancel', kwargs={ 'event': event.slug, 'organizer': event.organizer.slug - }) + }), + edit_permission='event.settings.general:write', )) rd = event.settings.get('waiting_list_auto_disable', as_type=RelativeDateWrapper) @@ -182,7 +199,8 @@ def timeline_for_event(event, subevent=None): edit_url=reverse('control:event.settings', kwargs={ 'event': event.slug, 'organizer': event.organizer.slug - }) + '#waiting-list-open' + }) + '#waiting-list-open', + edit_permission='event.settings.general:write', )) if not event.has_subevents: @@ -196,7 +214,8 @@ def timeline_for_event(event, subevent=None): edit_url=reverse('control:event.settings.mail', kwargs={ 'event': event.slug, 'organizer': event.organizer.slug - }) + }), + edit_permission='event.settings.general:write', )) if subevent: @@ -210,7 +229,8 @@ def timeline_for_event(event, subevent=None): 'event': event.slug, 'organizer': event.organizer.slug, 'subevent': subevent.pk, - }) + }), + edit_permission='event.subevents:write', )) if sei.available_until: tl.append(TimelineEvent( @@ -221,7 +241,8 @@ def timeline_for_event(event, subevent=None): 'event': event.slug, 'organizer': event.organizer.slug, 'subevent': subevent.pk, - }) + }), + edit_permission='event.subevents:write', )) for sei in subevent.var_overrides.values(): if sei.available_from: @@ -234,7 +255,8 @@ def timeline_for_event(event, subevent=None): 'event': event.slug, 'organizer': event.organizer.slug, 'subevent': subevent.pk, - }) + }), + edit_permission='event.subevents:write', )) if sei.available_until: tl.append(TimelineEvent( @@ -246,7 +268,8 @@ def timeline_for_event(event, subevent=None): 'event': event.slug, 'organizer': event.organizer.slug, 'subevent': subevent.pk, - }) + }), + edit_permission='event.subevents:write', )) for d in event.discounts.filter(Q(available_from__isnull=False) | Q(available_until__isnull=False)): @@ -259,7 +282,8 @@ def timeline_for_event(event, subevent=None): 'event': event.slug, 'organizer': event.organizer.slug, 'discount': d.pk, - }) + }), + edit_permission='event.items:write', )) if d.available_until: tl.append(TimelineEvent( @@ -270,7 +294,8 @@ def timeline_for_event(event, subevent=None): 'event': event.slug, 'organizer': event.organizer.slug, 'discount': d.pk, - }) + }), + edit_permission='event.items:write', )) for p in event.items.filter(Q(available_from__isnull=False) | Q(available_until__isnull=False)): @@ -283,7 +308,8 @@ def timeline_for_event(event, subevent=None): 'event': event.slug, 'organizer': event.organizer.slug, 'item': p.pk, - }) + '#id_available_from_0' + }) + '#id_available_from_0', + edit_permission='event.items:write', )) if p.available_until: tl.append(TimelineEvent( @@ -294,7 +320,8 @@ def timeline_for_event(event, subevent=None): 'event': event.slug, 'organizer': event.organizer.slug, 'item': p.pk, - }) + '#id_available_until_0' + }) + '#id_available_until_0', + edit_permission='event.items:write', )) for v in ItemVariation.objects.filter( @@ -313,7 +340,8 @@ def timeline_for_event(event, subevent=None): 'event': event.slug, 'organizer': event.organizer.slug, 'item': v.item.pk, - }) + '#tab-0-3-open' + }) + '#tab-0-3-open', + edit_permission='event.items:write', )) if v.available_until: tl.append(TimelineEvent( @@ -327,7 +355,8 @@ def timeline_for_event(event, subevent=None): 'event': event.slug, 'organizer': event.organizer.slug, 'item': v.item.pk, - }) + '#tab-0-3-open' + }) + '#tab-0-3-open', + edit_permission='event.items:write', )) pprovs = event.get_payment_providers() @@ -357,7 +386,8 @@ def timeline_for_event(event, subevent=None): 'event': event.slug, 'organizer': event.organizer.slug, 'provider': pprov.identifier, - }) + }), + edit_permission='event.settings.payment:write', )) availability_date = pprov.settings.get('_availability_date', as_type=RelativeDateWrapper) if availability_date: @@ -375,7 +405,8 @@ def timeline_for_event(event, subevent=None): 'event': event.slug, 'organizer': event.organizer.slug, 'provider': pprov.identifier, - }) + }), + edit_permission='event.settings.payment:write', )) for recv, resp in timeline_events.send(sender=event, subevent=subevent): diff --git a/src/pretix/control/context.py b/src/pretix/control/context.py index 4480a11481..f622560ae9 100644 --- a/src/pretix/control/context.py +++ b/src/pretix/control/context.py @@ -102,7 +102,7 @@ def _default_context(request): complain_testmode_orders = request.event.orders.filter(testmode=True).exists() request.event.cache.set('complain_testmode_orders', complain_testmode_orders, 30) ctx['complain_testmode_orders'] = complain_testmode_orders and request.user.has_event_permission( - request.organizer, request.event, 'can_view_orders', request=request + request.organizer, request.event, 'event.orders:read', request=request ) else: ctx['complain_testmode_orders'] = False diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index ca215a9ae0..d70a94eb08 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -62,6 +62,7 @@ from pretix.base.forms import ( ) from pretix.base.models import Event, Organizer, TaxRule, Team from pretix.base.models.event import EventFooterLink, EventMetaValue, SubEvent +from pretix.base.models.organizer import TeamQuerySet from pretix.base.models.tax import TAX_CODE_LISTS from pretix.base.reldate import RelativeDateField, RelativeDateTimeField from pretix.base.services.placeholders import FormPlaceholderMixin @@ -100,11 +101,12 @@ class EventWizardFoundationForm(forms.Form): def __init__(self, *args, **kwargs): self.user = kwargs.pop('user') self.session = kwargs.pop('session') + self.clone_from = kwargs.pop('clone_from') super().__init__(*args, **kwargs) qs = Organizer.objects.all() if not self.user.has_active_staff_session(self.session.session_key): qs = qs.filter( - id__in=self.user.teams.filter(can_create_events=True).values_list('organizer', flat=True) + id__in=self.user.teams.filter(TeamQuerySet.organizer_permission_q("organizer.events:create")).values_list('organizer', flat=True) ) self.fields['organizer'] = forms.ModelChoiceField( label=_("Organizer"), @@ -125,6 +127,16 @@ class EventWizardFoundationForm(forms.Form): self.fields['organizer'].initial = organizer self.fields['locales'].initial = organizer.settings.locales + def clean(self): + d = super().clean() + if d.get('organizer') and self.clone_from and not self.user.has_active_staff_session(self.session.session_key): + if not self.clone_from.allow_copy_data(d['organizer'], self.user): + raise ValidationError({ + "organizer": _("You do not have a sufficient level of access on the event you selected " + "to copy it to the desired organizer.") + }) + return d + class EventWizardBasicsForm(I18nModelForm): error_messages = { @@ -198,6 +210,7 @@ class EventWizardBasicsForm(I18nModelForm): self.has_subevents = kwargs.pop('has_subevents') self.user = kwargs.pop('user') self.session = kwargs.pop('session') + self.clone_from = kwargs.pop('clone_from') super().__init__(*args, **kwargs) if 'timezone' not in self.initial: self.initial['timezone'] = get_current_timezone_name() @@ -238,6 +251,16 @@ class EventWizardBasicsForm(I18nModelForm): 'check "{field}" above.').format(field=self.fields["no_taxes"].label) }) + if self.clone_from and not self.user.has_active_staff_session(self.session.session_key): + if data.get("team"): + source_event_perms = self.user.get_event_permission_set(self.organizer, self.clone_from) + team_perms = data["team"].event_permission_set(include_legacy=False) + if any(t not in source_event_perms for t in team_perms): + raise ValidationError({ + "team": _("You cannot choose a team that would give you more access than you have on " + "the event you are copying.") + }) + # change timezone zone = ZoneInfo(data.get('timezone')) data['date_from'] = self.reset_timezone(zone, data.get('date_from')) @@ -261,9 +284,12 @@ class EventWizardBasicsForm(I18nModelForm): @staticmethod def has_control_rights(user, organizer, session): + # It's mostly pointless to let a user create an event where they can't event change the name or create products, + # so we detect if the user has sufficient access for that on a new event. return user.teams.filter( - organizer=organizer, all_events=True, can_change_event_settings=True, can_change_items=True, - can_change_orders=True, can_change_vouchers=True + TeamQuerySet.event_permission_q("event.settings.general:write"), + organizer=organizer, + all_events=True, ).exists() or user.has_active_staff_session(session.session_key) @@ -293,18 +319,24 @@ class EventWizardCopyForm(forms.Form): if user.has_active_staff_session(session.session_key): return Event.objects.all() return Event.objects.filter( + # It is generally pointless to let users copy events when they would not even be able to change the + # date of the event they have just created. Therefore, even if it looks wrong, we're checking a write + # permission for read access. Q(organizer_id__in=user.teams.filter( - all_events=True, can_change_event_settings=True, can_change_items=True + TeamQuerySet.event_permission_q("event.settings.general:write"), + all_events=True, ).values_list('organizer', flat=True)) | Q(id__in=user.teams.filter( - can_change_event_settings=True, can_change_items=True + TeamQuerySet.event_permission_q("event.settings.general:write"), ).values_list('limit_events__id', flat=True)) ) def __init__(self, *args, **kwargs): - kwargs.pop('organizer') + self.organizer = kwargs.pop('organizer') kwargs.pop('locales') self.session = kwargs.pop('session') + self.team = kwargs.pop('team') kwargs.pop('has_subevents') + kwargs.pop('clone_from') self.user = kwargs.pop('user') super().__init__(*args, **kwargs) @@ -323,6 +355,24 @@ class EventWizardCopyForm(forms.Form): ) self.fields['copy_from_event'].widget.choices = self.fields['copy_from_event'].choices + def clean(self): + d = super().clean() + if d.get('copy_from_event') and not self.user.has_active_staff_session(self.session.session_key): + if not d['copy_from_event'].allow_copy_data(self.organizer, self.user): + raise ValidationError({ + "copy_from_event": _("You do not have a sufficient level of access on the event you selected " + "to copy it to the desired organizer.") + }) + if self.team: + source_event_perms = self.user.get_event_permission_set(self.organizer, d['copy_from_event']) + team_perms = self.team.event_permission_set(include_legacy=False) + if any(t not in source_event_perms for t in team_perms): + raise ValidationError({ + "copy_from_event": _("You cannot choose an event on which you have less access than the " + "team you selected in the previous step.") + }) + return d + class EventMetaValueForm(forms.ModelForm): diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index 7b3a06ed01..806c5e2ea2 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -1111,7 +1111,7 @@ class OrderPaymentSearchFilterForm(forms.Form): self.fields['organizer'].queryset = Organizer.objects.filter( pk__in=self.request.user.teams.values_list('organizer', flat=True) ) - self.fields['event'].queryset = self.request.user.get_events_with_permission('can_view_orders') + self.fields['event'].queryset = self.request.user.get_events_with_permission('event.orders:read') self.fields['provider'].choices += get_all_payment_providers() diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index 06743457bb..d0c48fc3d4 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -75,7 +75,10 @@ from pretix.base.models import ( ReusableMedium, SalesChannel, Team, ) from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider -from pretix.base.models.organizer import OrganizerFooterLink +from pretix.base.models.organizer import OrganizerFooterLink, TeamQuerySet +from pretix.base.permissions import ( + get_all_event_permission_groups, get_all_organizer_permission_groups, +) from pretix.base.settings import ( PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_organizer_settings, ) @@ -297,7 +300,34 @@ class MembershipTypeForm(I18nModelForm): fields = ['name', 'transferable', 'allow_parallel_usage', 'max_usages'] +class PermissionMultipleChoiceField(forms.MultipleChoiceField): + def to_python(self, value): + return { + k: True for k in super().to_python(value) if k + } + + def prepare_value(self, value): + if isinstance(value, dict): + return [k for k, v in value.items() if v is True] + return super().prepare_value(value) + + class TeamForm(forms.ModelForm): + def _make_label(self, p): + source = '{}' + params = [p.label] + + if p.plugin_name: + source = ' ' + source + params.insert(0, _("Provided by a plugin")) + + if p.help_text: + source += ' ' + params.append(p.help_text) + + source += ' ({})' + params.append(p.name) + return format_html(source, *params) def __init__(self, *args, **kwargs): organizer = kwargs.pop('organizer') @@ -305,16 +335,62 @@ class TeamForm(forms.ModelForm): self.fields['limit_events'].queryset = organizer.events.all().order_by( '-has_subevents', '-date_from' ) + self.event_field_names = [] + for pg in get_all_event_permission_groups().values(): + initial = ",".join(sorted( + a for a in pg.actions if self.instance and self.instance.limit_event_permissions.get(f"{pg.name}:{a}") + )) or "EMPTY" + self.fields[f'event_{pg.name}'] = forms.ChoiceField( + choices=[ + ( + ",".join(sorted(opt.actions)) or "EMPTY", + format_html( + '{label} ' + '', + label=opt.label, + help_text=opt.help_text, + ) if opt.help_text else opt.label, + ) + for opt in pg.options + ], + label=pg.label, + help_text=pg.help_text, + initial=initial, + widget=forms.RadioSelect, + ) + self.event_field_names.append(f'event_{pg.name}') + self.organizer_field_names = [] + for pg in get_all_organizer_permission_groups().values(): + initial = ",".join(sorted( + a for a in pg.actions if self.instance and self.instance.limit_organizer_permissions.get(f"{pg.name}:{a}") + )) or "EMPTY" + self.fields[f'organizer_{pg.name}'] = forms.ChoiceField( + choices=[ + ( + ",".join(sorted(opt.actions)) or "EMPTY", + format_html( + '{label} ' + '', + label=opt.label, + help_text=opt.help_text, + ) if opt.help_text else opt.label, + ) + for opt in pg.options + ], + label=pg.label, + help_text=pg.help_text, + initial=initial, + widget=forms.RadioSelect, + ) + self.organizer_field_names.append(f'organizer_{pg.name}') class Meta: model = Team - fields = ['name', 'require_2fa', 'all_events', 'limit_events', 'can_create_events', - 'can_change_teams', 'can_change_organizer_settings', - 'can_manage_gift_cards', 'can_manage_customers', - 'can_manage_reusable_media', - 'can_change_event_settings', 'can_change_items', - 'can_view_orders', 'can_change_orders', 'can_checkin_orders', - 'can_view_vouchers', 'can_change_vouchers'] + fields = ['name', 'require_2fa', 'all_events', 'limit_events', + 'all_event_permissions', + 'all_organizer_permissions',] widgets = { 'limit_events': forms.CheckboxSelectMultiple(attrs={ 'data-inverse-dependency': '#id_all_events', @@ -327,15 +403,57 @@ class TeamForm(forms.ModelForm): def clean(self): data = super().clean() - if self.instance.pk and not data['can_change_teams']: + + data['limit_event_permissions'] = {} + if not data['all_event_permissions']: + for pg in get_all_event_permission_groups().values(): + selected = data.get(f'event_{pg.name}', 'EMPTY') + if selected == "EMPTY": + selected_actions = [] + else: + selected_actions = selected.split(',') + for action in pg.actions: + if action in selected_actions: + data['limit_event_permissions'][f"{pg.name}:{action}"] = True + self.instance.limit_event_permissions = data['limit_event_permissions'] + + data['limit_organizer_permissions'] = {} + if not data['all_organizer_permissions']: + for pg in get_all_organizer_permission_groups().values(): + selected = data.get(f'organizer_{pg.name}', 'EMPTY') + if selected == "EMPTY": + selected_actions = [] + else: + selected_actions = selected.split(',') + for action in pg.actions: + if action in selected_actions: + data['limit_organizer_permissions'][f"{pg.name}:{action}"] = True + self.instance.limit_organizer_permissions = data['limit_organizer_permissions'] + + if self.instance.pk and not data['all_organizer_permissions'] and 'organizer.teams:write' not in data.get('limit_organizer_permissions', []): if not self.instance.organizer.teams.exclude(pk=self.instance.pk).filter( - can_change_teams=True, members__isnull=False + TeamQuerySet.organizer_permission_q("organizer.teams:write"), + members__isnull=False ).exists(): raise ValidationError(_('The changes could not be saved because there would be no remaining team with ' 'the permission to change teams and permissions.')) return data + @property + def changed_data_for_log(self): + r = {} + for k in self.changed_data: + if k == "limit_events": + r[k] = [e.id for e in getattr(self.instance, k).all()] + elif k.startswith("event_"): + r["limit_event_permissions"] = self.instance.limit_event_permissions + elif k.startswith("organizer_"): + r["limit_organizer_permissions"] = self.instance.limit_organizer_permissions + else: + r[k] = getattr(self.instance, k) + return r + class GateForm(forms.ModelForm): diff --git a/src/pretix/control/middleware.py b/src/pretix/control/middleware.py index 9b02152656..4c77671ab4 100644 --- a/src/pretix/control/middleware.py +++ b/src/pretix/control/middleware.py @@ -45,7 +45,9 @@ from django.utils.translation import gettext as _ from django_scopes import scope from pretix.base.models import Event, Organizer -from pretix.base.models.auth import SuperuserPermissionSet, User +from pretix.base.models.auth import ( + EventPermissionSet, OrganizerPermissionSet, SuperuserPermissionSet, User, +) from pretix.helpers.http import redirect_to_url from pretix.helpers.security import ( Session2FASetupRequired, SessionInvalid, SessionPasswordChangeRequired, @@ -170,7 +172,7 @@ class PermissionMiddleware: if request.user.has_active_staff_session(request.session.session_key): request.eventpermset = SuperuserPermissionSet() else: - request.eventpermset = request.user.get_event_permission_set(request.organizer, request.event) + request.eventpermset = EventPermissionSet(request.user.get_event_permission_set(request.organizer, request.event)) elif 'organizer' in url.kwargs: if url.kwargs['organizer'] == '-': # This is a hack that just takes the user to ANY organizer. It's useful to link to features in support @@ -192,7 +194,7 @@ class PermissionMiddleware: if request.user.has_active_staff_session(request.session.session_key): request.orgapermset = SuperuserPermissionSet() else: - request.orgapermset = request.user.get_organizer_permission_set(request.organizer) + request.orgapermset = OrganizerPermissionSet(request.user.get_organizer_permission_set(request.organizer)) with scope(organizer=getattr(request, 'organizer', None)): r = self.get_response(request) diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py index 7e1f9e0b28..51acabf28c 100644 --- a/src/pretix/control/navigation.py +++ b/src/pretix/control/navigation.py @@ -43,24 +43,29 @@ def get_event_navigation(request: HttpRequest): 'icon': 'dashboard', } ] - if 'can_change_event_settings' in request.eventpermset: - event_settings = [ - { - 'label': _('General'), - 'url': reverse('control:event.settings', kwargs={ - 'event': request.event.slug, - 'organizer': request.event.organizer.slug, - }), - 'active': url.url_name == 'event.settings', - }, - { - 'label': _('Payment'), - 'url': reverse('control:event.settings.payment', kwargs={ - 'event': request.event.slug, - 'organizer': request.event.organizer.slug, - }), - 'active': url.url_name in ('event.settings.payment', 'event.settings.payment.provider'), - }, + event_settings = [] + if "event.settings.general:write" in request.eventpermset: + event_settings.append({ + 'label': _('General'), + 'url': reverse('control:event.settings', kwargs={ + 'event': request.event.slug, + 'organizer': request.event.organizer.slug, + }), + 'active': url.url_name == 'event.settings', + }) + + if "event.settings.payment:write" in request.eventpermset or "event.settings.general:write" in request.eventpermset: + event_settings.append({ + 'label': _('Payment'), + 'url': reverse('control:event.settings.payment', kwargs={ + 'event': request.event.slug, + 'organizer': request.event.organizer.slug, + }), + 'active': url.url_name in ('event.settings.payment', 'event.settings.payment.provider'), + }) + + if "event.settings.general:write" in request.eventpermset: + event_settings += [ { 'label': _('Plugins'), 'url': reverse('control:event.settings.plugins', kwargs={ @@ -84,23 +89,31 @@ def get_event_navigation(request: HttpRequest): 'organizer': request.event.organizer.slug, }), 'active': url.url_name == 'event.settings.mail', - }, - { - 'label': _('Taxes'), - 'url': reverse('control:event.settings.tax', kwargs={ - 'event': request.event.slug, - 'organizer': request.event.organizer.slug, - }), - 'active': url.url_name.startswith('event.settings.tax'), - }, - { - 'label': _('Invoicing'), - 'url': reverse('control:event.settings.invoice', kwargs={ - 'event': request.event.slug, - 'organizer': request.event.organizer.slug, - }), - 'active': url.url_name == 'event.settings.invoice', - }, + } + ] + + if "event.settings.tax:write" in request.eventpermset or "event.settings.general:write" in request.eventpermset: + event_settings.append({ + 'label': _('Taxes'), + 'url': reverse('control:event.settings.tax', kwargs={ + 'event': request.event.slug, + 'organizer': request.event.organizer.slug, + }), + 'active': url.url_name.startswith('event.settings.tax'), + }) + + if "event.settings.invoicing:write" in request.eventpermset or "event.settings.general:write" in request.eventpermset: + event_settings.append({ + 'label': _('Invoicing'), + 'url': reverse('control:event.settings.invoice', kwargs={ + 'event': request.event.slug, + 'organizer': request.event.organizer.slug, + }), + 'active': url.url_name == 'event.settings.invoice', + }) + + if "event.settings.general:write" in request.eventpermset: + event_settings += [ { 'label': pgettext_lazy('action', 'Cancellation'), 'url': reverse('control:event.settings.cancel', kwargs={ @@ -118,88 +131,87 @@ def get_event_navigation(request: HttpRequest): 'active': url.url_name == 'event.settings.widget', }, ] + + # It would be better to allow plugins to handle the permission themselves, but for backwards compatibility + # we need to have it in the "if" statement event_settings += sorted( sum((list(a[1]) for a in nav_event_settings.send(request.event, request=request)), []), key=lambda r: r['label'] ) + if event_settings: nav.append({ 'label': _('Settings'), - 'url': reverse('control:event.settings', kwargs={ - 'event': request.event.slug, - 'organizer': request.event.organizer.slug, - }), + 'url': event_settings[0]["url"], 'active': False, 'icon': 'wrench', 'children': event_settings }) - if 'can_change_items' in request.eventpermset: - nav.append({ - 'label': _('Products'), - 'url': reverse('control:event.items', kwargs={ - 'event': request.event.slug, - 'organizer': request.event.organizer.slug, - }), - 'active': False, - 'icon': 'ticket', - 'children': [ - { - 'label': _('Products'), - 'url': reverse('control:event.items', kwargs={ - 'event': request.event.slug, - 'organizer': request.event.organizer.slug, - }), - 'active': url.url_name in ( - 'event.item', 'event.items.add', 'event.items') or "event.item." in url.url_name, - }, - { - 'label': _('Quotas'), - 'url': reverse('control:event.items.quotas', kwargs={ - 'event': request.event.slug, - 'organizer': request.event.organizer.slug, - }), - 'active': 'event.items.quota' in url.url_name, - }, - { - 'label': _('Categories'), - 'url': reverse('control:event.items.categories', kwargs={ - 'event': request.event.slug, - 'organizer': request.event.organizer.slug, - }), - 'active': 'event.items.categories' in url.url_name, - }, - { - 'label': _('Questions'), - 'url': reverse('control:event.items.questions', kwargs={ - 'event': request.event.slug, - 'organizer': request.event.organizer.slug, - }), - 'active': 'event.items.questions' in url.url_name, - }, - { - 'label': _('Discounts'), - 'url': reverse('control:event.items.discounts', kwargs={ - 'event': request.event.slug, - 'organizer': request.event.organizer.slug, - }), - 'active': 'event.items.discounts' in url.url_name, - }, - ] - }) - - if 'can_change_event_settings' in request.eventpermset: - if request.event.has_subevents: - nav.append({ - 'label': pgettext_lazy('subevent', 'Dates'), - 'url': reverse('control:event.subevents', kwargs={ + nav.append({ + 'label': _('Products'), + 'url': reverse('control:event.items', kwargs={ + 'event': request.event.slug, + 'organizer': request.event.organizer.slug, + }), + 'active': False, + 'icon': 'ticket', + 'children': [ + { + 'label': _('Products'), + 'url': reverse('control:event.items', kwargs={ 'event': request.event.slug, 'organizer': request.event.organizer.slug, }), - 'active': ('event.subevent' in url.url_name), - 'icon': 'calendar', - }) + 'active': url.url_name in ( + 'event.item', 'event.items.add', 'event.items') or "event.item." in url.url_name, + }, + { + 'label': _('Quotas'), + 'url': reverse('control:event.items.quotas', kwargs={ + 'event': request.event.slug, + 'organizer': request.event.organizer.slug, + }), + 'active': 'event.items.quota' in url.url_name, + }, + { + 'label': _('Categories'), + 'url': reverse('control:event.items.categories', kwargs={ + 'event': request.event.slug, + 'organizer': request.event.organizer.slug, + }), + 'active': 'event.items.categories' in url.url_name, + }, + { + 'label': _('Questions'), + 'url': reverse('control:event.items.questions', kwargs={ + 'event': request.event.slug, + 'organizer': request.event.organizer.slug, + }), + 'active': 'event.items.questions' in url.url_name, + }, + { + 'label': _('Discounts'), + 'url': reverse('control:event.items.discounts', kwargs={ + 'event': request.event.slug, + 'organizer': request.event.organizer.slug, + }), + 'active': 'event.items.discounts' in url.url_name, + }, + ] + }) - if 'can_view_orders' in request.eventpermset: + if request.event.has_subevents: + nav.append({ + 'label': pgettext_lazy('subevent', 'Dates'), + 'url': reverse('control:event.subevents', kwargs={ + 'event': request.event.slug, + 'organizer': request.event.organizer.slug, + }), + 'active': ('event.subevent' in url.url_name), + 'icon': 'calendar', + }) + + if 'event.orders:read' in request.eventpermset: children = [ { 'label': _('All orders'), @@ -242,7 +254,7 @@ def get_event_navigation(request: HttpRequest): 'active': 'event.orders.waitinglist' in url.url_name, }, ] - if 'can_change_orders' in request.eventpermset: + if 'event.orders:write' in request.eventpermset: children.append({ 'label': _('Import'), 'url': reverse('control:event.orders.import', kwargs={ @@ -261,8 +273,18 @@ def get_event_navigation(request: HttpRequest): 'icon': 'shopping-cart', 'children': children }) + else: + nav.append({ + 'label': _('Export'), + 'url': reverse('control:event.orders.export', kwargs={ + 'event': request.event.slug, + 'organizer': request.event.organizer.slug, + }), + 'active': 'event.orders.export' in url.url_name, + 'icon': 'download', + }) - if 'can_view_vouchers' in request.eventpermset: + if 'event.vouchers:read' in request.eventpermset: nav.append({ 'label': _('Vouchers'), 'url': reverse('control:event.vouchers', kwargs={ @@ -291,7 +313,7 @@ def get_event_navigation(request: HttpRequest): ] }) - if 'can_view_orders' in request.eventpermset: + if 'event.orders:read' in request.eventpermset or 'event.settings.general:write' in request.eventpermset: nav.append({ 'label': pgettext_lazy('navigation', 'Check-in'), 'url': reverse('control:event.orders.checkinlists', kwargs={ @@ -485,7 +507,7 @@ def get_organizer_navigation(request): 'icon': 'calendar', }, ] - if 'can_change_organizer_settings' in request.orgapermset: + if 'organizer.settings.general:write' in request.orgapermset: nav.append({ 'label': _('Settings'), 'url': reverse('control:organizer.edit', kwargs={ @@ -539,7 +561,7 @@ def get_organizer_navigation(request): ] }) - if 'can_change_teams' in request.orgapermset: + if 'organizer.teams:write' in request.orgapermset: nav.append({ 'label': _('Teams'), 'url': reverse('control:organizer.teams', kwargs={ @@ -549,7 +571,7 @@ def get_organizer_navigation(request): 'icon': 'group', }) - if 'can_manage_gift_cards' in request.orgapermset: + if 'organizer.giftcards:read' in request.orgapermset or 'organizer.giftcards:write' in request.orgapermset: children = [] children.append({ 'label': _('Gift cards'), @@ -559,7 +581,7 @@ def get_organizer_navigation(request): 'active': 'organizer.giftcard' in url.url_name and 'acceptance' not in url.url_name, 'children': children, }) - if 'can_change_organizer_settings' in request.orgapermset: + if 'organizer.settings.general:write' in request.orgapermset: children.append( { 'label': _('Acceptance'), @@ -580,7 +602,7 @@ def get_organizer_navigation(request): if request.organizer.settings.customer_accounts: children = [] - if 'can_manage_customers' in request.orgapermset: + if 'organizer.customers:read' in request.orgapermset or 'organizer.customers:write' in request.orgapermset: children.append( { 'label': _('Customers'), @@ -590,7 +612,7 @@ def get_organizer_navigation(request): 'active': 'organizer.customer' in url.url_name, } ) - if 'can_change_organizer_settings' in request.orgapermset: + if 'organizer.settings.general:write' in request.orgapermset: children.append( { 'label': _('Membership types'), @@ -629,16 +651,17 @@ def get_organizer_navigation(request): }) if request.organizer.settings.reusable_media_active: - nav.append({ - 'label': _('Reusable media'), - 'url': reverse('control:organizer.reusable_media', kwargs={ - 'organizer': request.organizer.slug - }), - 'icon': 'key', - 'active': 'organizer.reusable_medi' in url.url_name, - }) + if 'organizer.reusablemedia:read' in request.orgapermset or 'organizer.reusablemedia:write' in request.orgapermset: + nav.append({ + 'label': _('Reusable media'), + 'url': reverse('control:organizer.reusable_media', kwargs={ + 'organizer': request.organizer.slug + }), + 'icon': 'key', + 'active': 'organizer.reusable_medi' in url.url_name, + }) - if 'can_change_organizer_settings' in request.orgapermset: + if 'organizer.devices:read' in request.orgapermset or 'organizer.devices:write' in request.orgapermset: nav.append({ 'label': _('Devices'), 'url': reverse('control:organizer.devices', kwargs={ @@ -672,7 +695,7 @@ def get_organizer_navigation(request): 'icon': 'download', }) - if 'can_change_organizer_settings' in request.orgapermset: + if 'organizer.settings.general:write' in request.orgapermset: merge_in(nav, [{ 'parent': reverse('control:organizer.export', kwargs={ 'organizer': request.organizer.slug, @@ -684,6 +707,7 @@ def get_organizer_navigation(request): 'active': (url.url_name == 'organizer.datasync.failedjobs'), }]) + if 'organizer.outgoingmails:read' in request.orgapermset: nav.append({ 'label': _('Outgoing emails'), 'url': reverse('control:organizer.outgoingmails', kwargs={ diff --git a/src/pretix/control/permissions.py b/src/pretix/control/permissions.py index 059e2c9d3e..141b06f895 100644 --- a/src/pretix/control/permissions.py +++ b/src/pretix/control/permissions.py @@ -38,6 +38,9 @@ from django.core.exceptions import PermissionDenied from django.urls import reverse from django.utils.translation import gettext as _ +from pretix.base.permissions import ( + assert_valid_event_permission, assert_valid_organizer_permission, +) from pretix.helpers.http import redirect_to_url @@ -55,7 +58,9 @@ def event_permission_required(permission): """ if permission == 'can_change_settings': # Legacy support - permission = 'can_change_event_settings' + permission = 'event.settings.general:write' + + assert_valid_event_permission(permission) def decorator(function): def wrapper(request, *args, **kw): @@ -79,7 +84,7 @@ class EventPermissionRequiredMixin: This mixin is equivalent to the event_permission_required view decorator but is in a form suitable for class-based views. """ - permission = '' + permission = None # None means "any permission" @classmethod def as_view(cls, **initkwargs): @@ -92,9 +97,11 @@ def organizer_permission_required(permission): This view decorator rejects all requests with a 403 response which are not from users having the given permission for the event the request is associated with. """ - if permission == 'can_change_settings': + if permission in ('event.settings.general:write', 'can_change_settings', 'can_change_event_settings'): # Legacy support - permission = 'can_change_organizer_settings' + permission = 'organizer.settings.general:write' + + assert_valid_organizer_permission(permission) def decorator(function): def wrapper(request, *args, **kw): @@ -116,7 +123,7 @@ class OrganizerPermissionRequiredMixin: This mixin is equivalent to the organizer_permission_required view decorator but is in a form suitable for class-based views. """ - permission = '' + permission = None # None means "any permission" @classmethod def as_view(cls, **initkwargs): diff --git a/src/pretix/control/templates/pretixcontrol/checkin/index.html b/src/pretix/control/templates/pretixcontrol/checkin/index.html index 652a8819b0..3de6e73e2f 100644 --- a/src/pretix/control/templates/pretixcontrol/checkin/index.html +++ b/src/pretix/control/templates/pretixcontrol/checkin/index.html @@ -9,7 +9,7 @@ {% block content %}

{% blocktrans with name=checkinlist.name %}Check-in list: {{ name }}{% endblocktrans %} - {% if 'can_change_event_settings' in request.eventpermset %} + {% if 'event.settings.general:write' in request.eventpermset %} @@ -87,7 +87,7 @@ - {% if "can_change_orders" in request.eventpermset or "can_checkin_orders" in request.eventpermset %} + {% if "event.orders:write" in request.eventpermset or "event.orders:checkin" in request.eventpermset %} {% endif %} @@ -132,7 +132,7 @@ {% for e in entries %} - {% if "can_change_orders" in request.eventpermset or "can_checkin_orders" in request.eventpermset %} + {% if "event.orders:write" in request.eventpermset or "event.orders:checkin" in request.eventpermset %} {% endif %} @@ -207,7 +207,7 @@ {% else %}

- {% if "can_change_event_settings" in request.eventpermset %} + {% if "event.settings.general:write" in request.eventpermset %} {% trans "Create a new check-in list" %} {% endif %} - {% if can_change_organizer_settings %} + {% if link_device_settings %} {% trans "Connected devices" %} {% endif %} - {% if "can_change_orders" in request.eventpermset %} + {% if "event.settings.general:write" in request.eventpermset and "event.orders:write" in request.eventpermset %} @@ -100,7 +100,9 @@ - {% trans "Checked in" %} + {% if "event.orders:read" in request.eventpermset %} + {% trans "Checked in" %} + {% endif %} {% if request.event.has_subevents %} {% trans "Date" context "subevent" %} @@ -119,18 +121,20 @@ {{ cl.name }} - -

-
-
+ {% if "event.orders:read" in request.eventpermset %} + +
+
+
+
+
+
+ {{ cl.checkin_count|default_if_none:"0" }} / + {{ cl.position_count|default_if_none:"0" }}
-
- {{ cl.checkin_count|default_if_none:"0" }} / - {{ cl.position_count|default_if_none:"0" }} -
-
- + + {% endif %} {% if request.event.has_subevents %} {% if cl.subevent %} @@ -156,16 +160,18 @@ {% endif %} - - {% if "can_change_event_settings" in request.eventpermset %} + {% if "event.orders:read" in request.eventpermset %} + + + {% endif %} + {% if "event.settings.general:write" in request.eventpermset %} - {% blocktrans with name=checkinlist.name %}Check-in list: {{ name }}{% endblocktrans %} - {% if 'can_change_event_settings' in request.eventpermset %} + {% if 'event.settings.general:write' in request.eventpermset %} diff --git a/src/pretix/control/templates/pretixcontrol/datasync/control_order_info.html b/src/pretix/control/templates/pretixcontrol/datasync/control_order_info.html index 1561437332..98bc6f4cad 100644 --- a/src/pretix/control/templates/pretixcontrol/datasync/control_order_info.html +++ b/src/pretix/control/templates/pretixcontrol/datasync/control_order_info.html @@ -11,18 +11,20 @@
    {% for identifier, display_name, pending, objects in providers %}
  • -
    - {% csrf_token %} - {% if pending %} - {% if pending.not_before > now or pending.need_manual_retry %} - + {% if "event.orders:write" in request.eventpermset %} + + {% csrf_token %} + {% if pending %} + {% if pending.not_before > now or pending.need_manual_retry %} + + {% endif %} + + {% else %} + + {% endif %} - - {% else %} - - - {% endif %} -
    + + {% endif %}

    {{ display_name }}

    {% if pending %}

    diff --git a/src/pretix/control/templates/pretixcontrol/event/dangerzone.html b/src/pretix/control/templates/pretixcontrol/event/dangerzone.html index 24d7a32599..dc08123fc9 100644 --- a/src/pretix/control/templates/pretixcontrol/event/dangerzone.html +++ b/src/pretix/control/templates/pretixcontrol/event/dangerzone.html @@ -40,12 +40,16 @@ this option. {% endblocktrans %}

-
- - - {% trans "Cancel event" %} - +
+ {% if "event:cancel" in request.eventpermset %} + + + {% trans "Cancel event" %} + + {% else %} + {% trans "No permission" %} + {% endif %}
diff --git a/src/pretix/control/templates/pretixcontrol/event/fragment_timeline.html b/src/pretix/control/templates/pretixcontrol/event/fragment_timeline.html index 3877578097..71448e13bf 100644 --- a/src/pretix/control/templates/pretixcontrol/event/fragment_timeline.html +++ b/src/pretix/control/templates/pretixcontrol/event/fragment_timeline.html @@ -19,7 +19,7 @@ {{ e.entry.description }} - {% if e.entry.edit_url %} + {% if e.entry.edit_url and e.entry.edit_permission in request.eventpermset %}   diff --git a/src/pretix/control/templates/pretixcontrol/event/index.html b/src/pretix/control/templates/pretixcontrol/event/index.html index 78aeb4f9a4..f1fd140be9 100644 --- a/src/pretix/control/templates/pretixcontrol/event/index.html +++ b/src/pretix/control/templates/pretixcontrol/event/index.html @@ -155,22 +155,24 @@ - + {% endif %} {% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/event/invoicing.html b/src/pretix/control/templates/pretixcontrol/event/invoicing.html index 0c51a8e185..f3e843ae44 100644 --- a/src/pretix/control/templates/pretixcontrol/event/invoicing.html +++ b/src/pretix/control/templates/pretixcontrol/event/invoicing.html @@ -165,13 +165,15 @@

-
- - -
+ {% if "event.settings.invoicing:write" in request.eventpermset %} +
+ + +
+ {% endif %} {% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/event/payment.html b/src/pretix/control/templates/pretixcontrol/event/payment.html index c1746a37d2..12e89a8649 100644 --- a/src/pretix/control/templates/pretixcontrol/event/payment.html +++ b/src/pretix/control/templates/pretixcontrol/event/payment.html @@ -41,14 +41,17 @@ {% endfor %} - - - {% trans "Settings" %} - + {% if "event.settings.payment:write" in request.eventpermset %} + + + {% trans "Settings" %} + + {% endif %} {% endfor %} + {% if "event.settings.general:write" in request.eventpermset %}
@@ -58,6 +61,7 @@ + {% endif %} @@ -83,10 +87,12 @@ {% bootstrap_field form.payment_explanation layout="control" %} -
- -
+ {% if "event.settings.payment:write" in request.eventpermset %} +
+ +
+ {% endif %} {% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/event/tax.html b/src/pretix/control/templates/pretixcontrol/event/tax.html index 9a58c75405..9700836c98 100644 --- a/src/pretix/control/templates/pretixcontrol/event/tax.html +++ b/src/pretix/control/templates/pretixcontrol/event/tax.html @@ -23,8 +23,10 @@ {% endblocktrans %}

- {% trans "Create a new tax rule" %} + {% if "event.settings.tax:write" in request.eventpermset %} + {% trans "Create a new tax rule" %} + {% endif %} {% else %}
@@ -42,10 +44,14 @@ {% for tr in taxrules %} - - {{ tr.internal_name|default:tr.name }} - + {% if "event.settings.tax:write" in request.eventpermset %} + + {{ tr.internal_name|default:tr.name }} + + {% else %} + {{ tr.internal_name|default:tr.name }} + {% endif %} {% if tr.default %} @@ -53,7 +59,7 @@ {% trans "Default" %} - {% else %} + {% elif "event.settings.tax:write" in request.eventpermset %}
{% csrf_token %} @@ -83,10 +89,12 @@ {% endif %} - - + {% if "event.settings.tax:write" in request.eventpermset %} + + + {% endif %} {% endfor %} @@ -94,9 +102,11 @@ - {% trans "Create a new tax rule" %} - + {% if "event.settings.tax:write" in request.eventpermset %} + {% trans "Create a new tax rule" %} + + {% endif %} @@ -111,10 +121,12 @@ {% bootstrap_field form.tax_rounding layout="control" %} {% bootstrap_field form.display_net_prices layout="control" %} -
- -
+ {% if "event.settings.tax:write" in request.eventpermset %} +
+ +
+ {% endif %}
{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/items/categories.html b/src/pretix/control/templates/pretixcontrol/items/categories.html index bb0fccd765..f2dbab638f 100644 --- a/src/pretix/control/templates/pretixcontrol/items/categories.html +++ b/src/pretix/control/templates/pretixcontrol/items/categories.html @@ -16,14 +16,18 @@ {% endblocktrans %}

- {% trans "Create a new category" %} + {% if 'event.items:write' in request.eventpermset %} + {% trans "Create a new category" %} + {% endif %}
{% else %} -

- {% trans "Create a new category" %} - -

+ {% if 'event.items:write' in request.eventpermset %} +

+ {% trans "Create a new category" %} + +

+ {% endif %}
{% csrf_token %}
@@ -39,7 +43,11 @@ {% for c in categories %} - {{ c.internal_name|default:c.name }} + {% if 'event.items:write' in request.eventpermset %} + {{ c.internal_name|default:c.name }} + {% else %} + {{ c.internal_name|default:c.name }} + {% endif %}
#{{ c.pk }} @@ -49,15 +57,17 @@ {{ c.get_category_type_display }} - - - - - - - - + {% if 'event.items:write' in request.eventpermset %} + + + + + + + + + {% endif %} {% endfor %} diff --git a/src/pretix/control/templates/pretixcontrol/items/discounts.html b/src/pretix/control/templates/pretixcontrol/items/discounts.html index 43a2b5e0a2..28a587a077 100644 --- a/src/pretix/control/templates/pretixcontrol/items/discounts.html +++ b/src/pretix/control/templates/pretixcontrol/items/discounts.html @@ -39,15 +39,19 @@ {% endblocktrans %}

- {% trans "Create a new discount" %} + {% if 'event.items:write' in request.eventpermset %} + {% trans "Create a new discount" %} + {% endif %}
{% else %} -

- {% trans "Create a new discount" %} - -

+ {% if 'event.items:write' in request.eventpermset %} +

+ {% trans "Create a new discount" %} + +

+ {% endif %} {% csrf_token %}
@@ -70,8 +74,12 @@ {% else %} {% endif %} - + {% if 'event.items:write' in request.eventpermset %} + {{ d.internal_name }} + {% else %} + {{ d.internal_name }} + {% endif %} {% if d.active %} {% else %} @@ -134,23 +142,25 @@ {% endif %} - - - - - - - - + {% if 'event.items:write' in request.eventpermset %} + + + + + + + + + {% endif %} {% endfor %} diff --git a/src/pretix/control/templates/pretixcontrol/items/index.html b/src/pretix/control/templates/pretixcontrol/items/index.html index a803c64953..08d39ffa5f 100644 --- a/src/pretix/control/templates/pretixcontrol/items/index.html +++ b/src/pretix/control/templates/pretixcontrol/items/index.html @@ -21,14 +21,18 @@ {% endblocktrans %}

- {% trans "Create a new product" %} + {% if 'event.items:write' in request.eventpermset %} + {% trans "Create a new product" %} + {% endif %}
{% else %} -

- {% trans "Create a new product" %} -

+ {% if 'event.items:write' in request.eventpermset %} +

+ {% trans "Create a new product" %} +

+ {% endif %} {% csrf_token %}
@@ -51,7 +55,9 @@ {{ c.internal_name|default:c.name }}{% if c.category_type != "normal" %} ({{ c.get_category_type_display }}){% endif %} - + {% if 'event.items:write' in request.eventpermset %} + + {% endif %} {% endif %} @@ -62,7 +68,11 @@ {% if not i.active %}{% endif %} - {{ i }} + {% if 'event.items:write' in request.eventpermset %} + {{ i }} + {% else %} + {{ i }} + {% endif %} {% if not i.active %}{% endif %}
@@ -158,12 +168,14 @@ {% endif %} - - - - - - + {% if 'event.items:write' in request.eventpermset %} + + + + + + + {% endif %} {% endfor %} diff --git a/src/pretix/control/templates/pretixcontrol/items/question.html b/src/pretix/control/templates/pretixcontrol/items/question.html index 3be5f25a69..35e531c0a0 100644 --- a/src/pretix/control/templates/pretixcontrol/items/question.html +++ b/src/pretix/control/templates/pretixcontrol/items/question.html @@ -7,45 +7,57 @@ {% block inside %}

{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %} - - - {% trans "Edit question" %} - + {% if 'event.items:write' in request.eventpermset %} + + + {% trans "Edit question" %} + + {% endif %}

-
-
-

{% trans "Filter" %}

+ {% if 'event.orders:read' in request.eventpermset %} +
+
+

{% trans "Filter" %}

+
+ +
+
+ {% bootstrap_field form.status %} +
+
+ {% bootstrap_field form.item %} +
+ {% if has_subevents %} +
+ {% bootstrap_field form.subevent %} +
+
+ {% bootstrap_field form.date_range %} +
+ {% endif %} +
+
+ +
+
-
-
-
- {% bootstrap_field form.status %} -
-
- {% bootstrap_field form.item %} -
- {% if has_subevents %} -
- {% bootstrap_field form.subevent %} -
-
- {% bootstrap_field form.date_range %} -
- {% endif %} -
-
- -
-
-
+ {% endif %}
- {% if not stats %} + {% if 'event.orders:read' not in request.eventpermset %} +
+

+ {% blocktrans trimmed %} + No permission to view answers. + {% endblocktrans %} +

+
+ {% elif not stats %}

{% blocktrans trimmed %} diff --git a/src/pretix/control/templates/pretixcontrol/items/questions.html b/src/pretix/control/templates/pretixcontrol/items/questions.html index 4b028657b3..ff5924517a 100644 --- a/src/pretix/control/templates/pretixcontrol/items/questions.html +++ b/src/pretix/control/templates/pretixcontrol/items/questions.html @@ -10,10 +10,12 @@ {% endblocktrans %}

{% csrf_token %} -

- {% trans "Create a new question" %} - -

+ {% if 'event.items:write' in request.eventpermset %} +

+ {% trans "Create a new question" %} + +

+ {% endif %}
@@ -24,7 +26,9 @@ - + {% if 'event.items:write' in request.eventpermset %} + + {% endif %} @@ -79,16 +83,22 @@ {% trans "All personalized products" %} {% endif %} - + {% if 'event.items:write' in request.eventpermset %} + + {% endif %} diff --git a/src/pretix/control/templates/pretixcontrol/items/quota.html b/src/pretix/control/templates/pretixcontrol/items/quota.html index 0af4b7395f..1fbc71c36a 100644 --- a/src/pretix/control/templates/pretixcontrol/items/quota.html +++ b/src/pretix/control/templates/pretixcontrol/items/quota.html @@ -7,7 +7,7 @@ {% block inside %}

{% blocktrans with name=quota.name %}Quota: {{ name }}{% endblocktrans %} - {% if 'can_change_items' in request.eventpermset %} + {% if 'event.items:write' in request.eventpermset %} diff --git a/src/pretix/control/templates/pretixcontrol/items/quotas.html b/src/pretix/control/templates/pretixcontrol/items/quotas.html index c6509296fa..5e2729bfca 100644 --- a/src/pretix/control/templates/pretixcontrol/items/quotas.html +++ b/src/pretix/control/templates/pretixcontrol/items/quotas.html @@ -30,14 +30,18 @@ {% endif %}

-
{% trans "Create a new quota" %} + {% if 'event.items:write' in request.eventpermset %} + {% trans "Create a new quota" %} + {% endif %} {% else %} -

- {% trans "Create a new quota" %} - -

+ {% if 'event.items:write' in request.eventpermset %} +

+ {% trans "Create a new quota" %} + +

+ {% endif %}

{% trans "Products" %}
- + {% if q.pk %} - - + {% if 'event.items:write' in request.eventpermset %} + + + {% endif %} {% else %} - + {% if 'event.settings.general:write' in request.eventpermset %} + + {% endif %} {% endif %}
@@ -91,12 +95,14 @@ {% endfor %} diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 9ae065d0ad..1d3fa472df 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -26,7 +26,7 @@ {% endif %} {% include "pretixcontrol/orders/fragment_order_status.html" with order=order class="pull-right flip" %} - {% if 'can_change_orders' in request.eventpermset %} + {% if 'event.orders:write' in request.eventpermset %} {% csrf_token %} @@ -193,7 +193,7 @@
{% trans "Order locale" %}
{{ display_locale }} - {% if "can_change_orders" in request.eventpermset %} + {% if "event.orders:write" in request.eventpermset %} @@ -220,7 +220,7 @@ {{ order.customer.identifier }} – {{ order.customer.email }} {% endif %} - {% if "can_change_orders" in request.eventpermset %} + {% if "event.orders:write" in request.eventpermset %} @@ -233,7 +233,7 @@ {% if order.email and order.email_known_to_work %} {% endif %} - {% if "can_change_orders" in request.eventpermset %} + {% if "event.orders:write" in request.eventpermset %} @@ -257,7 +257,7 @@
{% trans "Phone number" %}
{{ order.phone|default_if_none:""|phone_format }} - {% if "can_change_orders" in request.eventpermset %} + {% if "event.orders:write" in request.eventpermset %} @@ -319,7 +319,7 @@ {% endif %} - {% if i.transmission_status != "inflight" %} + {% if i.transmission_status != "inflight" and "event.orders:write" in request.eventpermset %} {% csrf_token %} @@ -334,7 +334,7 @@ {% endif %} {% if not i.canceled %} - {% if i.regenerate_allowed %} + {% if i.regenerate_allowed and "event.orders:write" in request.eventpermset %}
{% csrf_token %} @@ -344,7 +344,7 @@ {% endif %} - {% if not i.is_cancellation %} + {% if not i.is_cancellation and "event.orders:write" in request.eventpermset %}
{% csrf_token %} @@ -371,7 +371,7 @@
{% endif %} {% endfor %} - {% if can_generate_invoice and 'can_change_orders' in request.eventpermset %} + {% if can_generate_invoice and 'event.orders:write' in request.eventpermset %}
@@ -382,7 +382,7 @@ {% endif %}
- {% elif can_generate_invoice and 'can_change_orders' in request.eventpermset %} + {% elif can_generate_invoice and 'event.orders:write' in request.eventpermset %}
{% trans "Invoices" %}
- {% if 'can_change_orders' in request.eventpermset %} + {% if 'event.orders:write' in request.eventpermset %} {% trans "Change answers" %} @@ -893,7 +893,7 @@ {% endfor %}
{% if q.size == None %}Unlimited{% else %}{{ q.size }}{% endif %} {% include "pretixcontrol/items/fragment_quota_availability.html" with availability=q.cached_avail closed=q.closed %} - - - - - + {% if 'event.items:write' in request.eventpermset %} + + + + + + {% endif %}
- {% if order.payment_refund_sum > 0 and "can_change_orders" in request.eventpermset %} + {% if order.payment_refund_sum > 0 and "event.orders:write" in request.eventpermset %} {% trans "Create a refund" %} @@ -1012,7 +1012,7 @@
+ {% empty %} +

+ {% trans "There are no exporters available for you." %} +

{% endfor %} {% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/orders/export_form.html b/src/pretix/control/templates/pretixcontrol/orders/export_form.html index e2ed55aedb..38975624a3 100644 --- a/src/pretix/control/templates/pretixcontrol/orders/export_form.html +++ b/src/pretix/control/templates/pretixcontrol/orders/export_form.html @@ -39,16 +39,18 @@ {% if schedule_form %} {% include "pretixcontrol/orders/fragment_export_schedule_form.html" %} -
- -
+ {% if not no_save %} +
+ +
+ {% endif %} {% else %}
@@ -300,14 +306,18 @@ {% for gc in gift_cards %} - - {{ gc.secret }} - {% if gc.testmode %} - {% trans "TEST MODE" %} - {% endif %} - {% if gc.expired %} - {% trans "Expired" %} - {% endif %} + {% if "organizer.giftcards:read" in request.orgapermset %} + + {{ gc.secret }} + {% else %} + {{ gc.secret|slice:":3" }}… + {% endif %} + {% if gc.testmode %} + {% trans "TEST MODE" %} + {% endif %} + {% if gc.expired %} + {% trans "Expired" %} + {% endif %} {{ gc.issuance|date:"SHORT_DATETIME_FORMAT" }} {% if gc.expires %}{{ gc.expires|date:"SHORT_DATETIME_FORMAT" }}{% endif %} @@ -316,10 +326,12 @@

{{ gc.value|money:gc.currency }}

- - - + {% if "organizer.giftcards:read" in request.orgapermset %} + + + + {% endif %} {% endfor %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/customers.html b/src/pretix/control/templates/pretixcontrol/organizers/customers.html index 3bba675fcb..a858ea1a43 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/customers.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/customers.html @@ -15,8 +15,10 @@ No customer accounts have been created yet. {% endblocktrans %}

- {% trans "Create a new customer" %} + {% if "organizer.customers:write" in request.orgapermset %} + {% trans "Create a new customer" %} + {% endif %}
{% else %}
@@ -43,10 +45,12 @@
-

- {% trans "Create a new customer" %} -

+ {% if "organizer.customers:write" in request.orgapermset %} +

+ {% trans "Create a new customer" %} +

+ {% endif %}
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/detail.html b/src/pretix/control/templates/pretixcontrol/organizers/detail.html index a1315c8de1..e006973cc8 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/detail.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/detail.html @@ -7,7 +7,7 @@ {% blocktrans with name=request.organizer.name %}Organizer: {{ name }}{% endblocktrans %} {% if events|length == 0 and not filter_form.filtered %} - {% if "can_create_events" in request.orgapermset %} + {% if "organizer.events:create" in request.orgapermset %}

@@ -51,7 +51,7 @@ - {% if "can_create_events" in request.orgapermset %} + {% if "organizer.events:create" in request.orgapermset %}

@@ -147,7 +147,7 @@ data-toggle="tooltip"> - {% if "can_create_events" in request.orgapermset %} + {% if "organizer.events:create" in request.orgapermset %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/devices.html b/src/pretix/control/templates/pretixcontrol/organizers/devices.html index 0274d2e4c8..7a0d96b47d 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/devices.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/devices.html @@ -51,10 +51,12 @@ -

- {% trans "Connect a device" %} -

+ {% if "organizer.devices:write" in request.orgapermset %} +

+ {% trans "Connect a device" %} +

+ {% endif %} {% csrf_token %} {% for field in filter_form %} @@ -64,10 +66,12 @@
- + {% if "organizer.devices:write" in request.orgapermset %} + + {% endif %} {% for d in devices %} - + {% if "organizer.devices:write" in request.orgapermset %} + + {% endif %} @@ -158,15 +164,17 @@ {% endif %} {% endfor %}
- - + + {% trans "Device ID" %} @@ -105,12 +109,14 @@
- - + + {{ d.device_id }} - {% if not d.initialized %} - - {% trans "Connect" %} - {% endif %} - {% if not d.initialized or d.api_token %} - - {% trans "Revoke access" %} + {% if "organizer.devices:write" in request.orgapermset %} + {% if not d.initialized %} + + {% trans "Connect" %} + {% endif %} + {% if not d.initialized or d.api_token %} + + {% trans "Revoke access" %} + {% endif %} {% endif %} {% if d.initialized %} {% endif %} - + {% if "organizer.devices:write" in request.orgapermset %} + + {% endif %}
-
- -
+ {% if "organizer.devices:write" in request.orgapermset %} +
+ +
+ {% endif %} {% include "pretixcontrol/pagination.html" %} {% endif %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/export.html b/src/pretix/control/templates/pretixcontrol/organizers/export.html index 59c8ab4cba..2dac33d3ea 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/export.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/export.html @@ -34,7 +34,7 @@ {% if s.export_verbose_name == "?" %} - {% trans "Exporter not found" %} + {% trans "Exporter not found or no permission" %} {% elif s.error_counter >= 5 %} @@ -115,5 +115,9 @@ {% endfor %}
+ {% empty %} +

+ {% trans "There are no exporters available for you." %} +

{% endfor %} {% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/export_form.html b/src/pretix/control/templates/pretixcontrol/organizers/export_form.html index 7e4de3be13..a2f925ecf7 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/export_form.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/export_form.html @@ -40,16 +40,18 @@ {% if schedule_form %} {% include "pretixcontrol/orders/fragment_export_schedule_form.html" %} -
- -
+ {% if not no_save %} +
+ +
+ {% endif %} {% else %}
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html b/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html index 186c01dc18..26de04f9d1 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html @@ -15,10 +15,11 @@ or you can manually issue gift cards. {% endblocktrans %}

- - {% trans "Manually issue a gift card" %} - + {% if "organizer.giftcards:write" in request.orgapermset %} + {% trans "Manually issue a gift card" %} + + {% endif %}
{% else %}
@@ -45,10 +46,12 @@
-

- {% trans "Manually issue a gift card" %} -

+ {% if "organizer.giftcards:write" in request.orgapermset %} +

+ {% trans "Manually issue a gift card" %} +

+ {% endif %}
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/reusable_media.html b/src/pretix/control/templates/pretixcontrol/organizers/reusable_media.html index f481e01cf4..9b96fc8af7 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/reusable_media.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/reusable_media.html @@ -15,8 +15,10 @@ No media have been created yet. {% endblocktrans %}

- {% trans "Create a new medium" %} + {% if "organizer.reusablemedia:write" in request.orgapermset %} + {% trans "Create a new medium" %} + {% endif %} {% else %}
@@ -40,10 +42,12 @@
-

- {% trans "Create a new medium" %} -

+ {% if "organizer.reusablemedia:write" in request.orgapermset %} +

+ {% trans "Create a new medium" %} +

+ {% endif %}
@@ -77,9 +81,13 @@ {% if m.customer %} - + {% if "organizer.customers:read" in request.orgapermset %} + + {{ m.customer }} + + {% else %} {{ m.customer }} - + {% endif %} {% endif %} {% if m.linked_orderposition %} @@ -92,8 +100,12 @@ {% if m.linked_giftcard %} - - {{ m.linked_giftcard.secret }} + {% if "organizer.giftcards:read" in request.orgapermset %} + + {{ m.linked_giftcard.secret }} + {% else %} + {{ m.linked_giftcard.secret|slice:":3" }}… + {% endif %} {% endif %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/reusable_medium.html b/src/pretix/control/templates/pretixcontrol/organizers/reusable_medium.html index 66ea2d2804..f53ebbf0c2 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/reusable_medium.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/reusable_medium.html @@ -22,60 +22,68 @@
-
- {% csrf_token %} -
-
{% trans "Media type" context "reusable_media" %}
-
{{ medium.get_type_display }}
-
{% trans "Identifier" context "reusable_media" %}
-
{{ medium.identifier }}
-
{% trans "Status" %}
-
- {% if not medium.active %} - {% trans "disabled" %} - {% elif medium.is_expired %} - {% trans "expired" %} - {% else %} - {% trans "active" %} - {% endif %} -
-
{% trans "Connections" context "reusable_media" %}
-
- {% if medium.customer %} - - +
+
{% trans "Media type" context "reusable_media" %}
+
{{ medium.get_type_display }}
+
{% trans "Identifier" context "reusable_media" %}
+
{{ medium.identifier }}
+
{% trans "Status" %}
+
+ {% if not medium.active %} + {% trans "disabled" %} + {% elif medium.is_expired %} + {% trans "expired" %} + {% else %} + {% trans "active" %} + {% endif %} +
+
{% trans "Connections" context "reusable_media" %}
+
+ {% if medium.customer %} + + + {% if "organizer.customers:read" in request.orgapermset %} {{ medium.customer }} - + {% else %} + {{ medium.customer }} {% endif %} - {% if medium.linked_orderposition %} - - - - {{ medium.linked_orderposition.order.code }}-{{ medium.linked_orderposition.positionid }} - - {% endif %} - {% if medium.linked_giftcard %} - - - - {{ medium.linked_giftcard.secret }} - - {% endif %} -
- {% if medium.notes %} -
{% trans "Notes" %}
-
{{ medium.notes }}
+ {% endif %} -
- - + {% if medium.linked_orderposition %} + + + + {{ medium.linked_orderposition.order.code }}-{{ medium.linked_orderposition.positionid }} + + {% endif %} + {% if medium.linked_giftcard %} + + + {% if "organizer.giftcards:read" in request.orgapermset %} + + {{ medium.linked_giftcard.secret }} + + {% else %} + {{ medium.linked_giftcard.secret|slice:":3" }}… + {% endif %} + + {% endif %} +
+ {% if medium.notes %} +
{% trans "Notes" %}
+
{{ medium.notes }}
+ {% endif %} +
+ {% if "organizer.reusablemedia:write" in request.orgapermset %} + + {% endif %}
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/team_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/team_edit.html index a5bcb8bd2b..40693af31c 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/team_edit.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/team_edit.html @@ -1,6 +1,7 @@ {% extends "pretixcontrol/organizers/base.html" %} {% load i18n %} {% load bootstrap3 %} +{% load getitem %} {% block inner %} {% if team %}

{% trans "Team:" %} {{ team.name }}

@@ -22,25 +23,24 @@
{% trans "Organizer permissions" %} - {% bootstrap_field form.can_create_events layout="control" %} - {% bootstrap_field form.can_manage_gift_cards layout="control" %} - {% bootstrap_field form.can_manage_customers layout="control" %} - {% bootstrap_field form.can_manage_reusable_media layout="control" %} - {% bootstrap_field form.can_change_teams layout="control" %} - {% bootstrap_field form.can_change_organizer_settings layout="control" %} + {% bootstrap_field form.all_organizer_permissions layout="control" %} +
+ {% for f in form.organizer_field_names %} + {% bootstrap_field form|getitem:f layout="control" %} + {% endfor %} +
{% trans "Event permissions" %} {% bootstrap_field form.all_events layout="control" %} {% bootstrap_field form.limit_events layout="control" %} - {% bootstrap_field form.can_change_event_settings layout="control" %} - {% bootstrap_field form.can_change_items layout="control" %} - {% bootstrap_field form.can_view_orders layout="control" %} - {% bootstrap_field form.can_change_orders layout="control" %} - {% bootstrap_field form.can_checkin_orders layout="control" %} - {% bootstrap_field form.can_view_vouchers layout="control" %} - {% bootstrap_field form.can_change_vouchers layout="control" %} + {% bootstrap_field form.all_event_permissions layout="control" %} +
+ {% for f in form.event_field_names %} + {% bootstrap_field form|getitem:f layout="control" %} + {% endfor %} +
{% else %}
@@ -65,7 +67,7 @@
- {% if "can_change_event_settings" in request.eventpermset %} + {% if "event.subevents:write" in request.eventpermset %}

@@ -84,11 +86,13 @@

- + {% if "event.subevents:write" in request.eventpermset %} + + {% endif %} @@ -107,7 +111,7 @@ - {% if "can_change_event_settings" in request.eventpermset and page_obj.paginator.num_pages > 1 %} + {% if "event.subevents:write" in request.eventpermset and page_obj.paginator.num_pages > 1 %} {% for s in subevents %} - + + {% endif %} {% endfor %}
- {% if "can_change_event_settings" in request.eventpermset %} - - {% endif %} - + {% if "event.subevents:write" in request.eventpermset %} + + {% endif %} + {% trans "Name" %}
- {% if "can_change_event_settings" in request.eventpermset %} + {% if "event.subevents:write" in request.eventpermset %} + - {% endif %} - {{ s.name }}
@@ -173,35 +177,39 @@ {% endif %}
- + {% if "event.orders:read" in request.eventpermset %} + + {% endif %} - - - + {% if "event.subevents:write" in request.eventpermset %} + + + + {% endif %}
- {% if "can_change_event_settings" in request.eventpermset %} + {% if "event.subevents:write" in request.eventpermset %}
- {% if "can_change_vouchers" in request.eventpermset %} + {% if "event.vouchers:write" in request.eventpermset %} - {% if "can_change_vouchers" in request.eventpermset %} + {% if "event.vouchers:write" in request.eventpermset %}
- + {% if 'event.orders:write' in request.eventpermset %} + {% if not e.voucher %} + + - - - + + + - - {% else %} - - - + + {% else %} + + + + {% endif %} {% endif %} @@ -291,7 +296,7 @@
- {% if "can_change_orders" in request.eventpermset %} + {% if "event.orders:write" in request.eventpermset %}
{% else %}

- {% if "can_change_event_settings" in request.eventpermset %} + {% if "event.settings.general:write" in request.eventpermset %} {% trans "Create a new badge layout" %} {% endif %} @@ -40,7 +40,7 @@ {% for l in layouts %} - {% if "can_change_event_settings" in request.eventpermset %} + {% if "event.settings.general:write" in request.eventpermset %} {{ l.name }} @@ -54,7 +54,7 @@ {% trans "Default" %} - {% elif "can_change_event_settings" in request.eventpermset %} + {% elif "event.settings.general:write" in request.eventpermset %}

@@ -66,7 +66,7 @@ {% endif %} - {% if "can_change_event_settings" in request.eventpermset %} + {% if "event.settings.general:write" in request.eventpermset %} diff --git a/src/pretix/plugins/badges/views.py b/src/pretix/plugins/badges/views.py index 0ae02c4300..86e5128358 100644 --- a/src/pretix/plugins/badges/views.py +++ b/src/pretix/plugins/badges/views.py @@ -52,6 +52,7 @@ from pretix.helpers.models import modelcopy from pretix.plugins.badges.forms import BadgeLayoutForm from pretix.plugins.badges.tasks import badges_create_pdf +from ...base.permissions import AnyPermissionOf from ...helpers.compat import CompatDeleteView from .models import BadgeLayout from .templates import TEMPLATES @@ -59,7 +60,7 @@ from .templates import TEMPLATES class LayoutListView(EventPermissionRequiredMixin, ListView): model = BadgeLayout - permission = ('can_change_event_settings', 'can_view_orders') + permission = AnyPermissionOf('event.settings.general:write', 'event.orders:read') template_name = 'pretixplugins/badges/index.html' context_object_name = 'layouts' @@ -71,7 +72,7 @@ class LayoutCreate(EventPermissionRequiredMixin, CreateView): model = BadgeLayout form_class = BadgeLayoutForm template_name = 'pretixplugins/badges/edit.html' - permission = 'can_change_event_settings' + permission = 'event.settings.general:write' context_object_name = 'layout' success_url = '/ignored' @@ -139,7 +140,7 @@ class LayoutCreate(EventPermissionRequiredMixin, CreateView): class LayoutSetDefault(EventPermissionRequiredMixin, DetailView): model = BadgeLayout - permission = 'can_change_event_settings' + permission = 'event.settings.general:write' def get_object(self, queryset=None) -> BadgeLayout: try: @@ -171,7 +172,7 @@ class LayoutSetDefault(EventPermissionRequiredMixin, DetailView): class LayoutDelete(EventPermissionRequiredMixin, CompatDeleteView): model = BadgeLayout template_name = 'pretixplugins/badges/delete.html' - permission = 'can_change_event_settings' + permission = 'event.settings.general:write' context_object_name = 'layout' def get_object(self, queryset=None) -> BadgeLayout: @@ -269,7 +270,7 @@ class LayoutEditorView(BaseEditorView): class OrderPrintDo(EventPermissionRequiredMixin, AsyncAction, View): task = badges_create_pdf - permission = 'can_view_orders' + permission = 'event.orders:read' known_errortypes = ['OrderError', 'ExportError'] def get_success_message(self, value): diff --git a/src/pretix/plugins/banktransfer/api.py b/src/pretix/plugins/banktransfer/api.py index f611f405b4..573ae6d843 100644 --- a/src/pretix/plugins/banktransfer/api.py +++ b/src/pretix/plugins/banktransfer/api.py @@ -97,7 +97,6 @@ class BankImportJobViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): queryset = BankImportJob.objects.none() filter_backends = (DjangoFilterBackend,) filterset_class = JobFilter - permission = 'can_view_orders' def get_queryset(self): return BankImportJob.objects.filter(organizer=self.request.organizer) @@ -105,9 +104,30 @@ class BankImportJobViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): def perform_create(self, serializer): return serializer.save() + def retrieve(self, request, *args, **kwargs): + perm_holder = (request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user) + has_any_event_perm = perm_holder.get_events_with_permission( + "event.orders:read", request=request + ).filter(organizer=request.organizer).exists() + if not has_any_event_perm: + raise PermissionDenied('Invalid set of permissions') + return super().retrieve(request, *args, **kwargs) + + def list(self, request, *args, **kwargs): + perm_holder = (request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user) + has_any_event_perm = perm_holder.get_events_with_permission( + "event.orders:read", request=request + ).filter(organizer=request.organizer).exists() + if not has_any_event_perm: + raise PermissionDenied('Invalid set of permissions') + return super().list(request, *args, **kwargs) + def create(self, request, *args, **kwargs): perm_holder = (request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user) - if not perm_holder.has_organizer_permission(request.organizer, 'can_change_orders'): + has_any_event_perm = perm_holder.get_events_with_permission( + "event.orders:write", request=request + ).filter(organizer=request.organizer).exists() + if not has_any_event_perm: raise PermissionDenied('Invalid set of permissions') if BankImportJob.objects.filter(Q(organizer=request.organizer)).filter( diff --git a/src/pretix/plugins/banktransfer/signals.py b/src/pretix/plugins/banktransfer/signals.py index 6302cfcbea..a6500b5c45 100644 --- a/src/pretix/plugins/banktransfer/signals.py +++ b/src/pretix/plugins/banktransfer/signals.py @@ -41,7 +41,7 @@ def register_payment_provider(sender, **kwargs): @receiver(nav_event, dispatch_uid="payment_banktransfer_nav") def control_nav_import(sender, request=None, **kwargs): url = resolve(request.path_info) - if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders', request=request): + if not request.user.has_event_permission(request.organizer, request.event, 'event.orders:write', request=request): return [] return [ { @@ -76,7 +76,10 @@ def control_nav_import(sender, request=None, **kwargs): @receiver(nav_organizer, dispatch_uid="payment_banktransfer_organav") def control_nav_orga_import(sender, request=None, **kwargs): url = resolve(request.path_info) - if not request.user.has_organizer_permission(request.organizer, 'can_change_orders', request=request): + has_any_event_perm = request.user.get_events_with_permission( + "event.orders:write", request=request + ).filter(organizer=request.organizer).exists() + if not has_any_event_perm: return [] return [ { diff --git a/src/pretix/plugins/banktransfer/views.py b/src/pretix/plugins/banktransfer/views.py index 9733e32034..eeaf692671 100644 --- a/src/pretix/plugins/banktransfer/views.py +++ b/src/pretix/plugins/banktransfer/views.py @@ -44,6 +44,7 @@ from typing import Set from django import forms from django.contrib import messages +from django.core.exceptions import PermissionDenied from django.db import transaction from django.db.models import Count, Q, QuerySet from django.http import FileResponse, JsonResponse @@ -58,11 +59,10 @@ from localflavor.generic.forms import BICFormField, IBANFormField from pretix.base.forms.widgets import DatePickerWidget from pretix.base.models import Event, Order, OrderPayment, OrderRefund, Quota +from pretix.base.models.organizer import TeamQuerySet from pretix.base.settings import SettingsSandbox from pretix.base.templatetags.money import money_filter -from pretix.control.permissions import ( - EventPermissionRequiredMixin, OrganizerPermissionRequiredMixin, -) +from pretix.control.permissions import EventPermissionRequiredMixin from pretix.control.views.organizer import OrganizerDetailViewMixin from pretix.helpers.json import CustomJSONEncoder from pretix.plugins.banktransfer import camtimport, csvimport, mt940import @@ -79,7 +79,7 @@ logger = logging.getLogger('pretix.plugins.banktransfer') class ActionView(View): - permission = 'can_change_orders' + permission = 'event.orders:write' def _discard(self, trans): trans.state = BankTransaction.STATE_DISCARDED @@ -279,7 +279,7 @@ class ActionView(View): class JobDetailView(DetailView): template_name = 'pretixplugins/banktransfer/job_detail.html' - permission = 'can_change_orders' + permission = 'event.orders:write' context_objectname = 'job' def redirect_form(self): @@ -368,7 +368,7 @@ class BankTransactionFilterForm(forms.Form): class ImportView(ListView): template_name = 'pretixplugins/banktransfer/import_form.html' - permission = 'can_change_orders' + permission = 'event.orders:write' context_object_name = 'transactions_unhandled' paginate_by = 30 @@ -625,44 +625,54 @@ class ImportView(ListView): class OrganizerBanktransferView: def dispatch(self, request, *args, **kwargs): + has_any_event_perm = request.user.get_events_with_permission( + "event.orders:write", request=request + ).filter(organizer=request.organizer).exists() + if not has_any_event_perm: + raise PermissionDenied() return super().dispatch(request, *args, **kwargs) class EventImportView(EventPermissionRequiredMixin, ImportView): - permission = 'can_change_orders' + permission = 'event.orders:write' -class OrganizerImportView(OrganizerBanktransferView, OrganizerPermissionRequiredMixin, OrganizerDetailViewMixin, +class OrganizerImportView(OrganizerBanktransferView, OrganizerDetailViewMixin, ImportView): - permission = 'can_change_orders' + pass class EventJobDetailView(EventPermissionRequiredMixin, JobDetailView): - permission = 'can_change_orders' + permission = 'event.orders:write' -class OrganizerJobDetailView(OrganizerBanktransferView, OrganizerPermissionRequiredMixin, OrganizerDetailViewMixin, +class OrganizerJobDetailView(OrganizerBanktransferView, OrganizerDetailViewMixin, JobDetailView): - permission = 'can_change_orders' + pass class EventActionView(EventPermissionRequiredMixin, ActionView): - permission = 'can_change_orders' + permission = 'event.orders:write' -class OrganizerActionView(OrganizerBanktransferView, OrganizerPermissionRequiredMixin, OrganizerDetailViewMixin, +class OrganizerActionView(OrganizerBanktransferView, OrganizerDetailViewMixin, ActionView): - permission = 'can_change_orders' def order_qs(self): - all = self.request.user.teams.filter(organizer=self.request.organizer, can_change_orders=True, - can_view_orders=True, all_events=True).exists() + all = self.request.user.teams.filter( + TeamQuerySet.event_permission_q("event.orders:read"), + TeamQuerySet.event_permission_q("event.orders:write"), + all_events=True, + organizer=self.request.organizer, + ).exists() if self.request.user.has_active_staff_session(self.request.session.session_key) or all: return Order.objects.filter(event__organizer=self.request.organizer) else: return Order.objects.filter( event_id__in=self.request.user.teams.filter( - organizer=self.request.organizer, can_change_orders=True, can_view_orders=True + TeamQuerySet.event_permission_q("event.orders:read"), + TeamQuerySet.event_permission_q("event.orders:write"), + organizer=self.request.organizer, ).values_list('limit_events__id', flat=True) ) @@ -755,7 +765,7 @@ class RefundExportListView(ListView): class EventRefundExportListView(EventPermissionRequiredMixin, RefundExportListView): - permission = 'can_change_orders' + permission = 'event.orders:write' def get_success_url(self): return reverse('plugins:banktransfer:refunds.list', kwargs={ @@ -777,8 +787,7 @@ class EventRefundExportListView(EventPermissionRequiredMixin, RefundExportListVi ) -class OrganizerRefundExportListView(OrganizerPermissionRequiredMixin, RefundExportListView): - permission = 'can_change_orders' +class OrganizerRefundExportListView(OrganizerBanktransferView, RefundExportListView): def get_success_url(self): return reverse('plugins:banktransfer:refunds.list', kwargs={ @@ -811,7 +820,7 @@ class DownloadRefundExportView(DetailView): class EventDownloadRefundExportView(EventPermissionRequiredMixin, DownloadRefundExportView): - permission = 'can_change_orders' + permission = 'event.orders:write' def get_object(self, *args, **kwargs): return get_object_or_404( @@ -821,8 +830,7 @@ class EventDownloadRefundExportView(EventPermissionRequiredMixin, DownloadRefund ) -class OrganizerDownloadRefundExportView(OrganizerPermissionRequiredMixin, OrganizerDetailViewMixin, DownloadRefundExportView): - permission = 'can_change_orders' +class OrganizerDownloadRefundExportView(OrganizerBanktransferView, OrganizerDetailViewMixin, DownloadRefundExportView): def get_object(self, *args, **kwargs): return get_object_or_404( @@ -850,9 +858,9 @@ class SepaXMLExportView(SingleObjectMixin, FormView): template_name = 'pretixplugins/banktransfer/sepa_export.html' context_object_name = "export" - def setup(self, request, *args, **kwargs): - super().setup(request, *args, **kwargs) + def dispatch(self, request, *args, **kwargs): self.object: RefundExport = self.get_object() + return super().dispatch(request, *args, **kwargs) def form_valid(self, form): self.object.downloaded = True @@ -869,7 +877,7 @@ class SepaXMLExportView(SingleObjectMixin, FormView): class EventSepaXMLExportView(EventPermissionRequiredMixin, SepaXMLExportView): - permission = 'can_change_orders' + permission = 'event.orders:write' def get_object(self, *args, **kwargs): return get_object_or_404( @@ -884,8 +892,7 @@ class EventSepaXMLExportView(EventPermissionRequiredMixin, SepaXMLExportView): return form -class OrganizerSepaXMLExportView(OrganizerPermissionRequiredMixin, OrganizerDetailViewMixin, SepaXMLExportView): - permission = 'can_change_orders' +class OrganizerSepaXMLExportView(OrganizerBanktransferView, OrganizerDetailViewMixin, SepaXMLExportView): def get_object(self, *args, **kwargs): return get_object_or_404( diff --git a/src/pretix/plugins/paypal/views.py b/src/pretix/plugins/paypal/views.py index 01a37b814e..1436bdd5f9 100644 --- a/src/pretix/plugins/paypal/views.py +++ b/src/pretix/plugins/paypal/views.py @@ -246,7 +246,7 @@ def webhook(request, *args, **kwargs): return HttpResponse(status=200) -@event_permission_required('can_change_event_settings') +@event_permission_required('event.settings.general:write') @require_POST def oauth_disconnect(request, **kwargs): del request.event.settings.payment_paypal_connect_refresh_token diff --git a/src/pretix/plugins/paypal2/views.py b/src/pretix/plugins/paypal2/views.py index 336239a18e..7edf796bff 100644 --- a/src/pretix/plugins/paypal2/views.py +++ b/src/pretix/plugins/paypal2/views.py @@ -216,7 +216,7 @@ class PayView(PaypalOrderView, TemplateView): @scopes_disabled() -@event_permission_required('can_change_event_settings') +@event_permission_required('event.settings.general:write') def isu_return(request, *args, **kwargs): getparams = ['merchantId', 'merchantIdInPayPal', 'permissionsGranted', 'accountStatus', 'consentStatus', 'productIntentID', 'isEmailConfirmed'] sessionparams = ['payment_paypal_isu_event', 'payment_paypal_isu_tracking_id'] @@ -526,7 +526,7 @@ def webhook(request, *args, **kwargs): return HttpResponse(status=200) -@event_permission_required('can_change_event_settings') +@event_permission_required('event.settings.general:write') @require_POST def isu_disconnect(request, **kwargs): del request.event.settings.payment_paypal_connect_refresh_token diff --git a/src/pretix/plugins/returnurl/signals.py b/src/pretix/plugins/returnurl/signals.py index 60583003f1..3cf7e62621 100644 --- a/src/pretix/plugins/returnurl/signals.py +++ b/src/pretix/plugins/returnurl/signals.py @@ -83,7 +83,7 @@ def check_against_prefix_list(u, allowlist): @receiver(nav_event_settings, dispatch_uid='returnurl_nav') def navbar_info(sender, request, **kwargs): url = resolve(request.path_info) - if not request.user.has_event_permission(request.organizer, request.event, 'can_change_event_settings', + if not request.user.has_event_permission(request.organizer, request.event, 'event.settings.general:write', request=request): return [] return [{ diff --git a/src/pretix/plugins/returnurl/views.py b/src/pretix/plugins/returnurl/views.py index f3fab24909..43f53f3683 100644 --- a/src/pretix/plugins/returnurl/views.py +++ b/src/pretix/plugins/returnurl/views.py @@ -48,7 +48,7 @@ class ReturnSettings(EventSettingsViewMixin, EventSettingsFormView): model = Event form_class = ReturnSettingsForm template_name = 'returnurl/settings.html' - permission = 'can_change_settings' + permission = 'event.settings.general:write' def get_success_url(self) -> str: return reverse('plugins:returnurl:settings', kwargs={ diff --git a/src/pretix/plugins/sendmail/api.py b/src/pretix/plugins/sendmail/api.py index 1bcc80593a..7d5e1548ae 100644 --- a/src/pretix/plugins/sendmail/api.py +++ b/src/pretix/plugins/sendmail/api.py @@ -113,7 +113,7 @@ class RuleViewSet(viewsets.ModelViewSet): filterset_class = RuleFilter ordering = ('id',) ordering_fields = ('id',) - permission = 'can_change_event_settings' + permission = 'event.settings.general:write' def get_queryset(self): return Rule.objects.filter(event=self.request.event) diff --git a/src/pretix/plugins/sendmail/signals.py b/src/pretix/plugins/sendmail/signals.py index 1186f11f40..e573ce71d0 100644 --- a/src/pretix/plugins/sendmail/signals.py +++ b/src/pretix/plugins/sendmail/signals.py @@ -79,7 +79,7 @@ def scheduled_mail_create(sender, **kwargs): @receiver(nav_event, dispatch_uid="sendmail_nav") def control_nav_import(sender, request=None, **kwargs): url = resolve(request.path_info) - if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders', request=request): + if not request.user.has_event_permission(request.organizer, request.event, 'event.orders:write', request=request): return [] return [ { diff --git a/src/pretix/plugins/sendmail/views.py b/src/pretix/plugins/sendmail/views.py index 189761ab4b..a78edbb9c4 100644 --- a/src/pretix/plugins/sendmail/views.py +++ b/src/pretix/plugins/sendmail/views.py @@ -72,7 +72,7 @@ logger = logging.getLogger('pretix.plugins.sendmail') class IndexView(EventPermissionRequiredMixin, TemplateView): template_name = 'pretixplugins/sendmail/index.html' - permission = 'can_change_orders' + permission = 'event.orders:write' def get_context_data(self, **kwargs): from .signals import sendmail_view_classes @@ -94,7 +94,7 @@ class IndexView(EventPermissionRequiredMixin, TemplateView): class BaseSenderView(EventPermissionRequiredMixin, FormView): # These parameters usually SHOULD NOT be overridden template_name = 'pretixplugins/sendmail/send_form.html' - permission = 'can_change_orders' + permission = 'event.orders:write' # These parameters MUST be overridden by subclasses form_fragment_name = None @@ -523,7 +523,7 @@ class WaitinglistSendView(BaseSenderView): class EmailHistoryView(EventPermissionRequiredMixin, ListView): template_name = 'pretixplugins/sendmail/history.html' - permission = 'can_change_orders' + permission = 'event.orders:write' model = LogEntry context_object_name = 'logs' paginate_by = 5 @@ -571,7 +571,7 @@ class EmailHistoryView(EventPermissionRequiredMixin, ListView): class CreateRule(EventPermissionRequiredMixin, CreateView): template_name = 'pretixplugins/sendmail/rule_create.html' - permission = 'can_change_event_settings' + permission = 'event.settings.general:write' form_class = forms.RuleForm model = Rule @@ -621,7 +621,7 @@ class UpdateRule(EventPermissionRequiredMixin, UpdateView): model = Rule form_class = forms.RuleForm template_name = 'pretixplugins/sendmail/rule_update.html' - permission = 'can_change_event_settings' + permission = 'event.settings.general:write' def get_object(self, queryset=None) -> Rule: return get_object_or_404( @@ -701,7 +701,7 @@ class ListRules(EventPermissionRequiredMixin, PaginationMixin, ListView): class DeleteRule(EventPermissionRequiredMixin, DeleteView): model = Rule - permission = 'can_change_event_settings' + permission = 'event.settings.general:write' template_name = 'pretixplugins/sendmail/rule_delete.html' context_object_name = 'rule' diff --git a/src/pretix/plugins/statistics/signals.py b/src/pretix/plugins/statistics/signals.py index 224a916b0a..8c77d9ff63 100644 --- a/src/pretix/plugins/statistics/signals.py +++ b/src/pretix/plugins/statistics/signals.py @@ -30,7 +30,7 @@ from pretix.control.signals import nav_event @receiver(nav_event, dispatch_uid="statistics_nav") def control_nav_import(sender, request=None, **kwargs): url = resolve(request.path_info) - if not request.user.has_event_permission(request.organizer, request.event, 'can_view_orders', request=request): + if not request.user.has_event_permission(request.organizer, request.event, 'event.orders:read', request=request): return [] return [ { diff --git a/src/pretix/plugins/statistics/views.py b/src/pretix/plugins/statistics/views.py index 17e9b48330..4be161e979 100644 --- a/src/pretix/plugins/statistics/views.py +++ b/src/pretix/plugins/statistics/views.py @@ -52,7 +52,7 @@ from pretix.plugins.statistics.signals import clear_cache class IndexView(EventPermissionRequiredMixin, ChartContainingView, TemplateView): template_name = 'pretixplugins/statistics/index.html' - permission = 'can_view_orders' + permission = 'event.orders:read' def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) diff --git a/src/pretix/plugins/stripe/views.py b/src/pretix/plugins/stripe/views.py index f997a67b10..a9590f49ab 100644 --- a/src/pretix/plugins/stripe/views.py +++ b/src/pretix/plugins/stripe/views.py @@ -473,7 +473,7 @@ def paymentintent_webhook(event, event_json, paymentintent_id, rso): return HttpResponse(status=200) -@event_permission_required('can_change_event_settings') +@event_permission_required('event.settings.general:write') def oauth_disconnect(request, **kwargs): if request.method != "POST": return render(request, 'pretixplugins/stripe/oauth_disconnect.html', {}) @@ -671,7 +671,7 @@ class ScaReturnView(StripeOrderView, View): class OrganizerSettingsFormView(DecoupleMixin, OrganizerDetailViewMixin, AdministratorPermissionRequiredMixin, FormView): model = Organizer - permission = 'can_change_organizer_settings' + permission = 'organizer.settings.general:write' form_class = OrganizerStripeSettingsForm template_name = 'pretixplugins/stripe/organizer_stripe.html' diff --git a/src/pretix/plugins/ticketoutputpdf/api.py b/src/pretix/plugins/ticketoutputpdf/api.py index 7232c4bcf1..231c48b0ff 100644 --- a/src/pretix/plugins/ticketoutputpdf/api.py +++ b/src/pretix/plugins/ticketoutputpdf/api.py @@ -206,7 +206,7 @@ class RenderJobSerializer(serializers.Serializer): class TicketRendererViewSet(viewsets.ViewSet): - permission = 'can_view_orders' + permission = 'event.orders:read' def get_serializer_kwargs(self): return {} diff --git a/src/pretix/plugins/ticketoutputpdf/templates/pretixplugins/ticketoutputpdf/index.html b/src/pretix/plugins/ticketoutputpdf/templates/pretixplugins/ticketoutputpdf/index.html index a2db4f765a..5b1141bfce 100644 --- a/src/pretix/plugins/ticketoutputpdf/templates/pretixplugins/ticketoutputpdf/index.html +++ b/src/pretix/plugins/ticketoutputpdf/templates/pretixplugins/ticketoutputpdf/index.html @@ -12,7 +12,7 @@ {% endblocktrans %}

- {% if "can_change_event_settings" in request.eventpermset %} + {% if "event.settings.general:write" in request.eventpermset %} {% trans "Create a new layout" %} @@ -20,7 +20,7 @@
{% else %}

- {% if "can_change_event_settings" in request.eventpermset %} + {% if "event.settings.general:write" in request.eventpermset %} {% trans "Create a new layout" %} {% endif %} @@ -38,7 +38,7 @@ {% for l in layouts %} - {% if "can_change_event_settings" in request.eventpermset %} + {% if "event.settings.general:write" in request.eventpermset %} {{ l.name }} @@ -52,7 +52,7 @@ {% trans "Default" %} - {% elif "can_change_event_settings" in request.eventpermset %} + {% elif "event.settings.general:write" in request.eventpermset %} {% csrf_token %} @@ -63,7 +63,7 @@ {% endif %} - {% if "can_change_event_settings" in request.eventpermset %} + {% if "event.settings.general:write" in request.eventpermset %} diff --git a/src/pretix/plugins/ticketoutputpdf/views.py b/src/pretix/plugins/ticketoutputpdf/views.py index 5d47cad30f..fd633083f4 100644 --- a/src/pretix/plugins/ticketoutputpdf/views.py +++ b/src/pretix/plugins/ticketoutputpdf/views.py @@ -95,7 +95,7 @@ class EditorView(BaseEditorView): class LayoutListView(EventPermissionRequiredMixin, ListView): model = TicketLayout - permission = ('can_change_event_settings') + permission = 'event.settings.general:write' template_name = 'pretixplugins/ticketoutputpdf/index.html' context_object_name = 'layouts' @@ -107,7 +107,7 @@ class LayoutCreate(EventPermissionRequiredMixin, CreateView): model = TicketLayout form_class = TicketLayoutForm template_name = 'pretixplugins/ticketoutputpdf/edit.html' - permission = 'can_change_event_settings' + permission = 'event.settings.general:write' context_object_name = 'layout' success_url = '/ignored' @@ -157,7 +157,7 @@ class LayoutCreate(EventPermissionRequiredMixin, CreateView): class LayoutSetDefault(EventPermissionRequiredMixin, DetailView): model = TicketLayout - permission = 'can_change_event_settings' + permission = 'event.settings.general:write' def get_object(self, queryset=None) -> TicketLayout: try: @@ -186,7 +186,7 @@ class LayoutSetDefault(EventPermissionRequiredMixin, DetailView): class LayoutDelete(EventPermissionRequiredMixin, CompatDeleteView): model = TicketLayout template_name = 'pretixplugins/ticketoutputpdf/delete.html' - permission = 'can_change_event_settings' + permission = 'event.settings.general:write' context_object_name = 'layout' def get_object(self, queryset=None) -> TicketLayout: @@ -218,7 +218,7 @@ class LayoutDelete(EventPermissionRequiredMixin, CompatDeleteView): class LayoutGetDefault(EventPermissionRequiredMixin, View): - permission = 'can_change_event_settings' + permission = 'event.settings.general:write' def get(self, request, *args, **kwargs): layout = self.request.event.ticket_layouts.get_or_create( @@ -304,7 +304,7 @@ class LayoutEditorView(BaseEditorView): class OrderPrintDo(EventPermissionRequiredMixin, AsyncAction, View): task = tickets_create_pdf - permission = 'can_view_orders' + permission = 'event.orders:read' known_errortypes = ['OrderError', 'ExportError'] def get_success_message(self, value): diff --git a/src/pretix/plugins/webcheckin/signals.py b/src/pretix/plugins/webcheckin/signals.py index b88ad5fad9..8488624589 100644 --- a/src/pretix/plugins/webcheckin/signals.py +++ b/src/pretix/plugins/webcheckin/signals.py @@ -30,7 +30,7 @@ from pretix.control.signals import nav_event @receiver(nav_event, dispatch_uid='webcheckin_nav_event') def navbar_entry(sender, request, **kwargs): url = request.resolver_match - if not request.user.has_event_permission(request.organizer, request.event, ('can_change_orders', 'can_checkin_orders'), request=request): + if not request.user.has_event_permission(request.organizer, request.event, ('event.orders:write', 'event.orders:checkin'), request=request): return [] return [{ 'label': mark_safe(_('Web Check-in') + ' beta'), diff --git a/src/pretix/plugins/webcheckin/views.py b/src/pretix/plugins/webcheckin/views.py index c4b42a795a..2d8d2a1c9a 100644 --- a/src/pretix/plugins/webcheckin/views.py +++ b/src/pretix/plugins/webcheckin/views.py @@ -21,12 +21,13 @@ # from django.views.generic import TemplateView +from pretix.base.permissions import AnyPermissionOf from pretix.control.permissions import EventPermissionRequiredMixin from pretix.helpers.countries import CachedCountries class IndexView(EventPermissionRequiredMixin, TemplateView): - permission = ('can_change_orders', 'can_checkin_orders') + permission = AnyPermissionOf('event.orders:write', 'event.orders:checkin') template_name = 'pretixplugins/webcheckin/index.html' def get_context_data(self, **kwargs): diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 73817f97c5..964974fc8e 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -138,7 +138,7 @@ class OrderDetailMixin(NoSearchIndexViewMixin): code=self.kwargs['order'], received_secret=self.kwargs['secret'], tag=None, ) - if has_event_access_permission(self.request, 'can_view_orders'): + if has_event_access_permission(self.request, 'event.orders:read'): return order if order.customer is None or not order.customer.is_verified or self._allow_anonymous_access(): @@ -255,7 +255,7 @@ class TicketPageMixin: ctx['download_buttons'] = self.download_buttons - ctx['backend_user'] = has_event_access_permission(self.request, 'can_view_orders') + ctx['backend_user'] = has_event_access_permission(self.request, 'event.orders:read') return ctx diff --git a/src/pretix/static/pretixbase/js/addressform.js b/src/pretix/static/pretixbase/js/addressform.js index 3a5b35ad39..40f9595603 100644 --- a/src/pretix/static/pretixbase/js/addressform.js +++ b/src/pretix/static/pretixbase/js/addressform.js @@ -33,6 +33,13 @@ $(function () { dependents[cleanName($(this).attr("name"))] = $(this) }) + const dependentsDisabled = []; + for (var k in dependents) { + if (dependents[k].prop("disabled")) { + dependentsDisabled.push(k); + } + } + if (!Object.values(dependents).some((el) => el.length)) { // No address fields found, do not create request return; @@ -101,7 +108,7 @@ $(function () { label.append('' + gettext('required') + '') } } - for (var k in dependents) dependents[k].prop("disabled", false); + for (var k in dependents) dependents[k].prop("disabled", dependentsDisabled.includes(k)); loader.hide(); } @@ -158,7 +165,7 @@ $(function () { required = false; dependent.closest(".form-group").toggle(visible).toggleClass('required', required); - dependent.prop("required", required).prop("disabled", false); + dependent.prop("required", required).prop("disabled", dependentsDisabled.includes(k)); } }).finally(function () { loader.hide(); diff --git a/src/pretix/static/pretixcontrol/js/ui/main.js b/src/pretix/static/pretixcontrol/js/ui/main.js index ed24303c2a..77f0cb2215 100644 --- a/src/pretix/static/pretixcontrol/js/ui/main.js +++ b/src/pretix/static/pretixcontrol/js/ui/main.js @@ -340,11 +340,12 @@ var form_handlers = function (el) { } el.find("input[data-checkbox-dependency]").each(function () { + var initially_disabled = $(this).prop("disabled"); var dependent = $(this), dependency = findDependency($(this).attr("data-checkbox-dependency"), this), update = function () { var enabled = dependency.prop('checked'); - dependent.prop('disabled', !enabled).closest('.form-group, .form-field-boundary').toggleClass('disabled', !enabled); + dependent.prop('disabled', !enabled || initially_disabled).closest('.form-group, .form-field-boundary').toggleClass('disabled', !enabled); if (!enabled && !dependent.is('[data-checkbox-dependency-visual]')) { dependent.prop('checked', false); dependent.trigger('change') @@ -366,11 +367,12 @@ var form_handlers = function (el) { }); el.find("div[data-display-dependency], textarea[data-display-dependency], input[data-display-dependency], select[data-display-dependency], button[data-display-dependency]").each(function () { + var initially_disabled = $(this).prop("disabled"); var dependent = $(this), dependency = findDependency($(this).attr("data-display-dependency"), this), update = function (ev) { var enabled = dependency.toArray().some(function(d) { - if (d.disabled) return false; + if (d.disabled && !initially_disabled) return false; if (d.type === 'checkbox' || d.type === 'radio') { return d.checked; } else if (d.type === 'select-one') { @@ -391,7 +393,7 @@ var form_handlers = function (el) { } var $toggling = dependent; if (dependent.is("[data-disable-dependent]")) { - $toggling.attr('disabled', !enabled).trigger("change"); + $toggling.attr('disabled', !enabled || initially_disabled).trigger("change"); } const tagName = dependent.get(0).tagName.toLowerCase() if (tagName !== "div" && tagName !== "button") { diff --git a/src/pretix/static/pretixcontrol/scss/_forms.scss b/src/pretix/static/pretixcontrol/scss/_forms.scss index a80c9d5152..6980721311 100644 --- a/src/pretix/static/pretixcontrol/scss/_forms.scss +++ b/src/pretix/static/pretixcontrol/scss/_forms.scss @@ -417,6 +417,30 @@ div.scrolling-multiple-choice, div.scrolling-choice { } } } +.team-permission-groups { + border: 1px solid $input-border; + border-radius: $input-border-radius; + padding: 10px 15px; + + .radio { + display: inline-block; + margin-right: 10px; + &:not(:has(.fa-fw)) { + // Visual adjustment between options with and without help text + &:after { + display: inline-block; + content: " "; + padding-right: 1.28571em; + } + } + } + .control-label { + text-align: left; + } + .form-group:last-child, .help-block { + margin-bottom: 0; + } +} table td > .checkbox { margin: 0; position: static; diff --git a/src/tests/api/conftest.py b/src/tests/api/conftest.py index 4947708d7c..031b6a7c52 100644 --- a/src/tests/api/conftest.py +++ b/src/tests/api/conftest.py @@ -106,17 +106,8 @@ def team(organizer): return Team.objects.create( organizer=organizer, name="Test-Team", - can_change_teams=True, - can_manage_gift_cards=True, - can_change_items=True, - can_create_events=True, - can_change_event_settings=True, - can_change_vouchers=True, - can_view_vouchers=True, - can_change_orders=True, - can_manage_customers=True, - can_manage_reusable_media=True, - can_change_organizer_settings=True + all_event_permissions=True, + all_organizer_permissions=True, ) @@ -140,8 +131,9 @@ def user(): @pytest.fixture @scopes_disabled() def user_client(client, team, user): - team.can_view_orders = True - team.can_view_vouchers = True + if not team.all_event_permissions: + team.limit_event_permissions["event.orders:read"] = True + team.limit_event_permissions["event.vouchers:read"] = True team.all_events = True team.save() team.members.add(user) @@ -152,8 +144,9 @@ def user_client(client, team, user): @pytest.fixture @scopes_disabled() def token_client(client, team): - team.can_view_orders = True - team.can_view_vouchers = True + if not team.all_event_permissions: + team.limit_event_permissions["event.orders:read"] = True + team.limit_event_permissions["event.vouchers:read"] = True team.all_events = True team.save() t = team.tokens.create(name='Foo') diff --git a/src/tests/api/test_checkin.py b/src/tests/api/test_checkin.py index 1fcb140a22..f829eb3422 100644 --- a/src/tests/api/test_checkin.py +++ b/src/tests/api/test_checkin.py @@ -1382,9 +1382,8 @@ def test_checkin_pdf_data_requires_permission(token_client, event, team, organiz )) assert resp.data['results'][0].get('pdf_data') with scopes_disabled(): - team.can_view_orders = False - team.can_change_orders = False - team.can_checkin_orders = True + team.limit_event_permissions = {"event.orders:checkin": True} + team.all_event_permissions = False team.save() resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?search=z3fsn8jyu&pdf_data=true'.format( organizer.slug, event.slug, clist_all.pk diff --git a/src/tests/api/test_checkinrpc.py b/src/tests/api/test_checkinrpc.py index 9fb79cfa67..44e278a93d 100644 --- a/src/tests/api/test_checkinrpc.py +++ b/src/tests/api/test_checkinrpc.py @@ -984,9 +984,8 @@ def test_search_multiple_lists(token_client, organizer, clist_all, clist_event2, @pytest.mark.django_db def test_without_permission(token_client, event, team, organizer, clist_all, order): with scopes_disabled(): - team.can_view_orders = False - team.can_change_orders = False - team.can_checkin_orders = False + team.limit_event_permissions = {} + team.all_event_permissions = False team.save() resp = token_client.get( '/api/v1/organizers/{}/checkinrpc/search/?list={}&search=dummy.test&ordering=attendee_name'.format(organizer.slug, clist_all.pk)) @@ -1043,9 +1042,8 @@ def test_checkin_only_permission(token_client, event, team, organizer, clist_all assert resp.data['position'].get('pdf_data') with scopes_disabled(): - team.can_view_orders = False - team.can_change_orders = False - team.can_checkin_orders = True + team.limit_event_permissions = {"event.orders:checkin": True} + team.all_event_permissions = False team.save() # With limited permissions, I can not search with a 2-character query diff --git a/src/tests/api/test_events.py b/src/tests/api/test_events.py index b2b350216a..fd19291855 100644 --- a/src/tests/api/test_events.py +++ b/src/tests/api/test_events.py @@ -243,7 +243,8 @@ def test_event_create(team, token_client, organizer, event, meta_prop): {"key": "Workshop", "label": {"en": "Workshop"}}, ] meta_prop.save() - team.can_change_organizer_settings = False + team.limit_organizer_permissions = {"organizer.events:create": True} + team.all_organizer_permissions = False team.save() organizer.meta_properties.create( name="protected", protected=True @@ -576,21 +577,15 @@ def test_event_create_with_clone_unknown_source(user, user_client, organizer, ev @pytest.mark.django_db -def test_event_create_with_clone_across_organizers(user, user_client, organizer, event, taxrule): +def test_event_create_with_clone_across_organizers(user, user_client, organizer, event, taxrule, team): + team.all_event_permissions = True + team.save() with scopes_disabled(): target_org = Organizer.objects.create(name='Dummy', slug='dummy2') team = target_org.teams.create( name="Test-Team", - can_change_teams=True, - can_manage_gift_cards=True, - can_change_items=True, - can_create_events=True, - can_change_event_settings=True, - can_change_vouchers=True, - can_view_vouchers=True, - can_change_orders=True, - can_manage_customers=True, - can_change_organizer_settings=True + all_event_permissions=True, + all_organizer_permissions=True, ) team.members.add(user) @@ -629,6 +624,51 @@ def test_event_create_with_clone_across_organizers(user, user_client, organizer, assert cloned_event.tax_rules.exists() +@pytest.mark.django_db +def test_event_create_with_clone_across_organizers_lack_of_permission_on_source(user, user_client, team, organizer, event, taxrule): + team.all_event_permissions = False + team.limit_event_permissions = { + "event.settings.general:write": True, + } + team.save() + with scopes_disabled(): + target_org = Organizer.objects.create(name='Dummy', slug='dummy2') + team = target_org.teams.create( + name="Test-Team", + all_event_permissions=True, + all_organizer_permissions=True, + ) + team.members.add(user) + + resp = user_client.post( + '/api/v1/organizers/{}/events/?clone_from={}/{}'.format(target_org.slug, organizer.slug, event.slug), + { + "name": { + "de": "Demo Konference 2020 Test", + "en": "Demo Conference 2020 Test" + }, + "live": False, + "testmode": True, + "currency": "EUR", + "date_from": "2018-12-27T10:00:00Z", + "date_to": "2018-12-28T10:00:00Z", + "date_admission": None, + "is_public": False, + "presale_start": None, + "presale_end": None, + "location": None, + "slug": "2030", + "plugins": [ + "pretix.plugins.ticketoutputpdf" + ], + "timezone": "Europe/Vienna" + }, + format='json' + ) + assert resp.status_code == 403 + assert resp.data["detail"] == "Not sufficient permission on source event to copy" + + @pytest.mark.django_db def test_event_put_with_clone(token_client, organizer, event, meta_prop): resp = token_client.put( @@ -1394,7 +1434,14 @@ def test_get_event_settings(token_client, organizer, event): @pytest.mark.django_db -def test_patch_event_settings(token_client, organizer, event): +def test_patch_event_settings(token_client, organizer, event, team): + team.all_event_permissions = False + team.limit_event_permissions = { + "event.settings.general:write": True, + "event.settings.tax:write": True, + } + team.save() + organizer.settings.imprint_url = 'https://example.org' resp = token_client.patch( '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), @@ -1510,6 +1557,29 @@ def test_patch_event_settings(token_client, organizer, event): event.settings.flush() assert set(event.settings.locales) == set(locales) + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), + { + 'display_net_prices': True, + }, + format='json' + ) + assert resp.status_code == 200 + event.settings.flush() + assert event.settings.display_net_prices + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug), + { + 'invoice_address_asked': False, + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.data == { + 'invoice_address_asked': ['Setting this field requires permission event.settings.invoicing:write'] + } + @pytest.mark.django_db def test_patch_event_settings_validation(token_client, organizer, event): diff --git a/src/tests/api/test_exporters.py b/src/tests/api/test_exporters.py index 222362e6a1..6976d50a96 100644 --- a/src/tests/api/test_exporters.py +++ b/src/tests/api/test_exporters.py @@ -39,8 +39,11 @@ from datetime import time import pytest from django.utils.timezone import now +from rest_framework.test import APIClient -from pretix.base.models import CachedFile, User +from pretix.base.models import ( + CachedFile, Event, ScheduledEventExport, ScheduledOrganizerExport, User, +) SAMPLE_EXPORTER_CONFIG = { "identifier": "orderlist", @@ -111,6 +114,10 @@ def test_org_list(token_client, organizer, event): "name": "events", "required": False }) + c['input_parameters'].insert(0, { + "name": "all_events", + "required": False + }) c['input_parameters'].remove({ "name": "items", "required": False @@ -144,13 +151,6 @@ def test_org_validate_events(token_client, organizer, team, event): }, format='json') assert resp.status_code == 202 - resp = token_client.post('/api/v1/organizers/{}/exporters/orderlist/run/'.format(organizer.slug), data={ - '_format': 'xlsx', - 'events': [] - }, format='json') - assert resp.status_code == 400 - assert resp.data == {"events": ["This list may not be empty."]} - resp = token_client.post('/api/v1/organizers/{}/exporters/orderlist/run/'.format(organizer.slug), data={ '_format': 'xlsx', 'events': ["nonexisting"] @@ -280,7 +280,8 @@ def test_org_level_export(token_client, organizer, team, event): }, format='json') assert resp.status_code == 202 - team.can_manage_gift_cards = False + team.limit_organizer_permissions = {"organizer.events:create": True} + team.all_organizer_permissions = False team.save() resp = token_client.post('/api/v1/organizers/{}/exporters/giftcardlist/run/'.format(organizer.slug), data={ @@ -339,10 +340,12 @@ def test_event_scheduled_export_list_token(token_client, organizer, event, user, assert resp.status_code == 200 assert [res] == resp.data['results'] - team.can_change_event_settings = False + team.limit_organizer_permissions = {"organizer.events:create": True} + team.all_organizer_permissions = False + team.all_event_permissions = False team.save() - # Token can no longer sees it an gets error message + # Token can no longer sees it and gets error message resp = token_client.get('/api/v1/organizers/{}/events/{}/scheduled_exports/'.format(organizer.slug, event.slug)) assert resp.status_code == 403 @@ -361,7 +364,9 @@ def test_event_scheduled_export_list_user(user_client, organizer, event, user, t resp = user_client.get('/api/v1/organizers/{}/events/{}/scheduled_exports/'.format(organizer.slug, event.slug)) assert [res] == resp.data['results'] - team.can_change_event_settings = False + team.all_organizer_permissions = False + team.limit_event_permissions = {"event.orders:read": True} + team.all_event_permissions = False team.save() # Owner still can @@ -498,7 +503,8 @@ def test_org_scheduled_export_list_token(token_client, organizer, user, team, or assert resp.status_code == 200 assert [res] == resp.data['results'] - team.can_change_organizer_settings = False + team.limit_organizer_permissions = {"organizer.events:create": True} + team.all_organizer_permissions = False team.save() # Token can no longer sees it an gets error message @@ -521,7 +527,8 @@ def test_org_scheduled_export_list_user(user_client, organizer, user, team, org_ resp = user_client.get('/api/v1/organizers/{}/scheduled_exports/'.format(organizer.slug)) assert [res] == resp.data['results'] - team.can_change_organizer_settings = False + team.limit_organizer_permissions = {"organizer.events:create": True} + team.all_organizer_permissions = False team.save() # Owner still can @@ -817,3 +824,254 @@ def test_org_scheduled_export_validate_rrule(user_client, organizer, user): ) assert resp.status_code == 400 assert resp.data == {"schedule_rrule": ["BYEASTER not supported"]} + + +def _get_and_patch_org_export(client, scheduled, can_see=True, can_edit=None): + if can_edit is None: + can_edit = can_see + response = client.get( + '/api/v1/organizers/{}/scheduled_exports/{}/'.format("dummy", scheduled.pk), + ) + if can_see: + assert response.status_code == 200 + else: + assert response.status_code > 400 + assert can_edit is False # Check against useless test usage + return True # No point in editing, we don't have a body + + response = client.patch( + '/api/v1/organizers/{}/scheduled_exports/{}/'.format("dummy", scheduled.pk), + data=response.data, + format='json', + ) + if can_edit: + assert response.status_code == 200 + else: + assert response.status_code > 400 or (response.status_code == 400 and "export_identifier" in response.data) + return True + + +@pytest.mark.django_db(transaction=True) +def test_organizer_edit_restrictions(client, event, organizer, user, team): + # This tests the prevention of a possible privilege escalation where user A creates a scheduled export and + # user B has settings permission (= they can see the export configuration), but not enough permission + # to run the export themselves. Without this check, user B could modify the export and add themselves + # as a recipient. Thereby, user B would gain access to data they can't have. + user1_client = APIClient() + user1_client.force_authenticate(user=user) + user2 = User.objects.create_user("dummy2@dummy.dummy", "dummy") + user2_client = APIClient() + user2_client.force_authenticate(user=user2) + team1_client = APIClient() + t = team.tokens.create(name='Foo') + team1_client.credentials(HTTP_AUTHORIZATION='Token ' + t.token) + + event1 = event + event2 = Event.objects.create( + organizer=organizer, name="Dummy", slug="dummy2", + date_from=now(), plugins="pretix.plugins.banktransfer,pretix.plugins.stripe,tests.testdummy" + ) + + team1 = team + team1.all_organizer_permissions = False + team1.all_event_permissions = False + team1.all_events = False + team1.limit_organizer_permissions = {"organizer.settings.general:write": True} + team1.limit_event_permissions = {"event.orders:read": True, "event.settings.general:write": True} + team1.save() + team1.limit_events.add(event1) + team1.members.add(user) + + t = team.tokens.create(name='Foo') + client.credentials(HTTP_AUTHORIZATION='Token ' + t.token) + + team2 = organizer.teams.create( + all_organizer_permissions=False, all_event_permissions=False, all_events=False, + limit_event_permissions={"event.orders:read": True}, + limit_organizer_permissions={"organizer.giftcards:read": True} + ) + team2.limit_events.add(event2) + team2.members.add(user2) + + # Scenario 1 + # User 2 created an export for all events. User 2 can edit it, because they own it. + # User 1 can see it, because they have permission to see scheduled exports, but can't change it, because they + # don't have access to all events. + s1 = ScheduledOrganizerExport.objects.create( + organizer=organizer, + owner=user2, + export_identifier="dummy_orders", + export_form_data={"all_events": True, "events": []}, + mail_subject="Test", + mail_template="Test", + locale="en", + schedule_rrule="DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO", + schedule_rrule_time=time(2, 30, 0) + ) + user._teamcache = {} + user2._teamcache = {} + assert _get_and_patch_org_export(user2_client, s1) + assert _get_and_patch_org_export(user1_client, s1, can_see=True, can_edit=False) + assert _get_and_patch_org_export(team1_client, s1, can_see=True, can_edit=False) + + # Scenario 2 + # User 2 created an export for all events. User 2 can edit it, because they own it. + # User 1 can see it, because they have permission to see scheduled exports, and change it, because they + # have access to all events. + team1.all_events = True + team1.save() + user._teamcache = {} + user2._teamcache = {} + assert _get_and_patch_org_export(user2_client, s1) + assert _get_and_patch_org_export(user1_client, s1) + assert _get_and_patch_org_export(team1_client, s1) + + # Scenario 3 + # User 2 created an export for a specific event. User 2 can edit it, because they own it. + # User 1 can see it, because they have permission to see scheduled exports, but can't change it, because they + # don't have access to that event. + team1.all_events = False + team1.save() + s1.export_form_data = {"all_events": False, "events": [event2.pk]} + s1.save() + user._teamcache = {} + user2._teamcache = {} + assert _get_and_patch_org_export(user2_client, s1) + assert _get_and_patch_org_export(user1_client, s1, can_see=True, can_edit=False) + assert _get_and_patch_org_export(team1_client, s1, can_see=True, can_edit=False) + + # Scenario 4 + # User 2 created an export for a specific event. User 2 can edit it, because they own it. + # User 1 can see it, because they have permission to see scheduled exports, and change it, because they + # have access to that event. + team1.limit_events.add(event2) + user._teamcache = {} + user2._teamcache = {} + assert _get_and_patch_org_export(user2_client, s1) + assert _get_and_patch_org_export(user1_client, s1) + assert _get_and_patch_org_export(team1_client, s1) + + # Scenario 5 + # User 2 created an export that requires a special permission on organizer level + # user 1 can see it, because they have permission to see scheduled exports, but can't change it, because they lack + # that special permission + s2 = ScheduledOrganizerExport.objects.create( + organizer=organizer, + owner=user2, + export_identifier="giftcardlist", + mail_subject="Test", + mail_template="Test", + locale="en", + schedule_rrule="DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO", + schedule_rrule_time=time(2, 30, 0) + ) + user._teamcache = {} + user2._teamcache = {} + assert _get_and_patch_org_export(user2_client, s2) + assert _get_and_patch_org_export(user1_client, s2, can_see=True, can_edit=False) + assert _get_and_patch_org_export(team1_client, s2, can_see=True, can_edit=False) + + # Scenario 6 + # User 2 created an export that requires a special permission on organizer level + # user 1 can see it, because they have permission to see scheduled exports, and change it, because they have + # that special permission + team1.limit_organizer_permissions["organizer.giftcards:read"] = True + team1.save() + user._teamcache = {} + assert _get_and_patch_org_export(user2_client, s2) + assert _get_and_patch_org_export(team1_client, s2) + assert _get_and_patch_org_export(user1_client, s2) + + +def _get_and_patch_event_export(client, scheduled, can_see=True, can_edit=True): + if can_edit is None: + can_edit = can_see + response = client.get( + '/api/v1/organizers/{}/events/{}/scheduled_exports/{}/'.format("dummy", "dummy", scheduled.pk), + ) + if can_see: + assert response.status_code == 200 + else: + assert response.status_code > 400 + assert can_edit is False # Check against useless test usage + return True # No point in editing, we don't have a body + + response = client.patch( + '/api/v1/organizers/{}/events/{}/scheduled_exports/{}/'.format("dummy", "dummy", scheduled.pk), + data=response.data, + format='json', + ) + if can_edit: + assert response.status_code == 200 + else: + assert response.status_code > 400 or (response.status_code == 400 and "export_identifier" in response.data) + return True + + +@pytest.mark.django_db(transaction=True) +def test_event_edit_restrictions(client, event, organizer, user, team): + # This tests the prevention of a possible privilege escalation where user A creates a scheduled export and + # user B has settings permission (= they can see the export configuration), but not enough permission + # to run the export themselves. Without this check, user B could modify the export and add themselves + # as a recipient. Thereby, user B would gain access to data they can't have. + user1_client = APIClient() + user1_client.force_authenticate(user=user) + user2 = User.objects.create_user("dummy2@dummy.dummy", "dummy") + user2_client = APIClient() + user2_client.force_authenticate(user=user2) + team1_client = APIClient() + t = team.tokens.create(name='Foo') + team1_client.credentials(HTTP_AUTHORIZATION='Token ' + t.token) + + event1 = event + + team1 = team + team1.all_organizer_permissions = False + team1.all_event_permissions = False + team1.all_events = False + team1.limit_organizer_permissions = {"organizer.settings.general:write": True} + team1.limit_event_permissions = {"event.orders:read": True, "event.settings.general:write": True} + team1.save() + team1.limit_events.add(event1) + team1.members.add(user) + + t = team.tokens.create(name='Foo') + client.credentials(HTTP_AUTHORIZATION='Token ' + t.token) + + team2 = organizer.teams.create( + all_organizer_permissions=False, all_event_permissions=False, all_events=False, + limit_event_permissions={"event.orders:read": True, "event.vouchers:read": True}, + limit_organizer_permissions={"organizer.giftcards:read": True} + ) + team2.limit_events.add(event1) + team2.members.add(user2) + + # User 2 created an export that requires a special permission on organizer level + # user 1 can see it, because they have permission to see scheduled exports, but can't change it, because they lack + # that special permission + s2 = ScheduledEventExport.objects.create( + event=event, + owner=user2, + export_identifier="dummy_vouchers", + mail_subject="Test", + mail_template="Test", + locale="en", + schedule_rrule="DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO", + schedule_rrule_time=time(2, 30, 0) + ) + user._teamcache = {} + user2._teamcache = {} + assert _get_and_patch_event_export(user2_client, s2) + assert _get_and_patch_event_export(user1_client, s2, can_see=True, can_edit=False) + assert _get_and_patch_event_export(team1_client, s2, can_see=True, can_edit=False) + + # Scenario 6 + # User 2 created an export that requires a special permission on organizer level + # user 1 can see it, because they have permission to see scheduled exports, and change it, because they have + # that special permission + team1.limit_event_permissions["event.vouchers:read"] = True + team1.save() + user._teamcache = {} + assert _get_and_patch_event_export(user2_client, s2) + assert _get_and_patch_event_export(team1_client, s2) + assert _get_and_patch_event_export(user1_client, s2) diff --git a/src/tests/api/test_invoices.py b/src/tests/api/test_invoices.py index 3c2ccd706e..7f3bcf1915 100644 --- a/src/tests/api/test_invoices.py +++ b/src/tests/api/test_invoices.py @@ -340,6 +340,8 @@ def test_invoice_list_multi_filter(token_client, organizer, event, order, order2 @pytest.mark.django_db def test_organizer_level(token_client, organizer, team, event, event2, invoice, invoice2): + team.all_events = True + team.save() resp = token_client.get('/api/v1/organizers/{}/invoices/'.format(organizer.slug)) assert resp.status_code == 200 assert len(resp.data['results']) == 2 diff --git a/src/tests/api/test_oauth.py b/src/tests/api/test_oauth.py index 8a9054c9cd..082ad6e5e2 100644 --- a/src/tests/api/test_oauth.py +++ b/src/tests/api/test_oauth.py @@ -53,8 +53,13 @@ def organizer(): @pytest.fixture def admin_team(organizer): - return Team.objects.create(organizer=organizer, can_change_teams=True, name='Admin team', all_events=True, - can_create_events=True) + return Team.objects.create( + organizer=organizer, + name='Admin team', + all_events=True, + all_event_permissions=True, + all_organizer_permissions=True, + ) @pytest.fixture @@ -387,7 +392,7 @@ def test_token_from_code(client, admin_user, organizer, application: OAuthApplic @pytest.mark.django_db def test_use_token_for_access_one_organizer(client, admin_user, organizer, application: OAuthApplication): o2 = Organizer.objects.create(name='A', slug='a') - t2 = Team.objects.create(organizer=o2, can_change_teams=True, name='Admin team', all_events=True) + t2 = Team.objects.create(organizer=o2, all_organizer_permissions=True, name='Admin team', all_events=True) t2.members.add(admin_user) client.login(email='dummy@dummy.dummy', password='dummy') @@ -434,7 +439,13 @@ def test_use_token_for_access_one_organizer(client, admin_user, organizer, appli @pytest.mark.django_db def test_use_token_for_access_two_organizers(client, admin_user, organizer, application: OAuthApplication): o2 = Organizer.objects.create(name='A', slug='a') - t2 = Team.objects.create(organizer=o2, can_change_teams=True, name='Admin team', all_events=True) + t2 = Team.objects.create( + organizer=o2, + all_event_permissions=True, + all_organizer_permissions=True, + name='Admin team', + all_events=True + ) t2.members.add(admin_user) client.login(email='dummy@dummy.dummy', password='dummy') diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index ff047a00a8..6d8f2cc144 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -186,18 +186,8 @@ def team2(organizer, event2): team2 = Team.objects.create( organizer=organizer, name="Test-Team 2", - can_change_teams=True, - can_manage_gift_cards=True, - can_change_items=True, - can_create_events=True, - can_change_event_settings=True, - can_change_vouchers=True, - can_view_vouchers=True, - can_change_orders=True, - can_manage_customers=True, - can_manage_reusable_media=True, - can_change_organizer_settings=True, - + all_event_permissions=True, + all_organizer_permissions=True, ) team2.limit_events.add(event2) team2.save() @@ -209,6 +199,11 @@ def team2(organizer, event2): def limited_token_client(client, team2): team2.can_view_orders = True team2.can_view_vouchers = True + team2.all_event_permissions = True + team2.limit_event_permissions = { + "event.vouchers:read": True, + "event.orders:read": True, + } team2.save() t = team2.tokens.create(name='Foo') client.credentials(HTTP_AUTHORIZATION='Token ' + t.token) diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index 15da07f72e..d25ec2d1ac 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -43,247 +43,255 @@ from pretix.base.models import Organizer event_urls = [ (None, ''), (None, 'categories/'), - ('can_view_orders', 'invoices/'), + ('event.orders:read', 'invoices/'), (None, 'items/'), - ('can_view_orders', 'orders/'), - ('can_view_orders', 'orderpositions/'), + ('event.orders:read', 'orders/'), + ('event.orders:read', 'orderpositions/'), (None, 'questions/'), (None, 'quotas/'), - ('can_view_vouchers', 'vouchers/'), + ('event.vouchers:read', 'vouchers/'), (None, 'subevents/'), (None, 'taxrules/'), - ('can_view_orders', 'waitinglistentries/'), - ('can_view_orders', 'checkinlists/'), - ('can_view_orders', 'checkins/'), + ('event.orders:read', 'waitinglistentries/'), + ('event.orders:read', 'checkinlists/'), + ('event.orders:read', 'checkins/'), (None, 'seats/'), ] event_permission_sub_urls = [ - ('get', 'can_change_event_settings', 'settings/', 200), - ('patch', 'can_change_event_settings', 'settings/', 200), - ('get', 'can_view_orders', 'revokedsecrets/', 200), - ('get', 'can_view_orders', 'revokedsecrets/1/', 404), - ('get', 'can_view_orders', 'blockedsecrets/', 200), - ('get', 'can_view_orders', 'blockedsecrets/1/', 404), - ('get', 'can_view_orders', 'transactions/', 200), - ('get', 'can_view_orders', 'transactions/1/', 404), - ('get', 'can_view_orders', 'orders/', 200), - ('get', 'can_view_orders', 'orderpositions/', 200), - ('delete', 'can_change_orders', 'orderpositions/1/', 404), - ('post', 'can_change_orders', 'orderpositions/1/price_calc/', 404), - ('get', 'can_view_vouchers', 'vouchers/', 200), - ('get', 'can_view_orders', 'invoices/', 200), - ('get', 'can_view_orders', 'invoices/1/', 404), - ('post', 'can_change_orders', 'invoices/1/regenerate/', 404), - ('post', 'can_change_orders', 'invoices/1/reissue/', 404), - ('post', 'can_change_orders', 'invoices/1/retransmit/', 404), - ('get', 'can_view_orders', 'waitinglistentries/', 200), - ('get', 'can_view_orders', 'waitinglistentries/1/', 404), - ('post', 'can_change_orders', 'waitinglistentries/', 400), - ('delete', 'can_change_orders', 'waitinglistentries/1/', 404), - ('patch', 'can_change_orders', 'waitinglistentries/1/', 404), - ('put', 'can_change_orders', 'waitinglistentries/1/', 404), - ('post', 'can_change_orders', 'waitinglistentries/1/send_voucher/', 404), + ('get', None, 'settings/', 200), + ('patch', 'event.settings.general:write', 'settings/', 200), + ('get', 'event.orders:read', 'revokedsecrets/', 200), + ('get', 'event.orders:read', 'revokedsecrets/1/', 404), + ('get', 'event.orders:read', 'blockedsecrets/', 200), + ('get', 'event.orders:read', 'blockedsecrets/1/', 404), + ('get', 'event.orders:read', 'transactions/', 200), + ('get', 'event.orders:read', 'transactions/1/', 404), + ('get', 'event.orders:read', 'orders/', 200), + ('get', 'event.orders:read', 'orderpositions/', 200), + ('delete', 'event.orders:write', 'orderpositions/1/', 404), + ('post', 'event.orders:write', 'orderpositions/1/price_calc/', 404), + ('get', 'event.vouchers:read', 'vouchers/', 200), + ('get', 'event.orders:read', 'invoices/', 200), + ('get', 'event.orders:read', 'invoices/1/', 404), + ('post', 'event.orders:write', 'invoices/1/regenerate/', 404), + ('post', 'event.orders:write', 'invoices/1/reissue/', 404), + ('post', 'event.orders:write', 'invoices/1/retransmit/', 404), + ('get', 'event.orders:read', 'waitinglistentries/', 200), + ('get', 'event.orders:read', 'waitinglistentries/1/', 404), + ('post', 'event.orders:write', 'waitinglistentries/', 400), + ('delete', 'event.orders:write', 'waitinglistentries/1/', 404), + ('patch', 'event.orders:write', 'waitinglistentries/1/', 404), + ('put', 'event.orders:write', 'waitinglistentries/1/', 404), + ('post', 'event.orders:write', 'waitinglistentries/1/send_voucher/', 404), ('get', None, 'categories/', 200), ('get', None, 'items/', 200), ('get', None, 'questions/', 200), ('get', None, 'quotas/', 200), ('get', None, 'discounts/', 200), - ('post', 'can_change_items', 'items/', 400), + ('post', 'event.items:write', 'items/', 400), ('get', None, 'items/1/', 404), - ('put', 'can_change_items', 'items/1/', 404), - ('patch', 'can_change_items', 'items/1/', 404), - ('delete', 'can_change_items', 'items/1/', 404), - ('post', 'can_change_items', 'categories/', 400), + ('put', 'event.items:write', 'items/1/', 404), + ('patch', 'event.items:write', 'items/1/', 404), + ('delete', 'event.items:write', 'items/1/', 404), + ('post', 'event.items:write', 'categories/', 400), ('get', None, 'categories/1/', 404), - ('put', 'can_change_items', 'categories/1/', 404), - ('patch', 'can_change_items', 'categories/1/', 404), - ('delete', 'can_change_items', 'categories/1/', 404), - ('post', 'can_change_items', 'discounts/', 400), + ('put', 'event.items:write', 'categories/1/', 404), + ('patch', 'event.items:write', 'categories/1/', 404), + ('delete', 'event.items:write', 'categories/1/', 404), + ('post', 'event.items:write', 'discounts/', 400), ('get', None, 'discounts/1/', 404), - ('put', 'can_change_items', 'discounts/1/', 404), - ('patch', 'can_change_items', 'discounts/1/', 404), - ('delete', 'can_change_items', 'discounts/1/', 404), - ('post', 'can_change_items', 'items/1/variations/', 404), + ('put', 'event.items:write', 'discounts/1/', 404), + ('patch', 'event.items:write', 'discounts/1/', 404), + ('delete', 'event.items:write', 'discounts/1/', 404), + ('post', 'event.items:write', 'items/1/variations/', 404), ('get', None, 'items/1/variations/', 404), ('get', None, 'items/1/variations/1/', 404), - ('put', 'can_change_items', 'items/1/variations/1/', 404), - ('patch', 'can_change_items', 'items/1/variations/1/', 404), - ('delete', 'can_change_items', 'items/1/variations/1/', 404), + ('put', 'event.items:write', 'items/1/variations/1/', 404), + ('patch', 'event.items:write', 'items/1/variations/1/', 404), + ('delete', 'event.items:write', 'items/1/variations/1/', 404), ('get', None, 'items/1/addons/', 404), ('get', None, 'items/1/addons/1/', 404), - ('post', 'can_change_items', 'items/1/addons/', 404), - ('put', 'can_change_items', 'items/1/addons/1/', 404), - ('patch', 'can_change_items', 'items/1/addons/1/', 404), - ('delete', 'can_change_items', 'items/1/addons/1/', 404), + ('post', 'event.items:write', 'items/1/addons/', 404), + ('put', 'event.items:write', 'items/1/addons/1/', 404), + ('patch', 'event.items:write', 'items/1/addons/1/', 404), + ('delete', 'event.items:write', 'items/1/addons/1/', 404), ('get', None, 'subevents/', 200), ('get', None, 'subevents/1/', 404), + ('post', 'event.subevents:write', 'subevents/', 400), + ('patch', 'event.subevents:write', 'subevents/1/', 404), + ('put', 'event.subevents:write', 'subevents/1/', 404), ('get', None, 'taxrules/', 200), ('get', None, 'taxrules/1/', 404), - ('post', 'can_change_event_settings', 'taxrules/', 400), - ('put', 'can_change_event_settings', 'taxrules/1/', 404), - ('patch', 'can_change_event_settings', 'taxrules/1/', 404), - ('delete', 'can_change_event_settings', 'taxrules/1/', 404), - ('get', 'can_change_event_settings', 'sendmail_rules/', 200), - ('get', 'can_change_event_settings', 'sendmail_rules/1/', 404), - ('post', 'can_change_event_settings', 'sendmail_rules/', 400), - ('put', 'can_change_event_settings', 'sendmail_rules/1/', 404), - ('patch', 'can_change_event_settings', 'sendmail_rules/1/', 404), - ('delete', 'can_change_event_settings', 'sendmail_rules/1/', 404), - ('get', 'can_view_vouchers', 'vouchers/', 200), - ('get', 'can_view_vouchers', 'vouchers/1/', 404), - ('post', 'can_change_vouchers', 'vouchers/', 201), - ('put', 'can_change_vouchers', 'vouchers/1/', 404), - ('patch', 'can_change_vouchers', 'vouchers/1/', 404), - ('delete', 'can_change_vouchers', 'vouchers/1/', 404), + ('post', 'event.settings.tax:write', 'taxrules/', 400), + ('put', 'event.settings.tax:write', 'taxrules/1/', 404), + ('patch', 'event.settings.tax:write', 'taxrules/1/', 404), + ('delete', 'event.settings.tax:write', 'taxrules/1/', 404), + ('get', 'event.settings.general:write', 'sendmail_rules/', 200), + ('get', 'event.settings.general:write', 'sendmail_rules/1/', 404), + ('post', 'event.settings.general:write', 'sendmail_rules/', 400), + ('put', 'event.settings.general:write', 'sendmail_rules/1/', 404), + ('patch', 'event.settings.general:write', 'sendmail_rules/1/', 404), + ('delete', 'event.settings.general:write', 'sendmail_rules/1/', 404), + ('get', 'event.vouchers:read', 'vouchers/', 200), + ('get', 'event.vouchers:read', 'vouchers/1/', 404), + ('post', 'event.vouchers:write', 'vouchers/', 201), + ('put', 'event.vouchers:write', 'vouchers/1/', 404), + ('patch', 'event.vouchers:write', 'vouchers/1/', 404), + ('delete', 'event.vouchers:write', 'vouchers/1/', 404), ('get', None, 'quotas/', 200), ('get', None, 'quotas/1/', 404), - ('post', 'can_change_items', 'quotas/', 400), - ('put', 'can_change_items', 'quotas/1/', 404), - ('patch', 'can_change_items', 'quotas/1/', 404), - ('delete', 'can_change_items', 'quotas/1/', 404), + ('post', 'event.items:write', 'quotas/', 400), + ('put', 'event.items:write', 'quotas/1/', 404), + ('patch', 'event.items:write', 'quotas/1/', 404), + ('delete', 'event.items:write', 'quotas/1/', 404), ('get', None, 'questions/', 200), ('get', None, 'questions/1/', 404), - ('post', 'can_change_items', 'questions/', 400), - ('put', 'can_change_items', 'questions/1/', 404), - ('patch', 'can_change_items', 'questions/1/', 404), - ('delete', 'can_change_items', 'questions/1/', 404), + ('post', 'event.items:write', 'questions/', 400), + ('put', 'event.items:write', 'questions/1/', 404), + ('patch', 'event.items:write', 'questions/1/', 404), + ('delete', 'event.items:write', 'questions/1/', 404), ('get', None, 'questions/1/options/', 404), ('get', None, 'questions/1/options/1/', 404), - ('put', 'can_change_items', 'questions/1/options/1/', 404), - ('patch', 'can_change_items', 'questions/1/options/1/', 404), - ('delete', 'can_change_items', 'questions/1/options/1/', 404), - ('post', 'can_change_orders', 'orders/', 400), - ('patch', 'can_change_orders', 'orders/ABC12/', 404), - ('post', 'can_change_orders', 'orders/ABC12/mark_paid/', 404), - ('post', 'can_change_orders', 'orders/ABC12/mark_pending/', 404), - ('post', 'can_change_orders', 'orders/ABC12/mark_expired/', 404), - ('post', 'can_change_orders', 'orders/ABC12/mark_canceled/', 404), - ('post', 'can_change_orders', 'orders/ABC12/approve/', 404), - ('post', 'can_change_orders', 'orders/ABC12/deny/', 404), - ('post', 'can_change_orders', 'orders/ABC12/extend/', 400), - ('post', 'can_change_orders', 'orders/ABC12/create_invoice/', 404), - ('post', 'can_change_orders', 'orders/ABC12/resend_link/', 404), - ('post', 'can_change_orders', 'orders/ABC12/regenerate_secrets/', 404), - ('get', 'can_view_orders', 'orders/ABC12/payments/', 404), - ('get', 'can_view_orders', 'orders/ABC12/payments/1/', 404), - ('get', 'can_view_orders', 'orders/ABC12/refunds/', 404), - ('get', 'can_view_orders', 'orders/ABC12/refunds/1/', 404), - ('post', 'can_change_orders', 'orders/ABC12/payments/1/confirm/', 404), - ('post', 'can_change_orders', 'orders/ABC12/payments/1/refund/', 404), - ('post', 'can_change_orders', 'orders/ABC12/payments/1/cancel/', 404), - ('post', 'can_change_orders', 'orders/ABC12/refunds/1/cancel/', 404), - ('post', 'can_change_orders', 'orders/ABC12/refunds/1/process/', 404), - ('post', 'can_change_orders', 'orders/ABC12/refunds/1/done/', 404), - ('get', 'can_view_orders', 'checkinlists/', 200), - ('post', 'can_change_orders', 'checkinlists/1/failed_checkins/', 400), - ('get', 'can_view_orders', 'checkins/', 200), - ('get', 'can_view_orders', 'checkins/1/', 404), - ('post', 'can_change_event_settings', 'checkinlists/', 400), - ('put', 'can_change_event_settings', 'checkinlists/1/', 404), - ('patch', 'can_change_event_settings', 'checkinlists/1/', 404), - ('delete', 'can_change_event_settings', 'checkinlists/1/', 404), - ('get', 'can_view_orders', 'checkinlists/1/positions/', 404), - ('post', 'can_change_orders', 'checkinlists/1/positions/3/redeem/', 404), - ('post', 'can_create_events', 'clone/', 400), - ('get', 'can_view_orders', 'cartpositions/', 200), - ('get', 'can_view_orders', 'cartpositions/1/', 404), - ('post', 'can_change_orders', 'cartpositions/', 400), - ('delete', 'can_change_orders', 'cartpositions/1/', 404), - ('post', 'can_view_orders', 'exporters/invoicedata/run/', 400), - ('get', 'can_view_orders', 'exporters/invoicedata/download/bc3f9884-26ee-425b-8636-80613f84b6fa/3cb49ae6-eda3' - '-4605-814e-099e23777b36/', 404), + ('put', 'event.items:write', 'questions/1/options/1/', 404), + ('patch', 'event.items:write', 'questions/1/options/1/', 404), + ('delete', 'event.items:write', 'questions/1/options/1/', 404), + ('post', 'event.orders:write', 'orders/', 400), + ('patch', 'event.orders:write', 'orders/ABC12/', 404), + ('post', 'event.orders:write', 'orders/ABC12/mark_paid/', 404), + ('post', 'event.orders:write', 'orders/ABC12/mark_pending/', 404), + ('post', 'event.orders:write', 'orders/ABC12/mark_expired/', 404), + ('post', 'event.orders:write', 'orders/ABC12/mark_canceled/', 404), + ('post', 'event.orders:write', 'orders/ABC12/approve/', 404), + ('post', 'event.orders:write', 'orders/ABC12/deny/', 404), + ('post', 'event.orders:write', 'orders/ABC12/extend/', 400), + ('post', 'event.orders:write', 'orders/ABC12/create_invoice/', 404), + ('post', 'event.orders:write', 'orders/ABC12/resend_link/', 404), + ('post', 'event.orders:write', 'orders/ABC12/regenerate_secrets/', 404), + ('get', 'event.orders:read', 'orders/ABC12/payments/', 404), + ('get', 'event.orders:read', 'orders/ABC12/payments/1/', 404), + ('get', 'event.orders:read', 'orders/ABC12/refunds/', 404), + ('get', 'event.orders:read', 'orders/ABC12/refunds/1/', 404), + ('post', 'event.orders:write', 'orders/ABC12/payments/1/confirm/', 404), + ('post', 'event.orders:write', 'orders/ABC12/payments/1/refund/', 404), + ('post', 'event.orders:write', 'orders/ABC12/payments/1/cancel/', 404), + ('post', 'event.orders:write', 'orders/ABC12/refunds/1/cancel/', 404), + ('post', 'event.orders:write', 'orders/ABC12/refunds/1/process/', 404), + ('post', 'event.orders:write', 'orders/ABC12/refunds/1/done/', 404), + ('get', 'event.orders:read', 'checkinlists/', 200), + ('post', 'event.orders:write', 'checkinlists/1/failed_checkins/', 400), + ('get', 'event.orders:read', 'checkins/', 200), + ('get', 'event.orders:read', 'checkins/1/', 404), + ('post', 'event.settings.general:write', 'checkinlists/', 400), + ('put', 'event.settings.general:write', 'checkinlists/1/', 404), + ('patch', 'event.settings.general:write', 'checkinlists/1/', 404), + ('delete', 'event.settings.general:write', 'checkinlists/1/', 404), + ('get', 'event.orders:read', 'checkinlists/1/positions/', 404), + ('post', 'event.orders:write', 'checkinlists/1/positions/3/redeem/', 404), + ('post', ('organizer.events:create', 'event.settings.general:write'), 'clone/', 400), + ('get', 'event.orders:read', 'cartpositions/', 200), + ('get', 'event.orders:read', 'cartpositions/1/', 404), + ('post', 'event.orders:write', 'cartpositions/', 400), + ('delete', 'event.orders:write', 'cartpositions/1/', 404), + ('post', 'event.orders:read', 'exporters/invoicedata/run/', 400), ('get', None, 'item_meta_properties/', 200), ('get', None, 'item_meta_properties/0/', 404), - ('post', 'can_change_event_settings', 'item_meta_properties/', 400), - ('patch', 'can_change_event_settings', 'item_meta_properties/0/', 404), - ('delete', 'can_change_event_settings', 'item_meta_properties/0/', 404), + ('post', 'event.settings.general:write', 'item_meta_properties/', 400), + ('patch', 'event.settings.general:write', 'item_meta_properties/0/', 404), + ('delete', 'event.settings.general:write', 'item_meta_properties/0/', 404), ('get', None, 'seats/', 200), - ('get', 'can_view_orders', 'seats/?expand=orderposition', 200), - ('get', 'can_view_orders', 'seats/?expand=cartposition', 200), - ('get', 'can_view_vouchers', 'seats/?expand=voucher', 200), + ('get', 'event.orders:read', 'seats/?expand=orderposition', 200), + ('get', 'event.orders:read', 'seats/?expand=cartposition', 200), + ('get', 'event.vouchers:read', 'seats/?expand=voucher', 200), ('get', None, 'seats/1/', 404), - ('patch', 'can_change_event_settings', 'seats/1/', 404), + ('patch', 'event.settings.general:write', 'seats/1/', 404), ] org_permission_sub_urls = [ - ('patch', 'can_change_organizer_settings', '', 200), - ('patch', 'can_change_organizer_settings', 'settings/', 200), - ('get', 'can_change_organizer_settings', 'webhooks/', 200), - ('post', 'can_change_organizer_settings', 'webhooks/', 400), - ('get', 'can_change_organizer_settings', 'webhooks/1/', 404), - ('put', 'can_change_organizer_settings', 'webhooks/1/', 404), - ('patch', 'can_change_organizer_settings', 'webhooks/1/', 404), - ('delete', 'can_change_organizer_settings', 'webhooks/1/', 404), - ('get', 'can_manage_customers', 'customers/', 200), - ('post', 'can_manage_customers', 'customers/', 201), - ('get', 'can_manage_customers', 'customers/1/', 404), - ('patch', 'can_manage_customers', 'customers/1/', 404), - ('post', 'can_manage_customers', 'customers/1/anonymize/', 404), - ('put', 'can_manage_customers', 'customers/1/', 404), - ('delete', 'can_manage_customers', 'customers/1/', 404), - ('get', 'can_manage_customers', 'memberships/', 200), - ('post', 'can_manage_customers', 'memberships/', 400), - ('get', 'can_manage_customers', 'memberships/1/', 404), - ('patch', 'can_manage_customers', 'memberships/1/', 404), - ('put', 'can_manage_customers', 'memberships/1/', 404), - ('delete', 'can_manage_customers', 'memberships/1/', 404), - ('get', 'can_change_organizer_settings', 'saleschannels/', 200), - ('post', 'can_change_organizer_settings', 'saleschannels/', 400), - ('get', 'can_change_organizer_settings', 'saleschannels/web/', 200), - ('patch', 'can_change_organizer_settings', 'saleschannels/web/', 200), - ('put', 'can_change_organizer_settings', 'saleschannels/api.1/', 404), - ('delete', 'can_change_organizer_settings', 'saleschannels/api.1/', 404), - ('get', 'can_change_organizer_settings', 'membershiptypes/', 200), - ('post', 'can_change_organizer_settings', 'membershiptypes/', 400), - ('get', 'can_change_organizer_settings', 'membershiptypes/1/', 404), - ('patch', 'can_change_organizer_settings', 'membershiptypes/1/', 404), - ('put', 'can_change_organizer_settings', 'membershiptypes/1/', 404), - ('delete', 'can_change_organizer_settings', 'membershiptypes/1/', 404), - ('get', 'can_manage_gift_cards', 'giftcards/', 200), - ('post', 'can_manage_gift_cards', 'giftcards/', 400), - ('get', 'can_manage_gift_cards', 'giftcards/1/', 404), - ('put', 'can_manage_gift_cards', 'giftcards/1/', 404), - ('patch', 'can_manage_gift_cards', 'giftcards/1/', 404), - ('get', 'can_manage_gift_cards', 'giftcards/1/transactions/', 404), - ('get', 'can_manage_gift_cards', 'giftcards/1/transactions/1/', 404), - ('get', 'can_change_organizer_settings', 'devices/', 200), - ('post', 'can_change_organizer_settings', 'devices/', 400), - ('get', 'can_change_organizer_settings', 'devices/1/', 404), - ('put', 'can_change_organizer_settings', 'devices/1/', 404), - ('patch', 'can_change_organizer_settings', 'devices/1/', 404), - ('get', 'can_change_teams', 'teams/', 200), - ('post', 'can_change_teams', 'teams/', 400), - ('get', 'can_change_teams', 'teams/{team_id}/', 200), - ('put', 'can_change_teams', 'teams/{team_id}/', 400), - ('patch', 'can_change_teams', 'teams/{team_id}/', 200), - ('get', 'can_change_teams', 'teams/{team_id}/members/', 200), - ('delete', 'can_change_teams', 'teams/{team_id}/members/2/', 404), - ('get', 'can_change_teams', 'teams/{team_id}/invites/', 200), - ('get', 'can_change_teams', 'teams/{team_id}/invites/2/', 404), - ('delete', 'can_change_teams', 'teams/{team_id}/invites/2/', 404), - ('post', 'can_change_teams', 'teams/{team_id}/invites/', 400), - ('get', 'can_change_teams', 'teams/{team_id}/tokens/', 200), - ('get', 'can_change_teams', 'teams/{team_id}/tokens/0/', 404), - ('delete', 'can_change_teams', 'teams/{team_id}/tokens/0/', 404), - ('post', 'can_change_teams', 'teams/{team_id}/tokens/', 400), - ('get', 'can_manage_reusable_media', 'reusablemedia/1/', 404), + ('patch', 'organizer.settings.general:write', '', 200), + ('patch', 'organizer.settings.general:write', 'settings/', 200), + ('get', 'organizer.settings.general:write', 'webhooks/', 200), + ('post', 'organizer.settings.general:write', 'webhooks/', 400), + ('get', 'organizer.settings.general:write', 'webhooks/1/', 404), + ('put', 'organizer.settings.general:write', 'webhooks/1/', 404), + ('patch', 'organizer.settings.general:write', 'webhooks/1/', 404), + ('delete', 'organizer.settings.general:write', 'webhooks/1/', 404), + ('get', 'organizer.customers:read', 'customers/', 200), + ('post', 'organizer.customers:write', 'customers/', 201), + ('get', 'organizer.customers:read', 'customers/1/', 404), + ('patch', 'organizer.customers:write', 'customers/1/', 404), + ('post', 'organizer.customers:write', 'customers/1/anonymize/', 404), + ('put', 'organizer.customers:write', 'customers/1/', 404), + ('delete', 'organizer.customers:write', 'customers/1/', 404), + ('get', 'organizer.customers:read', 'memberships/', 200), + ('post', 'organizer.customers:write', 'memberships/', 400), + ('get', 'organizer.customers:read', 'memberships/1/', 404), + ('patch', 'organizer.customers:write', 'memberships/1/', 404), + ('put', 'organizer.customers:write', 'memberships/1/', 404), + ('delete', 'organizer.customers:write', 'memberships/1/', 404), + ('get', 'organizer.settings.general:write', 'saleschannels/', 200), + ('post', 'organizer.settings.general:write', 'saleschannels/', 400), + ('get', 'organizer.settings.general:write', 'saleschannels/web/', 200), + ('patch', 'organizer.settings.general:write', 'saleschannels/web/', 200), + ('put', 'organizer.settings.general:write', 'saleschannels/api.1/', 404), + ('delete', 'organizer.settings.general:write', 'saleschannels/api.1/', 404), + ('get', 'organizer.settings.general:write', 'membershiptypes/', 200), + ('post', 'organizer.settings.general:write', 'membershiptypes/', 400), + ('get', 'organizer.settings.general:write', 'membershiptypes/1/', 404), + ('patch', 'organizer.settings.general:write', 'membershiptypes/1/', 404), + ('put', 'organizer.settings.general:write', 'membershiptypes/1/', 404), + ('delete', 'organizer.settings.general:write', 'membershiptypes/1/', 404), + ('get', 'organizer.giftcards:read', 'giftcards/', 200), + ('post', 'organizer.giftcards:write', 'giftcards/', 400), + ('get', 'organizer.giftcards:read', 'giftcards/1/', 404), + ('put', 'organizer.giftcards:write', 'giftcards/1/', 404), + ('patch', 'organizer.giftcards:write', 'giftcards/1/', 404), + ('get', 'organizer.giftcards:read', 'giftcards/1/transactions/', 404), + ('get', 'organizer.giftcards:read', 'giftcards/1/transactions/1/', 404), + ('get', 'organizer.devices:read', 'devices/', 200), + ('post', 'organizer.devices:write', 'devices/', 400), + ('get', 'organizer.devices:read', 'devices/1/', 404), + ('put', 'organizer.devices:write', 'devices/1/', 404), + ('patch', 'organizer.devices:write', 'devices/1/', 404), + ('get', 'organizer.teams:write', 'teams/', 200), + ('post', 'organizer.teams:write', 'teams/', 400), + ('get', 'organizer.teams:write', 'teams/{team_id}/', 200), + ('put', 'organizer.teams:write', 'teams/{team_id}/', 400), + ('patch', 'organizer.teams:write', 'teams/{team_id}/', 200), + ('get', 'organizer.teams:write', 'teams/{team_id}/members/', 200), + ('delete', 'organizer.teams:write', 'teams/{team_id}/members/2/', 404), + ('get', 'organizer.teams:write', 'teams/{team_id}/invites/', 200), + ('get', 'organizer.teams:write', 'teams/{team_id}/invites/2/', 404), + ('delete', 'organizer.teams:write', 'teams/{team_id}/invites/2/', 404), + ('post', 'organizer.teams:write', 'teams/{team_id}/invites/', 400), + ('get', 'organizer.teams:write', 'teams/{team_id}/tokens/', 200), + ('get', 'organizer.teams:write', 'teams/{team_id}/tokens/0/', 404), + ('delete', 'organizer.teams:write', 'teams/{team_id}/tokens/0/', 404), + ('post', 'organizer.teams:write', 'teams/{team_id}/tokens/', 400), + ('get', 'organizer.reusablemedia:read', 'reusablemedia/', 200), + ('get', 'organizer.reusablemedia:read', 'reusablemedia/1/', 404), + ('post', 'organizer.reusablemedia:write', 'reusablemedia/', 400), + ('patch', 'organizer.reusablemedia:write', 'reusablemedia/1/', 404), + ('put', 'organizer.reusablemedia:write', 'reusablemedia/1/', 404), + ('post', 'organizer.seatingplans:write', 'seatingplans/', 400), + ('patch', 'organizer.seatingplans:write', 'seatingplans/1/', 404), + ('put', 'organizer.seatingplans:write', 'seatingplans/1/', 404), ] event_permission_root_urls = [ - ('post', 'can_create_events', 400), - ('put', 'can_change_event_settings', 400), - ('patch', 'can_change_event_settings', 200), - ('delete', 'can_change_event_settings', 204), + ('post', 'organizer.events:create', 400), + ('put', 'event.settings.general:write', 400), + ('patch', 'event.settings.general:write', 200), + ('delete', 'event.settings.general:write', 204), ] @pytest.fixture def token_client(client, team): - team.can_view_orders = True - team.can_view_vouchers = True - team.can_change_items = True + team.limit_event_permissions["event.orders:read"] = True + team.limit_event_permissions["event.vouchers:read"] = True + team.limit_event_permissions["event.items:write"] = True team.save() t = team.tokens.create(name='Foo') client.credentials(HTTP_AUTHORIZATION='Token ' + t.token) @@ -329,7 +337,7 @@ def test_event_allowed_all_events(token_client, team, organizer, event, url): @pytest.mark.parametrize("url", event_urls) def test_event_allowed_all_events_device(device_client, device, organizer, event, url): resp = device_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url[1])) - if url[0] is None or url[0] in device.permission_set(): + if url[0] is None or url[0] in device._event_permission_set(): assert resp.status_code == 200 else: assert resp.status_code == 403 @@ -352,7 +360,7 @@ def test_event_allowed_limit_events_device(device_client, organizer, device, eve device.save() device.limit_events.add(event) resp = device_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url[1])) - if url[0] is None or url[0] in device.permission_set(): + if url[0] is None or url[0] in device._event_permission_set(): assert resp.status_code == 200 else: assert resp.status_code == 403 @@ -387,8 +395,14 @@ def test_event_not_existing(token_client, organizer, url, event): @pytest.mark.parametrize("urlset", event_permission_sub_urls) def test_token_event_subresources_permission_allowed(token_client, team, organizer, event, urlset): team.all_events = True - if urlset[1]: - setattr(team, urlset[1], True) + if urlset[1] is not None: + for t in ((urlset[1],) if isinstance(urlset[1], str) else urlset[1]): + if "organizer" in urlset[1]: + team.all_organizer_permissions = False + team.limit_organizer_permissions[t] = True + else: + team.all_event_permissions = False + team.limit_event_permissions[t] = True team.save() resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/events/{}/{}'.format( organizer.slug, event.slug, urlset[2])) @@ -402,7 +416,10 @@ def test_token_event_subresources_permission_not_allowed(token_client, team, org team.all_events = False else: team.all_events = True - setattr(team, urlset[1], False) + team.all_event_permissions = False + team.limit_event_permissions.pop(urlset[1], None) + team.all_organizer_permissions = False + team.limit_organizer_permissions.pop(urlset[1], None) team.save() resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/events/{}/{}'.format( organizer.slug, event.slug, urlset[2])) @@ -416,7 +433,14 @@ def test_token_event_subresources_permission_not_allowed(token_client, team, org @pytest.mark.parametrize("urlset", event_permission_root_urls) def test_token_event_permission_allowed(token_client, team, organizer, event, urlset): team.all_events = True - setattr(team, urlset[1], True) + if urlset[1] is not None: + for t in ((urlset[1],) if isinstance(urlset[1], str) else urlset[1]): + if "organizer" in urlset[1]: + team.all_organizer_permissions = False + team.limit_organizer_permissions[t] = True + else: + team.all_event_permissions = False + team.limit_event_permissions[t] = True team.save() if urlset[0] == 'post': resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/events/'.format(organizer.slug)) @@ -429,7 +453,9 @@ def test_token_event_permission_allowed(token_client, team, organizer, event, ur @pytest.mark.parametrize("urlset", event_permission_root_urls) def test_token_event_permission_not_allowed(token_client, team, organizer, event, urlset): team.all_events = True - setattr(team, urlset[1], False) + team.all_event_permissions = False + team.limit_event_permissions.pop(urlset[1], None) + team.all_organizer_permissions = False team.save() if urlset[0] == 'post': resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/events/'.format(organizer.slug)) @@ -537,11 +563,11 @@ def test_update_session_activity(user_client, team, organizer, event): @pytest.mark.django_db @pytest.mark.parametrize("urlset", event_permission_sub_urls) def test_device_subresource_permission_check(device_client, device, organizer, event, urlset): - if urlset == ('get', 'can_change_event_settings', 'settings/', 200): + if urlset == ('get', 'event.settings.general:write', 'settings/', 200): return resp = getattr(device_client, urlset[0])('/api/v1/organizers/{}/events/{}/{}'.format( organizer.slug, event.slug, urlset[2])) - if urlset[1] is None or urlset[1] in device.permission_set(): + if urlset[1] is None or urlset[1] in device._event_permission_set(): assert resp.status_code == urlset[3] else: if urlset[3] == 404: @@ -555,7 +581,8 @@ def test_device_subresource_permission_check(device_client, device, organizer, e def test_token_org_subresources_permission_allowed(token_client, team, organizer, event, urlset): team.all_events = True if urlset[1]: - setattr(team, urlset[1], True) + team.all_organizer_permissions = False + team.limit_organizer_permissions[urlset[1]] = True team.save() resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/{}'.format( organizer.slug, urlset[2].format(team_id=team.pk))) @@ -568,8 +595,8 @@ def test_token_org_subresources_permission_not_allowed(token_client, team, organ if urlset[1] is None: team.all_events = False else: - team.all_events = True - setattr(team, urlset[1], False) + team.all_organizer_permissions = False + team.limit_organizer_permissions.pop(urlset[1], None) team.save() resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/{}'.format( organizer.slug, urlset[2].format(team_id=team.pk))) diff --git a/src/tests/api/test_reusable_media.py b/src/tests/api/test_reusable_media.py index 2938e524a6..30614e67be 100644 --- a/src/tests/api/test_reusable_media.py +++ b/src/tests/api/test_reusable_media.py @@ -119,7 +119,39 @@ def test_medium_list(token_client, organizer, event, medium): @pytest.mark.django_db -def test_medium_detail(token_client, organizer, event, medium, giftcard, customer): +def test_medium_detail_permission_missing(token_client, organizer, event, medium, giftcard, customer, team): + team.all_organizer_permissions = False + team.limit_organizer_permissions = { + "organizer.reusablemedia:read": True, + } + team.save() + resp = token_client.get( + '/api/v1/organizers/{}/reusablemedia/{}/?expand=linked_giftcard'.format( + organizer.slug, medium.pk + ) + ) + assert resp.status_code == 403 + assert "No permission to access gift card details." in str(resp.data) + + resp = token_client.get( + '/api/v1/organizers/{}/reusablemedia/{}/?expand=customer'.format( + organizer.slug, medium.pk + ) + ) + assert resp.status_code == 403 + assert "No permission to access customer details." in str(resp.data) + + +@pytest.mark.django_db +def test_medium_detail(token_client, organizer, event, medium, giftcard, customer, team): + team.all_organizer_permissions = False + team.limit_organizer_permissions = { + "organizer.reusablemedia:read": True, + "organizer.customers:read": True, + "organizer.giftcards:read": True, + } + team.save() + res = dict(TEST_MEDIUM_RES) res["id"] = medium.pk res["created"] = medium.created.isoformat().replace('+00:00', 'Z') @@ -340,7 +372,16 @@ def test_medium_lookup_not_found(token_client, organizer, organizer2, medium): @pytest.mark.django_db -def test_medium_lookup_autocreate(token_client, organizer): +def test_medium_lookup_autocreate(token_client, organizer, team): + team.all_organizer_permissions = False + team.limit_organizer_permissions = { + "organizer.reusablemedia:read": True, + "organizer.reusablemedia:write": True, + "organizer.customers:read": True, + "organizer.giftcards:read": True, + } + team.save() + # Disabled resp = token_client.post( '/api/v1/organizers/{}/reusablemedia/lookup/'.format(organizer.slug), @@ -386,7 +427,15 @@ def test_medium_lookup_autocreate(token_client, organizer): @pytest.mark.django_db -def test_medium_autocreate_giftcard(token_client, organizer): +def test_medium_autocreate_giftcard(token_client, organizer, team): + team.all_organizer_permissions = False + team.limit_organizer_permissions = { + "organizer.reusablemedia:write": True, + "organizer.reusablemedia:read": True, + "organizer.customers:read": True, + "organizer.giftcards:read": True, + } + team.save() organizer.settings.reusable_media_type_nfc_mf0aes_autocreate_giftcard = True organizer.settings.reusable_media_type_nfc_mf0aes_autocreate_giftcard_currency = 'USD' resp = token_client.post( diff --git a/src/tests/api/test_subevents.py b/src/tests/api/test_subevents.py index 24478d89be..7d06c97c36 100644 --- a/src/tests/api/test_subevents.py +++ b/src/tests/api/test_subevents.py @@ -260,7 +260,8 @@ def test_all_subevents_list_filter(token_client, organizer, event, subevent): def test_subevent_create(team, token_client, organizer, event, subevent, meta_prop, item): meta_prop.choices = [{"key": "Conference", "label": {"en": "Conference"}}, {"key": "Workshop", "label": {"en": "Workshop"}}] meta_prop.save() - team.can_change_organizer_settings = False + team.limit_organizer_permissions = {"organizer.events:create": True} + team.all_organizer_permissions = False team.save() organizer.meta_properties.create( name="protected", protected=True diff --git a/src/tests/api/test_teams.py b/src/tests/api/test_teams.py index e687d96501..dca30e609c 100644 --- a/src/tests/api/test_teams.py +++ b/src/tests/api/test_teams.py @@ -31,6 +31,7 @@ def second_team(organizer, event): t = organizer.teams.create( name='User team', all_events=False, + limit_event_permissions={"event.orders:read": True}, ) t.limit_events.add(event) return t @@ -41,8 +42,10 @@ TEST_TEAM_RES = { 'can_change_teams': True, 'can_change_organizer_settings': True, 'can_manage_gift_cards': True, 'can_manage_customers': True, 'can_manage_reusable_media': True, 'can_change_event_settings': True, 'can_change_items': True, 'can_view_orders': True, 'can_change_orders': True, - 'can_view_vouchers': True, 'can_change_vouchers': True, 'can_checkin_orders': False, + 'can_view_vouchers': True, 'can_change_vouchers': True, 'can_checkin_orders': True, 'require_2fa': False, + 'all_event_permissions': True, 'limit_event_permissions': [], + 'all_organizer_permissions': True, 'limit_organizer_permissions': [], } SECOND_TEAM_RES = { @@ -50,9 +53,11 @@ SECOND_TEAM_RES = { 'can_create_events': False, 'can_manage_customers': False, 'can_manage_reusable_media': False, 'can_change_teams': False, 'can_change_organizer_settings': False, 'can_manage_gift_cards': False, - 'can_change_event_settings': False, 'can_change_items': False, 'can_view_orders': False, 'can_change_orders': False, + 'can_change_event_settings': False, 'can_change_items': False, 'can_view_orders': True, 'can_change_orders': False, 'can_view_vouchers': False, 'can_change_vouchers': False, 'can_checkin_orders': False, 'require_2fa': False, + 'all_event_permissions': False, 'limit_event_permissions': ["event.orders:read"], + 'all_organizer_permissions': False, 'limit_organizer_permissions': [], } @@ -95,8 +100,80 @@ def test_team_create(token_client, organizer, event): @pytest.mark.django_db -def test_team_update(token_client, organizer, event, second_team): - assert not second_team.can_change_event_settings +def test_team_update(token_client, organizer, event, team, second_team): + resp = token_client.patch( + '/api/v1/organizers/{}/teams/{}/'.format(organizer.slug, second_team.pk), + { + 'all_event_permission': False, + 'limit_event_permissions': ["event.settings.general:write"], + }, + format='json' + ) + assert resp.status_code == 200 + second_team.refresh_from_db() + assert second_team.limit_event_permissions == { + 'event.settings.general:write': True, + } + assert not second_team.all_event_permissions + + resp = token_client.patch( + '/api/v1/organizers/{}/teams/{}/'.format(organizer.slug, second_team.pk), + { + 'all_event_permission': False, + 'limit_event_permissions': ["INVALID"], + }, + format='json' + ) + assert resp.status_code == 400 + assert "invalid" in str(resp.data) + + resp = token_client.patch( + '/api/v1/organizers/{}/teams/{}/'.format(organizer.slug, team.pk), + { + 'limit_event_permissions': ["event.settings.general:write"], + }, + format='json' + ) + assert resp.status_code == 400 + assert "Do not set both" in str(resp.data) + + resp = token_client.patch( + '/api/v1/organizers/{}/teams/{}/'.format(organizer.slug, second_team.pk), + { + 'all_organizer_permissions': False, + 'limit_organizer_permissions': ["organizer.devices:write"], + }, + format='json' + ) + assert resp.status_code == 400 + assert ("For permission group organizer.devices, the valid combinations of actions are '' or 'read' or " + "'read,write' but you tried to set 'write'.") in str(resp.data) + + resp = token_client.patch( + '/api/v1/organizers/{}/teams/{}/'.format(organizer.slug, second_team.pk), + { + 'all_organizer_permissions': True, + 'limit_organizer_permissions': ["organizer.events:create"], + }, + format='json' + ) + assert resp.status_code == 400 + assert "Do not set both" in str(resp.data) + + resp = token_client.patch( + '/api/v1/organizers/{}/teams/{}/'.format(organizer.slug, second_team.pk), + { + 'all_events': True, + }, + format='json' + ) + assert resp.status_code == 400 + assert "Do not set both" in str(resp.data) + + +@pytest.mark.django_db +@pytest.mark.filterwarnings("ignore") +def test_team_update_legacy_add_perm(token_client, organizer, event, second_team): resp = token_client.patch( '/api/v1/organizers/{}/teams/{}/'.format(organizer.slug, second_team.pk), { @@ -107,15 +184,95 @@ def test_team_update(token_client, organizer, event, second_team): assert resp.status_code == 200 second_team.refresh_from_db() assert second_team.can_change_event_settings + assert second_team.limit_event_permissions == { + "event.settings.general:write": True, + "event.settings.payment:write": True, + "event.settings.tax:write": True, + "event.settings.invoicing:write": True, + "event.subevents:write": True, + "event.orders:read": True, + } + + +@pytest.mark.django_db +@pytest.mark.filterwarnings("ignore") +def test_team_update_legacy_add_all_perms(token_client, organizer, event, second_team): + resp = token_client.patch( + '/api/v1/organizers/{}/teams/{}/'.format(organizer.slug, second_team.pk), + { + 'can_change_event_settings': True, + 'can_change_items': True, + # can_view_orders omitted because already set + 'can_change_orders': True, + 'can_checkin_orders': True, + 'can_view_vouchers': True, + 'can_change_vouchers': True, + }, + format='json' + ) + assert resp.status_code == 200 + second_team.refresh_from_db() + assert second_team.all_event_permissions + assert second_team.limit_event_permissions == {} resp = token_client.patch( '/api/v1/organizers/{}/teams/{}/'.format(organizer.slug, second_team.pk), { - 'all_events': True, + 'can_create_events': True, + 'can_change_organizer_settings': True, + 'can_change_teams': True, + 'can_manage_gift_cards': True, + 'can_manage_customers': True, + 'can_manage_reusable_media': True, + }, + format='json' + ) + assert resp.status_code == 200 + second_team.refresh_from_db() + assert second_team.all_organizer_permissions + assert second_team.limit_organizer_permissions == {} + + resp = token_client.patch( + '/api/v1/organizers/{}/teams/{}/'.format(organizer.slug, second_team.pk), + { + 'can_change_teams': False, + }, + format='json' + ) + assert resp.status_code == 200 + second_team.refresh_from_db() + assert not second_team.all_organizer_permissions + assert second_team.limit_organizer_permissions == { + 'organizer.settings.general:write': True, + 'organizer.giftcards:read': True, + 'organizer.giftcards:write': True, + 'organizer.events:create': True, + 'organizer.customers:read': True, + 'organizer.customers:write': True, + 'organizer.reusablemedia:read': True, + 'organizer.reusablemedia:write': True, + 'organizer.devices:read': True, + 'organizer.devices:write': True, + 'organizer.seatingplans:write': True, + 'organizer.outgoingmails:read': True, + } + assert resp.data["can_manage_customers"] is True + assert resp.data["can_change_teams"] is False + + +@pytest.mark.django_db +@pytest.mark.filterwarnings("ignore") +def test_team_update_legacy_and_new(token_client, organizer, event, second_team): + resp = token_client.patch( + '/api/v1/organizers/{}/teams/{}/'.format(organizer.slug, second_team.pk), + { + 'can_change_event_settings': True, + 'all_organizer_permissions': True, }, format='json' ) assert resp.status_code == 400 + assert "You cannot set deprecated and current permission attributes" in str(resp.data) @pytest.mark.django_db diff --git a/src/tests/api/test_transactions.py b/src/tests/api/test_transactions.py index 9a0dea3051..8bec030d09 100644 --- a/src/tests/api/test_transactions.py +++ b/src/tests/api/test_transactions.py @@ -242,7 +242,8 @@ def test_organizer_list(token_client, team, organizer, event, order, item, taxru assert resp.data["count"] == 0 team.all_events = True - team.can_view_orders = False + team.limit_event_permissions = {"event.vouchers:read": True} + team.all_event_permissions = False team.save() resp = token_client.get( diff --git a/src/tests/base/test_export.py b/src/tests/base/test_export.py index d07bb576d4..80c5deb877 100644 --- a/src/tests/base/test_export.py +++ b/src/tests/base/test_export.py @@ -48,7 +48,7 @@ def event(): @pytest.fixture def team(event): - return event.organizer.teams.create(all_events=True, can_view_orders=True) + return event.organizer.teams.create(all_events=True, all_event_permissions=True) @pytest.fixture @@ -105,7 +105,7 @@ def test_event_fail_invalid_config(event, user): assert s.error_counter == 1 assert len(djmail.outbox) == 1 assert djmail.outbox[0].subject == "Export failed" - assert "Reason: Export type not found." in djmail.outbox[0].body + assert "Reason: Export type not found" in djmail.outbox[0].body assert djmail.outbox[0].to == [user.email] @@ -143,7 +143,8 @@ def test_event_fail_user_no_permission(event, user, team): s.error_counter = 0 s.save() - team.can_view_orders = False + team.all_event_permissions = False + team.limit_event_permissions = {"event.vouchers:read": True} team.save() run_scheduled_exports(None) @@ -152,7 +153,7 @@ def test_event_fail_user_no_permission(event, user, team): assert s.error_counter == 1 assert len(djmail.outbox) == 1 assert djmail.outbox[0].subject == "Export failed" - assert "Reason: Permission denied." in djmail.outbox[0].body + assert "Reason: Export type not found or permission denied." in djmail.outbox[0].body assert djmail.outbox[0].to == [user.email] @@ -235,7 +236,7 @@ def test_organizer_fail_invalid_config(event, user): assert s.error_counter == 1 assert len(djmail.outbox) == 1 assert djmail.outbox[0].subject == "Export failed" - assert "Reason: Export type not found." in djmail.outbox[0].body + assert "Reason: Export type not found" in djmail.outbox[0].body assert djmail.outbox[0].to == [user.email] @@ -273,7 +274,8 @@ def test_organizer_fail_user_does_not_have_specific_permission(event, user, team s.error_counter = 0 s.save() - team.can_manage_customers = False + team.all_event_permissions = False + team.limit_event_permissions = {"organizer.giftcards:write": True} team.save() run_scheduled_exports(None) @@ -282,7 +284,7 @@ def test_organizer_fail_user_does_not_have_specific_permission(event, user, team assert s.error_counter == 1 assert len(djmail.outbox) == 1 assert djmail.outbox[0].subject == "Export failed" - assert "Reason: Permission denied." in djmail.outbox[0].body + assert "Reason: Export type not found or permission denied." in djmail.outbox[0].body assert djmail.outbox[0].to == [user.email] diff --git a/src/tests/base/test_notifications.py b/src/tests/base/test_notifications.py index c3570be104..31e4386e93 100644 --- a/src/tests/base/test_notifications.py +++ b/src/tests/base/test_notifications.py @@ -65,7 +65,7 @@ def order(event): @pytest.fixture def team(event): - return event.organizer.teams.create(all_events=True, can_view_orders=True) + return event.organizer.teams.create(all_events=True, all_event_permissions=True) @pytest.fixture @@ -142,7 +142,8 @@ def test_notification_ignore_same_user(event, order, user, monkeypatch_on_commit @pytest.mark.django_db def test_notification_ignore_insufficient_permissions(event, order, user, team, monkeypatch_on_commit): djmail.outbox = [] - team.can_view_orders = False + team.all_event_permissions = False + team.limit_event_permissions = {"event.vouchers:read": True} team.save() user.notification_settings.create( method='mail', event=event, action_type='pretix.event.order.paid', enabled=True diff --git a/src/tests/base/test_permissions.py b/src/tests/base/test_permissions.py index 366c40cfa3..9ac989a671 100644 --- a/src/tests/base/test_permissions.py +++ b/src/tests/base/test_permissions.py @@ -66,13 +66,6 @@ def admin_request(admin, client): return r -@pytest.mark.django_db -def test_invalid_permission(event, user): - team = Team.objects.create(organizer=event.organizer) - with pytest.raises(ValueError): - team.has_permission('FOOOOOOBAR') - - @pytest.mark.django_db def test_any_event_permission_limited(event, user): user._teamcache = {} @@ -117,59 +110,59 @@ def test_any_event_permission_all(event, user): @pytest.mark.django_db def test_specific_event_permission_limited(event, user): user._teamcache = {} - assert not user.has_event_permission(event.organizer, event, 'can_change_orders') + assert not user.has_event_permission(event.organizer, event, 'event.orders:write') - team = Team.objects.create(organizer=event.organizer, can_change_orders=True) + team = Team.objects.create(organizer=event.organizer, limit_event_permissions={"event.orders:write": True}) user._teamcache = {} - assert not user.has_event_permission(event.organizer, event, 'can_change_orders') + assert not user.has_event_permission(event.organizer, event, 'event.orders:write') team.members.add(user) user._teamcache = {} - assert not user.has_event_permission(event.organizer, event, 'can_change_orders') + assert not user.has_event_permission(event.organizer, event, 'event.orders:write') team.limit_events.add(event) user._teamcache = {} - assert user.has_event_permission(event.organizer, event, 'can_change_orders') - assert not user.has_event_permission(event.organizer, event, 'can_change_event_settings') + assert user.has_event_permission(event.organizer, event, 'event.orders:write') + assert not user.has_event_permission(event.organizer, event, 'event.settings.general:write') - assert user.has_event_permission(event.organizer, event, ('can_change_orders', 'can_change_event_settings')) - assert not user.has_event_permission(event.organizer, event, ('can_change_teams', 'can_change_event_settings')) + assert user.has_event_permission(event.organizer, event, ('event.orders:write', 'event.settings.general:write')) + assert not user.has_event_permission(event.organizer, event, ('event.items:write', 'event.settings.general:write')) - team.can_change_orders = False + team.limit_event_permissions = {} team.save() user._teamcache = {} - assert not user.has_event_permission(event.organizer, event, 'can_change_orders') + assert not user.has_event_permission(event.organizer, event, 'event.orders:write') @pytest.mark.django_db def test_specific_event_permission_all(event, user): user._teamcache = {} - assert not user.has_event_permission(event.organizer, event, 'can_change_orders') + assert not user.has_event_permission(event.organizer, event, 'event.orders:write') - team = Team.objects.create(organizer=event.organizer, can_change_orders=True) + team = Team.objects.create(organizer=event.organizer, limit_event_permissions={"event.orders:write": True}) user._teamcache = {} - assert not user.has_event_permission(event.organizer, event, 'can_change_orders') + assert not user.has_event_permission(event.organizer, event, 'event.orders:write') team.members.add(user) user._teamcache = {} - assert not user.has_event_permission(event.organizer, event, 'can_change_orders') + assert not user.has_event_permission(event.organizer, event, 'event.orders:write') team.all_events = True team.save() user._teamcache = {} - assert user.has_event_permission(event.organizer, event, 'can_change_orders') + assert user.has_event_permission(event.organizer, event, 'event.orders:write') - team.can_change_orders = False + team.limit_event_permissions = {} team.save() user._teamcache = {} - assert not user.has_event_permission(event.organizer, event, 'can_change_orders') + assert not user.has_event_permission(event.organizer, event, 'event.orders:write') @pytest.mark.django_db def test_event_permissions_multiple_teams(event, user): - team1 = Team.objects.create(organizer=event.organizer, can_change_orders=True, all_events=True) - team2 = Team.objects.create(organizer=event.organizer, can_change_vouchers=True) - team3 = Team.objects.create(organizer=event.organizer, can_change_event_settings=True) + team1 = Team.objects.create(organizer=event.organizer, limit_event_permissions={"event.orders:write": True}, all_events=True) + team2 = Team.objects.create(organizer=event.organizer, limit_event_permissions={"event.vouchers:write": True}) + team3 = Team.objects.create(organizer=event.organizer, limit_event_permissions={"event.settings.general:write": True}) event2 = Event.objects.create( organizer=event.organizer, name='Dummy', slug='dummy2', date_from=now() @@ -180,12 +173,17 @@ def test_event_permissions_multiple_teams(event, user): team2.limit_events.add(event) team3.limit_events.add(event2) - assert user.has_event_permission(event.organizer, event, 'can_change_orders') - assert user.has_event_permission(event.organizer, event, 'can_change_vouchers') - assert not user.has_event_permission(event.organizer, event, 'can_change_event_settings') - assert user.get_event_permission_set(event.organizer, event) == {'can_change_orders', 'can_change_vouchers'} - assert user.get_event_permission_set(event.organizer, event2) == {'can_change_orders', 'can_change_event_settings', - 'can_change_settings'} + assert user.has_event_permission(event.organizer, event, 'event.orders:write') + assert user.has_event_permission(event.organizer, event, 'event.vouchers:write') + assert not user.has_event_permission(event.organizer, event, 'event.settings.general:write') + assert user.get_event_permission_set(event.organizer, event) == { + 'event.orders:write', 'event.vouchers:write', + 'can_change_orders', 'can_change_vouchers', + } + assert user.get_event_permission_set(event.organizer, event2) == { + 'event.orders:write', 'event.settings.general:write', 'event.settings.general:write', + 'can_change_orders', 'can_change_event_settings', 'can_change_settings', + } @pytest.mark.django_db @@ -205,41 +203,47 @@ def test_any_organizer_permission(event, user): @pytest.mark.django_db def test_specific_organizer_permission(event, user): user._teamcache = {} - assert not user.has_organizer_permission(event.organizer, 'can_create_events') + assert not user.has_organizer_permission(event.organizer, 'organizer.events:create') - team = Team.objects.create(organizer=event.organizer, can_create_events=True) + team = Team.objects.create(organizer=event.organizer, limit_organizer_permissions={"organizer.events:create": True}) user._teamcache = {} - assert not user.has_organizer_permission(event.organizer, 'can_create_events') + assert not user.has_organizer_permission(event.organizer, 'organizer.events:create') team.members.add(user) user._teamcache = {} - assert user.has_organizer_permission(event.organizer, 'can_create_events') - assert user.has_organizer_permission(event.organizer, ('can_create_events', 'can_change_organizer_settings')) + assert user.has_organizer_permission(event.organizer, 'organizer.events:create') + assert user.has_organizer_permission(event.organizer, ('organizer.events:create', 'organizer.settings.general:write')) @pytest.mark.django_db def test_organizer_permissions_multiple_teams(event, user): - team1 = Team.objects.create(organizer=event.organizer, can_change_organizer_settings=True) - team2 = Team.objects.create(organizer=event.organizer, can_create_events=True) + team1 = Team.objects.create(organizer=event.organizer, limit_organizer_permissions={"organizer.settings.general:write": True}) + team2 = Team.objects.create(organizer=event.organizer, limit_organizer_permissions={"organizer.events:create": True}) team1.members.add(user) team2.members.add(user) orga2 = Organizer.objects.create(slug='d2', name='d2') - team3 = Team.objects.create(organizer=orga2, can_change_teams=True) + team3 = Team.objects.create(organizer=orga2, limit_organizer_permissions={"organizer.teams:write": True}) team3.members.add(user) - assert user.has_organizer_permission(event.organizer, 'can_create_events') - assert user.has_organizer_permission(event.organizer, 'can_change_organizer_settings') - assert not user.has_organizer_permission(event.organizer, 'can_change_teams') - assert user.get_organizer_permission_set(event.organizer) == {'can_create_events', 'can_change_organizer_settings'} - assert user.get_organizer_permission_set(orga2) == {'can_change_teams'} + assert user.has_organizer_permission(event.organizer, 'organizer.events:create') + assert user.has_organizer_permission(event.organizer, 'organizer.settings.general:write') + assert not user.has_organizer_permission(event.organizer, 'organizer.teams:write') + assert user.get_organizer_permission_set(event.organizer) == { + 'organizer.events:create', 'organizer.settings.general:write', + 'can_create_events', 'can_change_organizer_settings', + } + assert user.get_organizer_permission_set(orga2) == { + 'organizer.teams:write', + 'can_change_teams', + } @pytest.mark.django_db def test_superuser(event, admin, admin_request): assert admin.has_organizer_permission(event.organizer, request=admin_request) - assert admin.has_organizer_permission(event.organizer, 'can_create_events', request=admin_request) + assert admin.has_organizer_permission(event.organizer, 'organizer.events:create', request=admin_request) assert admin.has_event_permission(event.organizer, event, request=admin_request) - assert admin.has_event_permission(event.organizer, event, 'can_change_event_settings', request=admin_request) + assert admin.has_event_permission(event.organizer, event, 'event.settings.general:write', request=admin_request) assert 'arbitrary' not in admin.get_event_permission_set(event.organizer, event) assert 'arbitrary' not in admin.get_organizer_permission_set(event.organizer) @@ -266,9 +270,9 @@ def test_list_of_events(event, user, admin, admin_request): assert not user.get_events_with_any_permission() - team1 = Team.objects.create(organizer=event.organizer, can_change_orders=True, all_events=True) - team2 = Team.objects.create(organizer=event.organizer, can_change_vouchers=True) - team3 = Team.objects.create(organizer=orga2, can_change_event_settings=True) + team1 = Team.objects.create(organizer=event.organizer, limit_event_permissions={"event.orders:write": True}, all_events=True) + team2 = Team.objects.create(organizer=event.organizer, limit_event_permissions={"event.vouchers:write": True}) + team3 = Team.objects.create(organizer=orga2, limit_event_permissions={"event.settings.general:write": True}) team1.members.add(user) team2.members.add(user) team3.members.add(user) @@ -282,7 +286,7 @@ def test_list_of_events(event, user, admin, admin_request): assert event3 in events assert event4 not in events - events = list(user.get_events_with_permission('can_change_event_settings', request=admin_request)) + events = list(user.get_events_with_permission('event.settings.general:write', request=admin_request)) assert event not in events assert event2 not in events assert event3 in events @@ -293,8 +297,73 @@ def test_list_of_events(event, user, admin, admin_request): assert set(event3.get_users_with_any_permission()) == {user} assert set(event4.get_users_with_any_permission()) == set() - assert set(event.get_users_with_permission('can_change_event_settings')) == set() - assert set(event2.get_users_with_permission('can_change_event_settings')) == set() - assert set(event3.get_users_with_permission('can_change_event_settings')) == {user} - assert set(event4.get_users_with_permission('can_change_event_settings')) == set() - assert set(event.get_users_with_permission('can_change_orders')) == {user} + assert set(event.get_users_with_permission('event.settings.general:write')) == set() + assert set(event2.get_users_with_permission('event.settings.general:write')) == set() + assert set(event3.get_users_with_permission('event.settings.general:write')) == {user} + assert set(event4.get_users_with_permission('event.settings.general:write')) == set() + assert set(event.get_users_with_permission('event.orders:write')) == {user} + + +@pytest.mark.django_db +@pytest.mark.filterwarnings("ignore") +def test_check_with_legacy_permission_names(event, user): + team1 = Team.objects.create( + organizer=event.organizer, + limit_event_permissions={"event.settings.general:write": True}, + limit_organizer_permissions={ + "organizer.giftcards:read": True, + "organizer.giftcards:write": True, + "organizer.reusablemedia:write": True, + }, + all_events=True + ) + team1.members.add(user) + + # Team methods + assert team1.has_event_permission('can_change_event_settings') + assert team1.has_event_permission('can_change_settings') + assert not team1.has_event_permission('can_view_orders') + assert team1.has_organizer_permission('can_manage_gift_cards') + assert not team1.has_organizer_permission('can_manage_reusable_media') + assert team1.organizer_permission_set() == { + "organizer.giftcards:read", + "organizer.giftcards:write", + "organizer.reusablemedia:write", + "can_manage_gift_cards", + } + assert team1.organizer_permission_set(include_legacy=False) == { + "organizer.giftcards:read", + "organizer.giftcards:write", + "organizer.reusablemedia:write", + } + assert team1.event_permission_set() == { + "event.settings.general:write", "can_change_event_settings", "can_change_settings", + } + assert team1.event_permission_set(include_legacy=False) == { + "event.settings.general:write", + } + + # User methods + user._teamcache = {} + assert user.get_event_permission_set(event.organizer, event) == { + "event.settings.general:write", "can_change_event_settings", "can_change_settings", + } + assert user.get_organizer_permission_set(event.organizer) == { + "organizer.giftcards:read", + "organizer.giftcards:write", + "organizer.reusablemedia:write", + "can_manage_gift_cards", + } + assert user.has_event_permission(event.organizer, event, 'can_change_event_settings') + assert user.has_event_permission(event.organizer, event, 'can_change_settings') + assert not user.has_event_permission(event.organizer, event, 'can_view_orders') + assert user.has_organizer_permission(event.organizer, 'can_manage_gift_cards') + assert not user.has_organizer_permission(event.organizer, 'can_manage_reusable_media') + assert user.get_events_with_permission("can_change_event_settings").get() == event + assert not user.get_events_with_permission("can_view_orders").exists() + assert user.get_organizers_with_permission("can_manage_gift_cards").get() == event.organizer + assert not user.get_organizers_with_permission("can_manage_reusable_media").exists() + + # Event methods + assert event.get_users_with_permission("can_change_event_settings").get() == user + assert not event.get_users_with_permission("can_view_orders").exists() diff --git a/src/tests/control/test_auth.py b/src/tests/control/test_auth.py index 3787fbc33a..7b5fa54978 100644 --- a/src/tests/control/test_auth.py +++ b/src/tests/control/test_auth.py @@ -1123,7 +1123,7 @@ class Obligatory2FATest(TestCase): session.save() organizer = Organizer.objects.create(name='Dummy', slug='dummy') - team = Team.objects.create(organizer=organizer, can_change_teams=True, name='Admin team') + team = Team.objects.create(organizer=organizer, all_event_permissions=True, name='Admin team') team.members.add(self.user) self.user.require_2fa = False self.user.save() diff --git a/src/tests/control/test_checkins.py b/src/tests/control/test_checkins.py index a3afce9e83..759d744e92 100644 --- a/src/tests/control/test_checkins.py +++ b/src/tests/control/test_checkins.py @@ -61,7 +61,7 @@ def dashboard_env(): item_ticket = Item.objects.create(event=event, name="Ticket", default_price=23, admission=True) item_mascot = Item.objects.create(event=event, name="Mascot", default_price=10, admission=False) - t = Team.objects.create(organizer=o, can_view_orders=True, can_change_orders=True) + t = Team.objects.create(organizer=o, all_event_permissions=True) t.members.add(user) t.limit_events.add(event) @@ -139,7 +139,7 @@ def checkin_list_env(): # permission orga = Organizer.objects.create(name='Dummy', slug='dummy') user = User.objects.create_user('dummy@dummy.dummy', 'dummy') - team = Team.objects.create(organizer=orga, can_view_orders=True, can_change_orders=True) + team = Team.objects.create(organizer=orga, all_event_permissions=True) team.members.add(user) # event @@ -321,7 +321,7 @@ def test_manual_checkins_revert_requires_order_change_permission(client, checkin client.login(email='dummy@dummy.dummy', password='dummy') with scopes_disabled(): assert not checkin_list_env[5][3].checkins.exists() - Team.objects.update(can_change_orders=False, can_checkin_orders=True) + Team.objects.update(all_event_permissions=False, limit_event_permissions={"event.orders:checkin": True}) client.post('/control/event/dummy/dummy/checkinlists/{}/bulk_action'.format(checkin_list_env[6].pk), { 'checkin': [checkin_list_env[5][3].pk] }) @@ -363,7 +363,7 @@ def checkin_list_with_addon_env(): # permission orga = Organizer.objects.create(name='Dummy', slug='dummy') user = User.objects.create_user('dummy@dummy.dummy', 'dummy') - team = Team.objects.create(organizer=orga, can_view_orders=True, can_change_orders=True) + team = Team.objects.create(organizer=orga, all_event_permissions=True) team.members.add(user) # event @@ -466,7 +466,7 @@ class CheckinListFormTest(SoupTest): date_from=datetime(2013, 12, 26, tzinfo=timezone.utc), ) self.event1.settings.timezone = 'Europe/Berlin' - t = Team.objects.create(organizer=self.orga1, can_change_event_settings=True, can_view_orders=True) + t = Team.objects.create(organizer=self.orga1, all_event_permissions=True) t.members.add(self.user) t.limit_events.add(self.event1) self.client.login(email='dummy@dummy.dummy', password='dummy') diff --git a/src/tests/control/test_customer.py b/src/tests/control/test_customer.py index a973a19867..94e1f34618 100644 --- a/src/tests/control/test_customer.py +++ b/src/tests/control/test_customer.py @@ -85,7 +85,7 @@ def order(event, customer): def admin_user(organizer): u = User.objects.create_user('dummy@dummy.dummy', 'dummy') admin_team = Team.objects.create( - organizer=organizer, can_manage_customers=True, can_change_organizer_settings=True, + organizer=organizer, all_organizer_permissions=True, name='Admin team' ) admin_team.members.add(u) diff --git a/src/tests/control/test_devices.py b/src/tests/control/test_devices.py index afbca7b3b0..7b09239bb1 100644 --- a/src/tests/control/test_devices.py +++ b/src/tests/control/test_devices.py @@ -55,7 +55,7 @@ def admin_user(admin_team): @pytest.fixture def admin_team(organizer): - return Team.objects.create(organizer=organizer, can_change_organizer_settings=True, name='Admin team') + return Team.objects.create(organizer=organizer, all_organizer_permissions=True, name='Admin team') @pytest.mark.django_db diff --git a/src/tests/control/test_events.py b/src/tests/control/test_events.py index 9042470562..d03aa5da05 100644 --- a/src/tests/control/test_events.py +++ b/src/tests/control/test_events.py @@ -76,13 +76,16 @@ class EventsTest(SoupTest): date_from=datetime.datetime(2014, 9, 5, tzinfo=datetime.timezone.utc), ) - self.team1 = Team.objects.create(organizer=self.orga1, can_create_events=True, can_change_event_settings=True, - can_change_items=True) + self.team1 = Team.objects.create( + organizer=self.orga1, + name="T1", + all_event_permissions=True, + limit_organizer_permissions={"organizer.events:create": True} + ) self.team1.members.add(self.user) self.team1.limit_events.add(self.event1) - self.team2 = Team.objects.create(organizer=self.orga1, can_change_event_settings=True, can_change_items=True, - can_change_orders=True, can_change_vouchers=True) + self.team2 = Team.objects.create(organizer=self.orga1, name="T2", all_event_permissions=True) self.team2.members.add(self.user) self.client.login(email='dummy@dummy.dummy', password='dummy') @@ -339,7 +342,7 @@ class EventsTest(SoupTest): 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 = Team.objects.create(organizer=self.orga1, all_organizer_permissions=True, all_event_permissions=True) t2.members.add(self.user) self.post_doc('/control/event/%s/%s/settings/plugins' % (self.orga1.slug, self.event1.slug), @@ -1262,6 +1265,265 @@ class EventsTest(SoupTest): }) assert doc.select(".has-error") + def test_create_event_copy_from_other_org_validates_source_permissions(self): + # To prevent leaks of e.g. settings contents, a user may only copy from one organizer to the other + # if they have basically all permissions on the old event for all data that may be copied. + self.team1.all_event_permissions = False + self.team1.limit_event_permissions = {"event.settings.general:write": True, "event.orders:read": True} + self.team1.save() + team3 = Team.objects.create(organizer=self.orga2, all_event_permissions=True, all_organizer_permissions=True) + team3.members.add(self.user) + + doc = self.post_doc('/control/events/add', { + 'event_wizard-current_step': 'foundation', + 'event_wizard-prefix': 'event_wizard', + 'foundation-organizer': self.orga2.pk, + 'foundation-locales': ('en', 'de') + }) + assert doc.select("#id_basics-name_0") + + doc = self.post_doc('/control/events/add', { + 'event_wizard-current_step': 'basics', + 'event_wizard-prefix': 'event_wizard', + 'basics-name_0': '33C3', + 'basics-name_1': '33C3', + 'basics-slug': '33c3', + 'basics-date_from_0': '2016-12-27', + 'basics-date_from_1': '10:00:00', + 'basics-date_to_0': '2016-12-30', + 'basics-date_to_1': '19:00:00', + 'basics-location_0': 'Hamburg', + 'basics-location_1': 'Hamburg', + 'basics-currency': 'EUR', + 'basics-tax_rate': '19.00', + 'basics-locale': 'en', + 'basics-timezone': 'Europe/Berlin', + 'basics-presale_start_0': '2016-11-01', + 'basics-presale_start_1': '10:00:00', + 'basics-presale_end_0': '2016-11-30', + 'basics-presale_end_1': '18:00:00', + }) + assert doc.select("#id_copy-copy_from_event") + + doc = self.post_doc('/control/events/add', { + 'event_wizard-current_step': 'copy', + 'event_wizard-prefix': 'event_wizard', + 'copy-copy_from_event': self.event1.pk + }) + assert doc.select(".alert-danger") + assert "sufficient level of access" in doc.select(".has-error")[0].text + + def test_create_event_clone_from_other_org_validates_source_permissions(self): + # To prevent leaks of e.g. settings contents, a user may only copy from one organizer to the other + # if they have basically all permissions on the old event for all data that may be copied. + self.team1.all_event_permissions = False + self.team1.limit_event_permissions = {"event.settings.general:write": True, "event.orders:read": True} + self.team1.save() + team3 = Team.objects.create(organizer=self.orga2, all_event_permissions=True, all_organizer_permissions=True) + team3.members.add(self.user) + + doc = self.post_doc(f'/control/events/add?clone={self.event1.pk}', { + 'event_wizard-current_step': 'foundation', + 'event_wizard-prefix': 'event_wizard', + 'foundation-organizer': self.orga2.pk, + 'foundation-locales': ('en', 'de') + }) + assert doc.select(".alert-danger") + assert "sufficient level of access" in doc.select(".has-error")[0].text + + def test_create_event_copy_from_same_org_creates_new_team_with_same_permissions(self): + # To prevent unwanted permission escalations, when a user copies an event and a new team is created to make + # sure they can access the new event, the new event must be created with the same level of access they have + # on the old event. + self.team1.all_event_permissions = False + self.team1.limit_event_permissions = {"event.settings.general:write": True, "event.orders:read": True} + self.team1.save() + + doc = self.post_doc('/control/events/add', { + 'event_wizard-current_step': 'foundation', + 'event_wizard-prefix': 'event_wizard', + 'foundation-organizer': self.orga1.pk, + 'foundation-locales': ('en', 'de') + }) + assert doc.select("#id_basics-name_0") + + doc = self.post_doc('/control/events/add', { + 'event_wizard-current_step': 'basics', + 'event_wizard-prefix': 'event_wizard', + 'basics-name_0': '33C3', + 'basics-name_1': '33C3', + 'basics-slug': '33c3', + 'basics-date_from_0': '2016-12-27', + 'basics-date_from_1': '10:00:00', + 'basics-date_to_0': '2016-12-30', + 'basics-date_to_1': '19:00:00', + 'basics-location_0': 'Hamburg', + 'basics-location_1': 'Hamburg', + 'basics-currency': 'EUR', + 'basics-tax_rate': '19.00', + 'basics-locale': 'en', + 'basics-timezone': 'Europe/Berlin', + 'basics-presale_start_0': '2016-11-01', + 'basics-presale_start_1': '10:00:00', + 'basics-presale_end_0': '2016-11-30', + 'basics-presale_end_1': '18:00:00', + }) + assert doc.select("#id_copy-copy_from_event") + + self.post_doc('/control/events/add', { + 'event_wizard-current_step': 'copy', + 'event_wizard-prefix': 'event_wizard', + 'copy-copy_from_event': self.event1.pk + }) + with scopes_disabled(): + ev = Event.objects.get(slug='33c3') + new_team = Team.objects.get(limit_events=ev, members=self.user) + assert new_team.pk > self.team2.pk + assert new_team.all_event_permissions is False + assert new_team.all_organizer_permissions is False + assert new_team.limit_event_permissions == {"event.settings.general:write": True, "event.orders:read": True} + assert new_team.limit_organizer_permissions == {} + assert new_team.all_events is False + assert new_team.limit_events.get() == ev + + def test_create_event_clone_from_same_org_creates_new_team_with_same_permissions(self): + # To prevent unwanted permission escalations, when a user copies an event and a new team is created to make + # sure they can access the new event, the new event must be created with the same level of access they have + # on the old event. + self.team1.all_event_permissions = False + self.team1.limit_event_permissions = {"event.settings.general:write": True, "event.orders:read": True} + self.team1.save() + + doc = self.post_doc(f'/control/events/add?clone={self.event1.pk}', { + 'event_wizard-current_step': 'foundation', + 'event_wizard-prefix': 'event_wizard', + 'foundation-organizer': self.orga1.pk, + 'foundation-locales': ('en', 'de') + }) + assert doc.select("#id_basics-name_0") + + self.post_doc(f'/control/events/add?clone={self.event1.pk}', { + 'event_wizard-current_step': 'basics', + 'event_wizard-prefix': 'event_wizard', + 'basics-name_0': '33C3', + 'basics-name_1': '33C3', + 'basics-slug': '33c3', + 'basics-date_from_0': '2016-12-27', + 'basics-date_from_1': '10:00:00', + 'basics-date_to_0': '2016-12-30', + 'basics-date_to_1': '19:00:00', + 'basics-location_0': 'Hamburg', + 'basics-location_1': 'Hamburg', + 'basics-currency': 'EUR', + 'basics-tax_rate': '19.00', + 'basics-locale': 'en', + 'basics-timezone': 'Europe/Berlin', + 'basics-presale_start_0': '2016-11-01', + 'basics-presale_start_1': '10:00:00', + 'basics-presale_end_0': '2016-11-30', + 'basics-presale_end_1': '18:00:00', + }) + with scopes_disabled(): + ev = Event.objects.get(slug='33c3') + new_team = Team.objects.get(limit_events=ev, members=self.user) + assert new_team.pk > self.team2.pk + assert new_team.all_event_permissions is False + assert new_team.all_organizer_permissions is False + assert new_team.limit_event_permissions == {"event.settings.general:write": True, "event.orders:read": True} + assert new_team.limit_organizer_permissions == {} + assert new_team.all_events is False + assert new_team.limit_events.get() == ev + + def test_create_event_copy_from_same_org_validates_selected_team_permissions(self): + # To prevent unwanted permission escalations, when a user copies an event and selects the team the new event + # should be attached to, this new team may not have higher permissions than the permissions the user holds for + # the event that is copied from. + self.team1.all_event_permissions = False + self.team1.limit_event_permissions = {"event.settings.general:write": True, "event.orders:read": True} + self.team1.save() + + doc = self.post_doc('/control/events/add', { + 'event_wizard-current_step': 'foundation', + 'event_wizard-prefix': 'event_wizard', + 'foundation-organizer': self.orga1.pk, + 'foundation-locales': ('en', 'de') + }) + assert doc.select("#id_basics-name_0") + assert doc.select("#id_basics-team") + + doc = self.post_doc('/control/events/add', { + 'event_wizard-current_step': 'basics', + 'event_wizard-prefix': 'event_wizard', + 'basics-name_0': '33C3', + 'basics-name_1': '33C3', + 'basics-slug': '33c3', + 'basics-date_from_0': '2016-12-27', + 'basics-date_from_1': '10:00:00', + 'basics-date_to_0': '2016-12-30', + 'basics-date_to_1': '19:00:00', + 'basics-location_0': 'Hamburg', + 'basics-location_1': 'Hamburg', + 'basics-currency': 'EUR', + 'basics-tax_rate': '19.00', + 'basics-locale': 'en', + 'basics-team': self.team2.pk, + 'basics-timezone': 'Europe/Berlin', + 'basics-presale_start_0': '2016-11-01', + 'basics-presale_start_1': '10:00:00', + 'basics-presale_end_0': '2016-11-30', + 'basics-presale_end_1': '18:00:00', + }) + assert doc.select("#id_copy-copy_from_event") + + doc = self.post_doc('/control/events/add', { + 'event_wizard-current_step': 'copy', + 'event_wizard-prefix': 'event_wizard', + 'copy-copy_from_event': self.event1.pk + }) + assert doc.select(".alert-danger") + assert "less access than" in doc.select(".has-error")[0].text + + def test_create_event_clone_from_same_org_validates_selected_team_permissions(self): + # To prevent unwanted permission escalations, when a user copies an event and selects the team the new event + # should be attached to, this new team may not have higher permissions than the permissions the user holds for + # the event that is copied from. + self.team1.all_event_permissions = False + self.team1.limit_event_permissions = {"event.settings.general:write": True, "event.orders:read": True} + self.team1.save() + + doc = self.post_doc(f'/control/events/add?clone={self.event1.pk}', { + 'event_wizard-current_step': 'foundation', + 'event_wizard-prefix': 'event_wizard', + 'foundation-organizer': self.orga1.pk, + 'foundation-locales': ('en', 'de') + }) + assert doc.select("#id_basics-name_0") + assert doc.select("#id_basics-team") + + doc = self.post_doc(f'/control/events/add?clone={self.event1.pk}', { + 'event_wizard-current_step': 'basics', + 'event_wizard-prefix': 'event_wizard', + 'basics-name_0': '33C3', + 'basics-name_1': '33C3', + 'basics-slug': '33c3', + 'basics-date_from_0': '2016-12-27', + 'basics-date_from_1': '10:00:00', + 'basics-date_to_0': '2016-12-30', + 'basics-date_to_1': '19:00:00', + 'basics-location_0': 'Hamburg', + 'basics-location_1': 'Hamburg', + 'basics-currency': 'EUR', + 'basics-tax_rate': '19.00', + 'basics-locale': 'en', + 'basics-team': self.team2.pk, + 'basics-timezone': 'Europe/Berlin', + 'basics-presale_start_0': '2016-11-01', + 'basics-presale_start_1': '10:00:00', + 'basics-presale_end_0': '2016-11-30', + 'basics-presale_end_1': '18:00:00', + }) + assert "would give you more access than" in doc.select(".has-error")[0].text + class EventDeletionTest(SoupTest): @scopes_disabled() @@ -1276,8 +1538,7 @@ class EventDeletionTest(SoupTest): has_subevents=False ) - t = Team.objects.create(organizer=self.orga1, can_create_events=True, can_change_event_settings=True, - can_change_items=True) + t = Team.objects.create(organizer=self.orga1, all_organizer_permissions=True, all_event_permissions=True) t.members.add(self.user) t.limit_events.add(self.event1) self.ticket = self.event1.items.create(name='Early-bird ticket', diff --git a/src/tests/control/test_export.py b/src/tests/control/test_export.py index ed28cb8697..091041d0f4 100644 --- a/src/tests/control/test_export.py +++ b/src/tests/control/test_export.py @@ -23,7 +23,9 @@ import datetime import json import pytest +from bs4 import BeautifulSoup from django.utils.timezone import now +from tests.base import extract_form_fields from pretix.base.models import ( Event, Item, Organizer, ScheduledEventExport, ScheduledOrganizerExport, @@ -40,8 +42,7 @@ def env(): ) event.settings.set("ticketoutput_testdummy__enabled", True) user = User.objects.create_user("dummy@dummy.dummy", "dummy") - t = Team.objects.create(organizer=o, can_view_orders=True, can_change_orders=True, can_manage_customers=True, - can_change_event_settings=True) + t = Team.objects.create(organizer=o, all_event_permissions=True) t.members.add(user) t.limit_events.add(event) @@ -163,7 +164,8 @@ def test_event_export_schedule(client, env): @pytest.mark.django_db(transaction=True) def test_event_limited_permission(client, env): - env[2].can_change_event_settings = False + env[2].all_event_permissions = False + env[2].limit_event_permissions = {"event.orders:read": True} env[2].save() user2 = User.objects.create_user("dummy2@dummy.dummy", "dummy") @@ -199,7 +201,7 @@ def test_event_limited_permission(client, env): response = client.get(f"/control/event/dummy/dummy/orders/export/{s2.pk}/delete") assert response.status_code == 404 - env[2].can_change_event_settings = True + env[2].limit_event_permissions = {"event.settings.general:write": True, "event.orders:read": True} env[2].save() response = client.get("/control/event/dummy/dummy/orders/export/") assert b"RULE1" in response.content @@ -330,7 +332,8 @@ def test_organizer_export_schedule(client, env): @pytest.mark.django_db(transaction=True) def test_organizer_limited_permission(client, env): - env[2].can_change_organizer_settings = False + env[2].all_organizer_permissions = False + env[2].all_event_permissions = True env[2].save() user2 = User.objects.create_user("dummy2@dummy.dummy", "dummy") @@ -366,7 +369,7 @@ def test_organizer_limited_permission(client, env): response = client.post(f"/control/organizer/dummy/export/{s2.pk}/run") assert response.status_code == 404 - env[2].can_change_organizer_settings = True + env[2].limit_organizer_permissions = {"organizer.settings.general:write": True} env[2].save() response = client.get("/control/organizer/dummy/export/") assert b"RULE1" in response.content @@ -377,3 +380,197 @@ def test_organizer_limited_permission(client, env): assert response.status_code == 200 response = client.post(f"/control/organizer/dummy/export/{s2.pk}/run") assert response.status_code == 302 + + +def _can_see_but_not_edit_org_export(client, user, scheduled): + client.login(email=user.email, password="dummy") + + response = client.get("/control/organizer/dummy/export/") + assert f"export/{scheduled.pk}/delete".encode() in response.content + response = client.get(f"/control/organizer/dummy/export/?identifier={scheduled.export_identifier}&scheduled={scheduled.pk}") + if response.status_code == 404: + return False + + assert response.status_code == 200 + doc = BeautifulSoup(response.content, "lxml") + form_data = extract_form_fields(doc.select("form[data-asynctask]")[0]) + form_data["schedule"] = "save" + + response = client.post(f"/control/organizer/dummy/export/?identifier={scheduled.export_identifier}&scheduled={scheduled.pk}", + data=form_data, follow=True) + assert response.status_code == 200 + + return b"alert-success" in response.content and b"does not have sufficient permission" not in response.content + + +@pytest.mark.django_db(transaction=True) +def test_organizer_edit_restrictions(client, env): + # This tests the prevention of a possible privilege escalation where user A creates a scheduled export and + # user B has settings permission (= they can see the export configuration), but not enough permission + # to run the export themselves. Without this check, user B could modify the export and add themselves + # as a recipient. Thereby, user B would gain access to data they can't have. + user1 = env[1] + user2 = User.objects.create_user("dummy2@dummy.dummy", "dummy") + + event1 = env[0] + event2 = Event.objects.create( + organizer=env[0].organizer, name="Dummy", slug="dummy2", + date_from=now(), plugins="pretix.plugins.banktransfer,pretix.plugins.stripe,tests.testdummy" + ) + + team1 = env[2] + team1.all_organizer_permissions = False + team1.all_event_permissions = False + team1.all_events = False + team1.limit_organizer_permissions = {"organizer.settings.general:write": True} + team1.limit_event_permissions = {"event.orders:read": True, "event.settings.general:write": True} + team1.save() + team1.limit_events.add(event1) + + team2 = env[0].organizer.teams.create( + all_organizer_permissions=False, all_event_permissions=False, all_events=False, + limit_event_permissions={"event.orders:read": True}, + limit_organizer_permissions={"organizer.giftcards:read": True} + ) + team2.limit_events.add(event2) + team2.members.add(user2) + + # Scenario 1 + # User 2 created an export for all events. User 2 can edit it, because they own it. + # User 1 can see it, because they have permission to see scheduled exports, but can't change it, because they + # don't have access to all events. + s1 = ScheduledOrganizerExport.objects.create( + organizer=env[0].organizer, + owner=user2, + export_identifier="dummy_orders", + export_form_data={"all_events": True, "events": []}, + mail_subject="Test", + mail_template="Test", + schedule_rrule="DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO", + schedule_rrule_time=datetime.time(2, 30, 0) + ) + assert _can_see_but_not_edit_org_export(client, user2, s1) + assert not _can_see_but_not_edit_org_export(client, user1, s1) + + # Scenario 2 + # User 2 created an export for all events. User 2 can edit it, because they own it. + # User 1 can see it, because they have permission to see scheduled exports, and change it, because they + # have access to all events. + team1.all_events = True + team1.save() + assert _can_see_but_not_edit_org_export(client, user2, s1) + assert _can_see_but_not_edit_org_export(client, user1, s1) + + # Scenario 3 + # User 2 created an export for a specific event. User 2 can edit it, because they own it. + # User 1 can see it, because they have permission to see scheduled exports, but can't change it, because they + # don't have access to that event. + team1.all_events = False + team1.save() + s1.export_form_data = {"all_events": False, "events": [event2.pk]} + s1.save() + assert _can_see_but_not_edit_org_export(client, user2, s1) + assert not _can_see_but_not_edit_org_export(client, user1, s1) + + # Scenario 4 + # User 2 created an export for a specific event. User 2 can edit it, because they own it. + # User 1 can see it, because they have permission to see scheduled exports, and change it, because they + # have access to that event. + team1.limit_events.add(event2) + assert _can_see_but_not_edit_org_export(client, user2, s1) + assert _can_see_but_not_edit_org_export(client, user1, s1) + + # Scenario 5 + # User 2 created an export that requires a special permission on organizer level + # user 1 can see it, because they have permission to see scheduled exports, but can't change it, because they lack + # that special permission + s2 = ScheduledOrganizerExport.objects.create( + organizer=env[0].organizer, + owner=user2, + export_identifier="giftcardlist", + mail_subject="Test", + mail_template="Test", + schedule_rrule="DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO", + schedule_rrule_time=datetime.time(2, 30, 0) + ) + assert _can_see_but_not_edit_org_export(client, user2, s2) + assert not _can_see_but_not_edit_org_export(client, user1, s2) + + # Scenario 6 + # User 2 created an export that requires a special permission on organizer level + # user 1 can see it, because they have permission to see scheduled exports, and change it, because they have + # that special permission + team1.limit_organizer_permissions["organizer.giftcards:read"] = True + team1.save() + assert _can_see_but_not_edit_org_export(client, user2, s2) + assert _can_see_but_not_edit_org_export(client, user1, s2) + + +def _can_see_but_not_edit_event_export(client, user, scheduled): + client.login(email=user.email, password="dummy") + + response = client.get("/control/event/dummy/dummy/orders/export/") + assert f"export/{scheduled.pk}/delete".encode() in response.content + response = client.get(f"/control/event/dummy/dummy/orders/export/?identifier={scheduled.export_identifier}&scheduled={scheduled.pk}") + if response.status_code == 404: + return False + + assert response.status_code == 200 + doc = BeautifulSoup(response.content, "lxml") + form_data = extract_form_fields(doc.select("form[data-asynctask]")[0]) + form_data["schedule"] = "save" + + response = client.post(f"/control/event/dummy/dummy/orders/export/?identifier={scheduled.export_identifier}&scheduled={scheduled.pk}", + data=form_data, follow=True) + assert response.status_code == 200 + + return b"alert-success" in response.content and b"does not have sufficient permission" not in response.content + + +@pytest.mark.django_db(transaction=True) +def test_event_edit_restrictions(client, env): + # This tests the prevention of a possible privilege escalation where user A creates a scheduled export and + # user B has settings permission (= they can see the export configuration), but not enough permission + # to run the export themselves. Without this check, user B could modify the export and add themselves + # as a recipient. Thereby, user B would gain access to data they can't have. + user1 = env[1] + user2 = User.objects.create_user("dummy2@dummy.dummy", "dummy") + event1 = env[0] + + team1 = env[2] + team1.all_organizer_permissions = False + team1.all_event_permissions = False + team1.all_events = False + team1.limit_organizer_permissions = {"organizer.settings.general:write": True} + team1.limit_event_permissions = {"event.orders:read": True, "event.settings.general:write": True} + team1.save() + team1.limit_events.add(event1) + + team2 = env[0].organizer.teams.create( + all_organizer_permissions=False, all_event_permissions=False, all_events=False, + limit_event_permissions={"event.orders:read": True, "event.vouchers:read": True}, + limit_organizer_permissions={"organizer.giftcards:read": True} + ) + team2.limit_events.add(event1) + team2.members.add(user2) + + s2 = ScheduledEventExport.objects.create( + event=event1, + owner=user2, + export_identifier="dummy_vouchers", + mail_subject="Test", + mail_template="Test", + schedule_rrule="DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO", + schedule_rrule_time=datetime.time(2, 30, 0) + ) + assert _can_see_but_not_edit_event_export(client, user2, s2) + assert not _can_see_but_not_edit_event_export(client, user1, s2) + + # Scenario 6 + # User 2 created an export that requires a special permission on organizer level + # user 1 can see it, because they have permission to see scheduled exports, and change it, because they have + # that special permission + team1.limit_event_permissions["event.vouchers:read"] = True + team1.save() + assert _can_see_but_not_edit_event_export(client, user2, s2) + assert _can_see_but_not_edit_event_export(client, user1, s2) diff --git a/src/tests/control/test_giftcards.py b/src/tests/control/test_giftcards.py index eb6215212c..340ab60a6c 100644 --- a/src/tests/control/test_giftcards.py +++ b/src/tests/control/test_giftcards.py @@ -51,15 +51,14 @@ def gift_card(organizer): @pytest.fixture def admin_user(organizer): u = User.objects.create_user('dummy@dummy.dummy', 'dummy') - admin_team = Team.objects.create(organizer=organizer, can_manage_gift_cards=True, name='Admin team', - can_change_organizer_settings=True) + admin_team = Team.objects.create(organizer=organizer, name='Admin team', all_organizer_permissions=True) admin_team.members.add(u) return u @pytest.fixture def team2(admin_user, organizer2): - admin_team = Team.objects.create(organizer=organizer2, can_manage_gift_cards=True, name='Admin team') + admin_team = Team.objects.create(organizer=organizer2, name='Admin team', all_organizer_permissions=True) admin_team.members.add(admin_user) @@ -213,8 +212,8 @@ def test_typeahead(organizer, admin_user, client, gift_card): assert d == {"results": [{"id": gift_card.pk, "text": gift_card.secret}], "pagination": {"more": False}} # Unprivileged user can only do exact match - team.can_manage_gift_cards = False - team.can_manage_reusable_media = True + team.all_organizer_permissions = False + team.limit_organizer_permissions = {"organizer.reusablemedia:write": True, "organizer.reusablemedia:read": True} team.save() r = client.get('/control/organizer/dummy/giftcards/select2?query=' + gift_card.secret[0:3]) diff --git a/src/tests/control/test_items.py b/src/tests/control/test_items.py index b3b4c00578..d7d37eaaac 100644 --- a/src/tests/control/test_items.py +++ b/src/tests/control/test_items.py @@ -57,9 +57,9 @@ class ItemFormTest(SoupTest): date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc), ) self.item1 = Item.objects.create(event=self.event1, name="Standard", default_price=0, position=1) - t = Team.objects.create(organizer=self.orga1, can_change_event_settings=True, can_change_items=True) - t.members.add(self.user) - t.limit_events.add(self.event1) + self.team = Team.objects.create(organizer=self.orga1, all_event_permissions=True) + self.team.members.add(self.user) + self.team.limit_events.add(self.event1) self.client.login(email='dummy@dummy.dummy', password='dummy') @@ -270,6 +270,14 @@ class QuestionsTest(ItemFormTest): tbl = doc.select('.container-fluid table.table-bordered tbody')[0] assert tbl.select('tr')[0].select('td')[0].text.strip() == '42' + # Test permission requirement + self.team.all_event_permissions = False + self.team.limit_event_permissions = {} + self.team.save() + doc = self.get_doc('/control/event/%s/%s/questions/%s/' % (self.orga1.slug, self.event1.slug, c.id)) + assert not doc.select('.container-fluid table.table-bordered tbody') + assert doc.select('.empty-collection') + def test_set_dependency(self): with scopes_disabled(): q1 = Question.objects.create(event=self.event1, question="What country are you from?", type="C", required=True) diff --git a/src/tests/control/test_mail_settings_preview.py b/src/tests/control/test_mail_settings_preview.py index 141fc043da..9a01a32c14 100644 --- a/src/tests/control/test_mail_settings_preview.py +++ b/src/tests/control/test_mail_settings_preview.py @@ -47,7 +47,7 @@ class MailSettingPreviewTest(SoupTest): ) self.locale_event.settings.locales = ['en', 'de-informal'] self.locale_event.save() - t = Team.objects.create(organizer=self.orga1, can_change_items=True, can_change_event_settings=True) + t = Team.objects.create(organizer=self.orga1, all_event_permissions=True) t.members.add(self.user) t.limit_events.add(self.locale_event) t.limit_events.add(self.event1) diff --git a/src/tests/control/test_modelimport.py b/src/tests/control/test_modelimport.py index 31801950d6..87726bc924 100644 --- a/src/tests/control/test_modelimport.py +++ b/src/tests/control/test_modelimport.py @@ -35,8 +35,7 @@ def env(): date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal' ) user = User.objects.create_user('dummy@dummy.dummy', 'dummy') - t = Team.objects.create(organizer=event.organizer, can_view_orders=True, can_change_orders=True, - can_change_vouchers=True) + t = Team.objects.create(organizer=event.organizer, all_event_permissions=True) t.members.add(user) t.limit_events.add(event) return event, user diff --git a/src/tests/control/test_orders.py b/src/tests/control/test_orders.py index 2b58501de6..c6cae66bc4 100644 --- a/src/tests/control/test_orders.py +++ b/src/tests/control/test_orders.py @@ -67,7 +67,7 @@ def env(): ) event.settings.set('ticketoutput_testdummy__enabled', True) user = User.objects.create_user('dummy@dummy.dummy', 'dummy') - t = Team.objects.create(organizer=o, can_view_orders=True, can_change_orders=True, can_manage_customers=True) + t = Team.objects.create(organizer=o, all_event_permissions=True, all_organizer_permissions=True) t.members.add(user) t.limit_events.add(event) o = Order.objects.create( @@ -1422,7 +1422,7 @@ class OrderChangeTests(SoupTest): self.quota.items.add(self.ticket) self.quota.items.add(self.shirt) user = User.objects.create_user('dummy@dummy.dummy', 'dummy') - t = Team.objects.create(organizer=o, can_view_orders=True, can_change_orders=True) + t = Team.objects.create(organizer=o, all_event_permissions=True) t.members.add(user) t.limit_events.add(self.event) self.client.login(email='dummy@dummy.dummy', password='dummy') diff --git a/src/tests/control/test_orders_bulk.py b/src/tests/control/test_orders_bulk.py index e3bd60d643..0f5a1e1ee0 100644 --- a/src/tests/control/test_orders_bulk.py +++ b/src/tests/control/test_orders_bulk.py @@ -56,7 +56,7 @@ def env(): ) event.settings.set('ticketoutput_testdummy__enabled', True) user = User.objects.create_user('dummy@dummy.dummy', 'dummy') - t = Team.objects.create(organizer=o, can_view_orders=True, can_change_orders=True, can_manage_customers=True) + t = Team.objects.create(organizer=o, all_event_permissions=True) t.members.add(user) t.limit_events.add(event) ticket = Item.objects.create(event=event, name='Early-bird ticket', diff --git a/src/tests/control/test_organizer.py b/src/tests/control/test_organizer.py index 233cd56012..c445e51f7f 100644 --- a/src/tests/control/test_organizer.py +++ b/src/tests/control/test_organizer.py @@ -52,8 +52,7 @@ class OrganizerTest(SoupTest): plugins='pretix.plugins.banktransfer,tests.testdummy' ) - t = Team.objects.create(organizer=self.orga1, can_create_events=True, can_change_event_settings=True, - can_change_items=True, can_change_organizer_settings=True) + t = Team.objects.create(organizer=self.orga1, all_organizer_permissions=True, all_event_permissions=True) t.members.add(self.user) t.limit_events.add(self.event1) diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index 605e8fce7f..aaff20d9ee 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -44,7 +44,7 @@ from pretix.base.models import Event, Order, Organizer, Team, User @pytest.fixture def env(): - o = Organizer.objects.create(name='Dummy', slug='dummy') + o = Organizer.objects.create(name='Dummy', slug='dummy', plugins='pretix.plugins.banktransfer') event = Event.objects.create( organizer=o, name='Dummy', slug='dummy', date_from=now(), plugins='pretix.plugins.banktransfer' @@ -292,7 +292,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(pk=2, organizer=env[2], can_change_event_settings=True) + t = Team.objects.create(pk=2, organizer=env[2], all_event_permissions=True) t.members.add(env[1]) t.limit_events.add(event2) @@ -307,115 +307,114 @@ HTTP_POST = "post" HTTP_GET = "get" event_permission_urls = [ - ("can_change_event_settings", "live/", 200, HTTP_GET), - ("can_change_event_settings", "delete/", 200, HTTP_GET), - ("can_change_event_settings", "dangerzone/", 200, HTTP_GET), - ("can_change_event_settings", "settings/", 200, HTTP_GET), - ("can_change_event_settings", "settings/plugins", 200, HTTP_GET), - ("can_change_event_settings", "settings/payment", 200, HTTP_GET), - ("can_change_event_settings", "settings/tickets", 200, HTTP_GET), - ("can_change_event_settings", "settings/email", 200, HTTP_GET), - ("can_change_event_settings", "settings/email/setup", 200, HTTP_GET), - ("can_change_event_settings", "settings/cancel", 200, HTTP_GET), - ("can_change_event_settings", "settings/invoice", 200, HTTP_GET), - ("can_change_event_settings", "settings/widget", 200, HTTP_GET), - ("can_change_event_settings", "settings/invoice/preview", 200, HTTP_GET), - ("can_change_event_settings", "settings/tax/", 200, HTTP_GET), - ("can_change_event_settings", "settings/tax/1/", 404, HTTP_GET), - ("can_change_event_settings", "settings/tax/add", 200, HTTP_GET), - ("can_change_event_settings", "settings/tax/1/delete", 404, HTTP_GET), - ("can_change_event_settings", "settings/tax/1/default", 404, HTTP_POST), - ("can_change_event_settings", "comment/", 405, HTTP_GET), - # Lists are currently not access-controlled - # ("can_change_items", "items/", 200), - ("can_change_items", "items/add", 200, HTTP_GET), - ("can_change_items", "items/1/up", 404, HTTP_POST), - ("can_change_items", "items/1/down", 404, HTTP_POST), - ("can_change_items", "items/reorder/2/", 400, HTTP_POST), - ("can_change_items", "items/1/delete", 404, HTTP_GET), - # ("can_change_items", "categories/", 200), + ("event.settings.general:write", "live/", 200, HTTP_GET), + ("event.settings.general:write", "delete/", 200, HTTP_GET), + ("event.settings.general:write", "dangerzone/", 200, HTTP_GET), + ("event.settings.general:write", "settings/", 200, HTTP_GET), + # ("event.settings.payment:write", "settings/payment", 200, HTTP_GET), GET allowed also with other permissions + ("event.settings.payment:write", "settings/payment", 200, HTTP_POST), + ("event.settings.payment:write", "settings/payment/banktransfer", 200, HTTP_GET), + ("event.settings.general:write", "settings/tickets", 200, HTTP_GET), + ("event.settings.general:write", "settings/email", 200, HTTP_GET), + ("event.settings.general:write", "settings/email/setup", 200, HTTP_GET), + ("event.settings.general:write", "settings/cancel", 200, HTTP_GET), + ("event.settings.general:write", "settings/widget", 200, HTTP_GET), + ("event.settings.invoicing:write", "settings/invoice", 200, HTTP_GET), + ("event.settings.invoicing:write", "settings/invoice/preview", 200, HTTP_GET), + ("event.settings.tax:write", "settings/tax/", 200, HTTP_GET), + ("event.settings.tax:write", "settings/tax/1/", 404, HTTP_GET), + ("event.settings.tax:write", "settings/tax/add", 200, HTTP_GET), + ("event.settings.tax:write", "settings/tax/1/delete", 404, HTTP_GET), + ("event.settings.tax:write", "settings/tax/1/default", 404, HTTP_POST), + ("event.settings.general:write", "comment/", 405, HTTP_GET), + (None, "items/", 200, HTTP_GET), + ("event.items:write", "items/add", 200, HTTP_GET), + ("event.items:write", "items/1/up", 404, HTTP_POST), + ("event.items:write", "items/1/down", 404, HTTP_POST), + ("event.items:write", "items/reorder/2/", 400, HTTP_POST), + ("event.items:write", "items/1/delete", 404, HTTP_GET), + (None, "categories/", 200, HTTP_GET), # We don't have to create categories and similar objects # for testing this, it is enough to test that a 404 error # is returned instead of a 403 one. - ("can_change_items", "categories/2/", 404, HTTP_GET), - ("can_change_items", "categories/2/delete", 404, HTTP_GET), - ("can_change_items", "categories/2/up", 404, HTTP_POST), - ("can_change_items", "categories/2/down", 404, HTTP_POST), - ("can_change_items", "categories/reorder", 400, HTTP_POST), - ("can_change_items", "categories/add", 200, HTTP_GET), - # ("can_change_items", "questions/", 200, HTTP_GET), - ("can_change_items", "questions/2/", 404, HTTP_GET), - ("can_change_items", "questions/2/delete", 404, HTTP_GET), - ("can_change_items", "questions/reorder", 400, HTTP_POST), - ("can_change_items", "questions/add", 200, HTTP_GET), - # ("can_change_items", "quotas/", 200, HTTP_GET), - ("can_change_items", "quotas/2/change", 404, HTTP_GET), - ("can_change_items", "quotas/2/delete", 404, HTTP_GET), - ("can_change_items", "quotas/add", 200, HTTP_GET), - # ("can_change_items", "discounts/", 200), - # We don't have to create categories and similar objects - # for testing this, it is enough to test that a 404 error - # is returned instead of a 403 one. - ("can_change_items", "discounts/2/", 404, HTTP_GET), - ("can_change_items", "discounts/2/delete", 404, HTTP_GET), - ("can_change_items", "discounts/2/up", 404, HTTP_POST), - ("can_change_items", "discounts/2/down", 404, HTTP_POST), - ("can_change_items", "discounts/reorder", 400, HTTP_POST), - ("can_change_items", "discounts/add", 200, HTTP_GET), - ("can_change_event_settings", "subevents/", 200, HTTP_GET), - ("can_change_event_settings", "subevents/2/", 404, HTTP_GET), - ("can_change_event_settings", "subevents/2/delete", 404, HTTP_GET), - ("can_change_event_settings", "subevents/add", 200, HTTP_GET), - ("can_view_orders", "orders/overview/", 200, HTTP_GET), - ("can_view_orders", "orders/export/", 200, HTTP_GET), - ("can_view_orders", "orders/export/do", 302, HTTP_POST), - ("can_view_orders", "orders/", 200, HTTP_GET), - ("can_view_orders", "orders/FOO/", 200, HTTP_GET), - ("can_change_orders", "orders/FOO/extend", 200, HTTP_GET), - ("can_change_orders", "orders/FOO/reactivate", 302, HTTP_GET), - ("can_change_orders", "orders/FOO/contact", 200, HTTP_GET), - ("can_change_orders", "orders/FOO/transition", 405, HTTP_GET), - ("can_change_orders", "orders/FOO/checkvatid", 405, HTTP_GET), - ("can_change_orders", "orders/FOO/resend", 405, HTTP_GET), - ("can_change_orders", "orders/FOO/invoice", 405, HTTP_GET), - ("can_change_orders", "orders/FOO/change", 200, HTTP_GET), - ("can_change_orders", "orders/FOO/approve", 200, HTTP_GET), - ("can_change_orders", "orders/FOO/deny", 200, HTTP_GET), - ("can_change_orders", "orders/FOO/delete", 302, HTTP_GET), - ("can_change_orders", "orders/FOO/comment", 405, HTTP_GET), - ("can_change_orders", "orders/FOO/locale", 200, HTTP_GET), - ("can_change_orders", "orders/FOO/sendmail", 200, HTTP_GET), - ("can_change_orders", "orders/FOO/1/sendmail", 404, HTTP_GET), - ("can_change_orders", "orders/import/", 200, HTTP_GET), - ("can_change_orders", "orders/import/0ab7b081-92d3-4480-82de-2f8b056fd32f/", 404, HTTP_GET), - ("can_view_orders", "orders/FOO/answer/5/", 404, HTTP_GET), - ("can_change_orders", "cancel/", 200, HTTP_GET), - ("can_change_vouchers", "vouchers/add", 200, HTTP_GET), - ("can_change_vouchers", "vouchers/bulk_add", 200, HTTP_GET), - ("can_view_vouchers", "vouchers/", 200, HTTP_GET), - ("can_view_vouchers", "vouchers/tags/", 200, HTTP_GET), - ("can_view_vouchers", "vouchers/1234/", 404, HTTP_GET), - ("can_change_vouchers", "vouchers/1234/", 404, HTTP_POST), - ("can_change_vouchers", "vouchers/1234/delete", 404, HTTP_GET), - ("can_view_orders", "waitinglist/", 200, HTTP_GET), - ("can_change_orders", "waitinglist/auto_assign", 405, HTTP_GET), - ("can_change_orders", "waitinglist/action", 405, HTTP_GET), - ("can_view_orders", "checkins/", 200, HTTP_GET), - ("can_view_orders", "checkinlists/", 200, HTTP_GET), - ("can_view_orders", "checkinlists/1/", 404, HTTP_GET), - ("can_change_orders", "checkinlists/1/bulk_action", 404, HTTP_POST), - ("can_checkin_orders", "checkinlists/1/bulk_action", 404, HTTP_POST), - ("can_change_event_settings", "checkinlists/add", 200, HTTP_GET), - ("can_change_event_settings", "checkinlists/1/change", 404, HTTP_GET), - ("can_change_event_settings", "checkinlists/1/delete", 404, HTTP_GET), + ("event.items:write", "categories/2/", 404, HTTP_GET), + ("event.items:write", "categories/2/delete", 404, HTTP_GET), + ("event.items:write", "categories/2/up", 404, HTTP_POST), + ("event.items:write", "categories/2/down", 404, HTTP_POST), + ("event.items:write", "categories/reorder", 400, HTTP_POST), + ("event.items:write", "categories/add", 200, HTTP_GET), + (None, "questions/", 200, HTTP_GET), + (None, "questions/2/", 404, HTTP_GET), + ("event.items:write", "questions/2/delete", 404, HTTP_GET), + ("event.items:write", "questions/reorder", 400, HTTP_POST), + ("event.items:write", "questions/add", 200, HTTP_GET), + (None, "quotas/", 200, HTTP_GET), + ("event.items:write", "quotas/2/change", 404, HTTP_GET), + ("event.items:write", "quotas/2/delete", 404, HTTP_GET), + ("event.items:write", "quotas/add", 200, HTTP_GET), + (None, "discounts/", 200, HTTP_GET), + ("event.items:write", "discounts/2/", 404, HTTP_GET), + ("event.items:write", "discounts/2/delete", 404, HTTP_GET), + ("event.items:write", "discounts/2/up", 404, HTTP_POST), + ("event.items:write", "discounts/2/down", 404, HTTP_POST), + ("event.items:write", "discounts/reorder", 400, HTTP_POST), + ("event.items:write", "discounts/add", 200, HTTP_GET), + (None, "subevents/", 200, HTTP_GET), + ("event.subevents:write", "subevents/2/", 404, HTTP_GET), + ("event.subevents:write", "subevents/2/", 404, HTTP_POST), + ("event.subevents:write", "subevents/2/delete", 404, HTTP_GET), + ("event.subevents:write", "subevents/add", 200, HTTP_GET), + ("event.subevents:write", "subevents/bulk_add", 200, HTTP_GET), + ("event.subevents:write", "subevents/bulk_action", 302, HTTP_POST), + ("event.subevents:write", "subevents/bulk_edit", 404, HTTP_POST), + ("event.orders:read", "orders/overview/", 200, HTTP_GET), + ("event.orders:read", "orders/", 200, HTTP_GET), + ("event.orders:read", "orders/FOO/", 200, HTTP_GET), + ("event.orders:write", "orders/FOO/extend", 200, HTTP_GET), + ("event.orders:write", "orders/FOO/reactivate", 302, HTTP_GET), + ("event.orders:write", "orders/FOO/contact", 200, HTTP_GET), + ("event.orders:write", "orders/FOO/transition", 405, HTTP_GET), + ("event.orders:write", "orders/FOO/checkvatid", 405, HTTP_GET), + ("event.orders:write", "orders/FOO/resend", 405, HTTP_GET), + ("event.orders:write", "orders/FOO/invoice", 405, HTTP_GET), + ("event.orders:write", "orders/FOO/change", 200, HTTP_GET), + ("event.orders:write", "orders/FOO/approve", 200, HTTP_GET), + ("event.orders:write", "orders/FOO/deny", 200, HTTP_GET), + ("event.orders:write", "orders/FOO/delete", 302, HTTP_GET), + ("event.orders:write", "orders/FOO/comment", 405, HTTP_GET), + ("event.orders:write", "orders/FOO/locale", 200, HTTP_GET), + ("event.orders:write", "orders/FOO/sendmail", 200, HTTP_GET), + ("event.orders:write", "orders/FOO/1/sendmail", 404, HTTP_GET), + ("event.orders:write", "orders/import/", 200, HTTP_GET), + ("event.orders:write", "orders/import/0ab7b081-92d3-4480-82de-2f8b056fd32f/", 404, HTTP_GET), + ("event.orders:read", "orders/FOO/answer/5/", 404, HTTP_GET), + ("event:cancel", "cancel/", 200, HTTP_GET), + ("event.vouchers:write", "vouchers/add", 200, HTTP_GET), + ("event.vouchers:write", "vouchers/bulk_add", 200, HTTP_GET), + ("event.vouchers:read", "vouchers/", 200, HTTP_GET), + ("event.vouchers:read", "vouchers/tags/", 200, HTTP_GET), + ("event.vouchers:read", "vouchers/1234/", 404, HTTP_GET), + ("event.vouchers:write", "vouchers/1234/", 404, HTTP_POST), + ("event.vouchers:write", "vouchers/1234/delete", 404, HTTP_GET), + ("event.orders:read", "waitinglist/", 200, HTTP_GET), + ("event.orders:write", "waitinglist/auto_assign", 405, HTTP_GET), + ("event.orders:write", "waitinglist/action", 405, HTTP_GET), + ("event.orders:read", "checkins/", 200, HTTP_GET), + ("event.orders:read", "checkinlists/", 200, HTTP_GET), + ("event.orders:read", "checkinlists/1/", 404, HTTP_GET), + ("event.orders:write", "checkinlists/1/bulk_action", 404, HTTP_POST), + ("event.orders:checkin", "checkinlists/1/bulk_action", 404, HTTP_POST), + ("event.settings.general:write", "checkinlists/add", 200, HTTP_GET), + ("event.settings.general:write", "checkinlists/1/change", 404, HTTP_GET), + ("event.settings.general:write", "checkinlists/1/delete", 404, HTTP_GET), # bank transfer - ("can_change_orders", "banktransfer/import/", 200, HTTP_GET), - ("can_change_orders", "banktransfer/job/1/", 404, HTTP_GET), - ("can_change_orders", "banktransfer/action/", 200, HTTP_GET), - ("can_change_orders", "banktransfer/refunds/", 200, HTTP_GET), - ("can_change_orders", "banktransfer/export/1/", 404, HTTP_GET), - ("can_change_orders", "banktransfer/sepa-export/1/", 404, HTTP_GET), + ("event.orders:write", "banktransfer/import/", 200, HTTP_GET), + ("event.orders:write", "banktransfer/job/1/", 404, HTTP_GET), + ("event.orders:write", "banktransfer/action/", 200, HTTP_GET), + ("event.orders:write", "banktransfer/refunds/", 200, HTTP_GET), + ("event.orders:write", "banktransfer/export/1/", 404, HTTP_GET), + ("event.orders:write", "banktransfer/sepa-export/1/", 404, HTTP_GET), ] @@ -425,7 +424,10 @@ def test_wrong_event_permission(perf_patch, client, env, perm, url, code, http_m t = Team( pk=2, organizer=env[2], all_events=True ) - setattr(t, perm, False) + if not perm: + pytest.skip() + t.all_event_permissions = False + t.limit_event_permissions.pop(perm, None) t.save() t.members.add(env[1]) client.login(email='dummy@dummy.dummy', password='dummy') @@ -443,7 +445,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(pk=2, organizer=env[2], can_change_event_settings=True) + t = Team.objects.create(pk=2, organizer=env[2], all_event_permissions=True) t.members.add(env[1]) t.limit_events.add(event2) @@ -460,14 +462,16 @@ def test_current_permission(client, env): t = Team( pk=2, organizer=env[2], all_events=True ) - setattr(t, 'can_change_event_settings', True) + setattr(t, 'event.settings.general:write', True) + t.all_event_permissions = False + t.limit_event_permissions['event.settings.general:write'] = True t.save() t.members.add(env[1]) client.login(email='dummy@dummy.dummy', password='dummy') response = client.get('/control/event/dummy/dummy/settings/') assert response.status_code == 200 - setattr(t, 'can_change_event_settings', False) + t.limit_event_permissions.pop('event.settings.general:write', None) t.save() response = client.get('/control/event/dummy/dummy/settings/') assert response.status_code == 403 @@ -477,7 +481,8 @@ def test_current_permission(client, env): @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(pk=2, organizer=env[2], all_events=True) - setattr(t, perm, True) + t.all_event_permissions = False + t.limit_event_permissions[perm] = True t.save() t.members.add(env[1]) client.login(email='dummy@dummy.dummy', password='dummy') @@ -495,7 +500,8 @@ def test_correct_event_permission_all_events(perf_patch, client, env, perm, url, @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(pk=2, organizer=env[2]) - setattr(t, perm, True) + t.all_event_permissions = False + t.limit_event_permissions[perm] = True t.save() t.members.add(env[1]) t.limit_events.add(env[0]) @@ -521,77 +527,80 @@ def test_wrong_organizer(perf_patch, client, env, url): organizer_permission_urls = [ - ("can_change_teams", "organizer/dummy/teams", 200), - ("can_change_teams", "organizer/dummy/team/add", 200), - ("can_change_teams", "organizer/dummy/team/1/", 200), - ("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/outgoingmails", 200), - ("can_change_organizer_settings", "organizer/dummy/outgoingmail/1/", 404), - ("can_change_organizer_settings", "organizer/dummy/outgoingmail/bulk_action", 405), - ("can_change_organizer_settings", "organizer/dummy/devices", 200), - ("can_change_organizer_settings", "organizer/dummy/devices/select2", 200), - ("can_change_organizer_settings", "organizer/dummy/device/add", 200), - ("can_change_organizer_settings", "organizer/dummy/device/1/edit", 404), - ("can_change_organizer_settings", "organizer/dummy/device/1/connect", 404), - ("can_change_organizer_settings", "organizer/dummy/device/1/revoke", 404), - ("can_change_organizer_settings", "organizer/dummy/gates", 200), - ("can_change_organizer_settings", "organizer/dummy/gates/select2", 200), - ("can_change_organizer_settings", "organizer/dummy/gate/add", 200), - ("can_change_organizer_settings", "organizer/dummy/gate/1/edit", 404), - ("can_change_organizer_settings", "organizer/dummy/gate/1/delete", 404), - ("can_change_organizer_settings", "organizer/dummy/properties", 200), - ("can_change_organizer_settings", "organizer/dummy/property/add", 200), - ("can_change_organizer_settings", "organizer/dummy/property/1/edit", 404), - ("can_change_organizer_settings", "organizer/dummy/property/1/delete", 404), - ("can_change_organizer_settings", "organizer/dummy/channels", 200), - ("can_change_organizer_settings", "organizer/dummy/channel/add", 200), - ("can_change_organizer_settings", "organizer/dummy/channel/web/edit", 200), - ("can_change_organizer_settings", "organizer/dummy/channel/web/delete", 200), - ("can_change_organizer_settings", "organizer/dummy/membershiptypes", 200), - ("can_change_organizer_settings", "organizer/dummy/membershiptype/add", 200), - ("can_change_organizer_settings", "organizer/dummy/membershiptype/1/edit", 404), - ("can_change_organizer_settings", "organizer/dummy/membershiptype/1/delete", 404), - ("can_change_organizer_settings", "organizer/dummy/ssoproviders", 200), - ("can_change_organizer_settings", "organizer/dummy/ssoprovider/add", 200), - ("can_change_organizer_settings", "organizer/dummy/ssoprovider/1/edit", 404), - ("can_change_organizer_settings", "organizer/dummy/ssoprovider/1/delete", 404), - ("can_manage_customers", "organizer/dummy/customers", 200), - ("can_manage_customers", "organizer/dummy/customer/ABC/edit", 404), - ("can_manage_customers", "organizer/dummy/customer/ABC/anonymize", 404), - ("can_manage_customers", "organizer/dummy/customer/ABC/membership/add", 404), - ("can_manage_customers", "organizer/dummy/customer/ABC/membership/1/edit", 404), - ("can_manage_customers", "organizer/dummy/customer/ABC/", 404), - ("can_manage_reusable_media", "organizer/dummy/reusable_media", 200), - ("can_manage_reusable_media", "organizer/dummy/reusable_media/1/edit", 404), - ("can_manage_reusable_media", "organizer/dummy/reusable_media/1/", 404), - ("can_manage_gift_cards", "organizer/dummy/giftcards", 200), - ("can_manage_gift_cards", "organizer/dummy/giftcard/add", 200), - ("can_manage_gift_cards", "organizer/dummy/giftcard/1/", 404), - ("can_manage_gift_cards", "organizer/dummy/giftcard/1/edit", 404), - ("can_change_organizer_settings", "organizer/dummy/giftcards/acceptance", 200), - ("can_change_organizer_settings", "organizer/dummy/giftcards/acceptance/invite", 200), + ("organizer.teams:write", "organizer/dummy/teams", 200), + ("organizer.teams:write", "organizer/dummy/team/add", 200), + ("organizer.teams:write", "organizer/dummy/team/1/", 200), + ("organizer.teams:write", "organizer/dummy/team/1/edit", 200), + ("organizer.teams:write", "organizer/dummy/team/1/delete", 200), + ("organizer.settings.general:write", "organizer/dummy/edit", 200), + ("organizer.settings.general:write", "organizer/dummy/settings/plugins", 200), + ("organizer.settings.general:write", "organizer/dummy/settings/plugins/pretix.plugins.sendmail/events", 200), + ("organizer.settings.general:write", "organizer/dummy/settings/email", 200), + ("organizer.settings.general:write", "organizer/dummy/settings/email/setup", 200), + ("organizer.outgoingmails:read", "organizer/dummy/outgoingmails", 200), + ("organizer.outgoingmails:read", "organizer/dummy/outgoingmail/1/", 404), + ("organizer.outgoingmails:read", "organizer/dummy/outgoingmail/bulk_action", 405), + ("organizer.devices:read", "organizer/dummy/devices", 200), + ("organizer.devices:read", "organizer/dummy/devices/select2", 200), + ("organizer.devices:write", "organizer/dummy/device/add", 200), + ("organizer.devices:write", "organizer/dummy/device/1/edit", 404), + ("organizer.devices:write", "organizer/dummy/device/1/connect", 404), + ("organizer.devices:write", "organizer/dummy/device/1/revoke", 404), + ("organizer.devices:read", "organizer/dummy/gates", 200), + ("organizer.devices:read", "organizer/dummy/gates/select2", 200), + ("organizer.devices:write", "organizer/dummy/gate/add", 200), + ("organizer.devices:write", "organizer/dummy/gate/1/edit", 404), + ("organizer.devices:write", "organizer/dummy/gate/1/delete", 404), + ("organizer.settings.general:write", "organizer/dummy/properties", 200), + ("organizer.settings.general:write", "organizer/dummy/property/add", 200), + ("organizer.settings.general:write", "organizer/dummy/property/1/edit", 404), + ("organizer.settings.general:write", "organizer/dummy/property/1/delete", 404), + ("organizer.settings.general:write", "organizer/dummy/channels", 200), + ("organizer.settings.general:write", "organizer/dummy/channel/add", 200), + ("organizer.settings.general:write", "organizer/dummy/channel/web/edit", 200), + ("organizer.settings.general:write", "organizer/dummy/channel/web/delete", 200), + ("organizer.settings.general:write", "organizer/dummy/membershiptypes", 200), + ("organizer.settings.general:write", "organizer/dummy/membershiptype/add", 200), + ("organizer.settings.general:write", "organizer/dummy/membershiptype/1/edit", 404), + ("organizer.settings.general:write", "organizer/dummy/membershiptype/1/delete", 404), + ("organizer.settings.general:write", "organizer/dummy/ssoproviders", 200), + ("organizer.settings.general:write", "organizer/dummy/ssoprovider/add", 200), + ("organizer.settings.general:write", "organizer/dummy/ssoprovider/1/edit", 404), + ("organizer.settings.general:write", "organizer/dummy/ssoprovider/1/delete", 404), + ("organizer.customers:read", "organizer/dummy/customers", 200), + ("organizer.customers:write", "organizer/dummy/customer/ABC/edit", 404), + ("organizer.customers:write", "organizer/dummy/customer/ABC/anonymize", 404), + ("organizer.customers:write", "organizer/dummy/customer/ABC/membership/add", 404), + ("organizer.customers:write", "organizer/dummy/customer/ABC/membership/1/edit", 404), + ("organizer.customers:read", "organizer/dummy/customer/ABC/", 404), + ("organizer.reusablemedia:read", "organizer/dummy/reusable_media", 200), + ("organizer.reusablemedia:write", "organizer/dummy/reusable_media/1/edit", 404), + ("organizer.reusablemedia:read", "organizer/dummy/reusable_media/1/", 404), + ("organizer.giftcards:read", "organizer/dummy/giftcards", 200), + ("organizer.giftcards:write", "organizer/dummy/giftcard/add", 200), + ("organizer.giftcards:read", "organizer/dummy/giftcard/1/", 404), + ("organizer.giftcards:write", "organizer/dummy/giftcard/1/edit", 404), + ("organizer.settings.general:write", "organizer/dummy/giftcards/acceptance", 200), + ("organizer.settings.general:write", "organizer/dummy/giftcards/acceptance/invite", 200), # bank transfer - ("can_change_orders", "organizer/dummy/banktransfer/import/", 200), - ("can_change_orders", "organizer/dummy/banktransfer/job/1/", 404), - ("can_change_orders", "organizer/dummy/banktransfer/action/", 200), - ("can_change_orders", "organizer/dummy/banktransfer/refunds/", 200), - ("can_change_orders", "organizer/dummy/banktransfer/export/1/", 404), - ("can_change_orders", "organizer/dummy/banktransfer/sepa-export/1/", 404), + ("event.orders:write", "organizer/dummy/banktransfer/import/", 200), + ("event.orders:write", "organizer/dummy/banktransfer/job/1/", 404), + ("event.orders:write", "organizer/dummy/banktransfer/action/", 200), + ("event.orders:write", "organizer/dummy/banktransfer/refunds/", 200), + ("event.orders:write", "organizer/dummy/banktransfer/export/1/", 404), + ("event.orders:write", "organizer/dummy/banktransfer/sepa-export/1/", 404), ] @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(pk=2, organizer=env[2]) - setattr(t, perm, False) + t = Team(pk=2, organizer=env[2], all_events=True) + t.all_organizer_permissions = False + t.limit_organizer_permissions.pop(perm, None) + t.all_event_permissions = False + t.limit_event_permissions.pop(perm, None) t.save() t.members.add(env[1]) client.login(email='dummy@dummy.dummy', password='dummy') @@ -602,8 +611,14 @@ 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(pk=2, organizer=env[2]) - setattr(t, perm, True) + t = Team(pk=2, organizer=env[2], all_events=True) + if perm.startswith("event."): + t.all_organizer_permissions = False + t.all_event_permissions = False + t.limit_event_permissions[perm] = True + else: + t.all_organizer_permissions = False + t.limit_organizer_permissions[perm] = True t.save() t.members.add(env[1]) client.login(email='dummy@dummy.dummy', password='dummy') diff --git a/src/tests/control/test_reusable_media.py b/src/tests/control/test_reusable_media.py index 2ef20df84a..d9b070e079 100644 --- a/src/tests/control/test_reusable_media.py +++ b/src/tests/control/test_reusable_media.py @@ -51,7 +51,7 @@ def gift_card(organizer): @pytest.fixture def admin_user(organizer): u = User.objects.create_user('dummy@dummy.dummy', 'dummy') - admin_team = Team.objects.create(organizer=organizer, can_manage_reusable_media=True, name='Admin team') + admin_team = Team.objects.create(organizer=organizer, all_organizer_permissions=True, name='Admin team') admin_team.members.add(u) return u @@ -122,7 +122,7 @@ def test_typeahead(organizer, admin_user, client, gift_card): # Privileged user can search team.all_events = True - team.can_view_orders = True + team.limit_event_permissions["event.orders:read"] = True team.save() r = client.get('/control/organizer/dummy/ticket_select2?query=' + op.secret[0:3]) @@ -140,7 +140,8 @@ def test_typeahead(organizer, admin_user, client, gift_card): # Unprivileged user can only do exact match team.all_events = True - team.can_view_orders = False + team.all_event_permissions = False + team.limit_event_permissions = {"event.vouchers:read": True} team.save() r = client.get('/control/organizer/dummy/ticket_select2?query=' + op.secret[0:3]) @@ -154,7 +155,7 @@ def test_typeahead(organizer, admin_user, client, gift_card): assert d == {"results": [{'event': 'Dummy', 'id': op.pk, 'text': 'FOO-1 (Early-bird ticket)'}], "pagination": {"more": False}} team.all_events = False - team.can_view_orders = True + team.limit_event_permissions["event.orders:read"] = True team.save() r = client.get('/control/organizer/dummy/ticket_select2?query=' + op.secret[0:3]) diff --git a/src/tests/control/test_search.py b/src/tests/control/test_search.py index 83c94cc01a..066e0cc9e8 100644 --- a/src/tests/control/test_search.py +++ b/src/tests/control/test_search.py @@ -86,7 +86,7 @@ class OrderSearchTest(SoupTest): attendee_name_parts={'full_name': "Mark", "_scheme": "full"} ) - self.team = Team.objects.create(organizer=self.orga1, can_view_orders=True) + self.team = Team.objects.create(organizer=self.orga1, limit_event_permissions={"event.orders:read": True}) self.team.members.add(self.user) self.team.limit_events.add(self.event1) @@ -98,7 +98,8 @@ class OrderSearchTest(SoupTest): assert 'DEFFO2' not in resp def test_team_limit_event_wrong_permission(self): - self.team.can_view_orders = False + self.team.all_event_permissions = False + self.team.limit_event_permissions = {"event.vouchers:read": True} self.team.save() resp = self.client.get('/control/search/orders/').content.decode() assert 'ABCFO1' not in resp @@ -113,7 +114,8 @@ class OrderSearchTest(SoupTest): def test_team_all_events_wrong_permission(self): self.team.all_events = True - self.team.can_view_orders = False + self.team.all_event_permissions = False + self.team.limit_event_permissions = {"event.vouchers:read": True} self.team.save() resp = self.client.get('/control/search/orders/').content.decode() assert 'ABCFO1' not in resp @@ -270,8 +272,8 @@ class PaymentSearchTest(SoupTest): info="{test payment order 2}" ) - self.team = Team.objects.create(organizer=self.orga1, can_view_orders=True) - self.team2 = Team.objects.create(organizer=self.orga2, can_view_orders=True) + self.team = Team.objects.create(organizer=self.orga1, limit_event_permissions={"event.orders:read": True}) + self.team2 = Team.objects.create(organizer=self.orga2, limit_event_permissions={"event.orders:read": True}) self.team.members.add(self.user) self.team.limit_events.add(self.event1) @@ -283,7 +285,8 @@ class PaymentSearchTest(SoupTest): assert 'DEFFO2' not in resp def test_team_limit_event_wrong_permission(self): - self.team.can_view_orders = False + self.team.all_event_permissions = False + self.team.limit_event_permissions = {"event.vouchers:read": True} self.team.save() resp = self.client.get('/control/search/payments/').content.decode() assert 'ABCFO1' not in resp @@ -298,7 +301,8 @@ class PaymentSearchTest(SoupTest): def test_team_all_events_wrong_permission(self): self.team.all_events = True - self.team.can_view_orders = False + self.team.all_event_permissions = False + self.team.limit_event_permissions = {"event.vouchers:read": True} self.team.save() resp = self.client.get('/control/search/payments/').content.decode() assert 'ABCFO1' not in resp diff --git a/src/tests/control/test_shredders.py b/src/tests/control/test_shredders.py index c409716ad1..7e8a14d5fa 100644 --- a/src/tests/control/test_shredders.py +++ b/src/tests/control/test_shredders.py @@ -58,8 +58,7 @@ class EventShredderTest(SoupTest): plugins='pretix.plugins.banktransfer,pretix.plugins.stripe,tests.testdummy' ) - t = Team.objects.create(organizer=self.orga1, can_create_events=True, can_change_event_settings=True, - can_change_items=True, can_change_orders=True) + t = Team.objects.create(organizer=self.orga1, all_organizer_permissions=True, all_event_permissions=True) t.members.add(self.user) t.limit_events.add(self.event1) self.order = Order.objects.create( diff --git a/src/tests/control/test_subevents.py b/src/tests/control/test_subevents.py index 2b82b7094a..273f1b3c17 100644 --- a/src/tests/control/test_subevents.py +++ b/src/tests/control/test_subevents.py @@ -45,8 +45,7 @@ class SubEventsTest(SoupTest): has_subevents=True ) - t = Team.objects.create(organizer=self.orga1, can_create_events=True, can_change_event_settings=True, - can_change_items=True) + t = Team.objects.create(organizer=self.orga1, all_organizer_permissions=True, all_event_permissions=True) t.members.add(self.user) t.limit_events.add(self.event1) self.ticket = self.event1.items.create(name='Early-bird ticket', diff --git a/src/tests/control/test_taxrates.py b/src/tests/control/test_taxrates.py index 62a58a26db..a69d995fca 100644 --- a/src/tests/control/test_taxrates.py +++ b/src/tests/control/test_taxrates.py @@ -41,7 +41,7 @@ class TaxRateFormTest(SoupTest): organizer=self.orga1, name='30C3', slug='30c3', date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc), ) - t = Team.objects.create(organizer=self.orga1, can_change_event_settings=True, can_change_items=True) + t = Team.objects.create(organizer=self.orga1, all_organizer_permissions=True, all_event_permissions=True) t.members.add(self.user) t.limit_events.add(self.event1) self.client.login(email='dummy@dummy.dummy', password='dummy') diff --git a/src/tests/control/test_teams.py b/src/tests/control/test_teams.py index 65c3f2b0b0..ea9c633cd1 100644 --- a/src/tests/control/test_teams.py +++ b/src/tests/control/test_teams.py @@ -56,7 +56,7 @@ def event(organizer): @pytest.fixture def admin_team(organizer): - return Team.objects.create(organizer=organizer, can_change_teams=True, name='Admin team') + return Team.objects.create(organizer=organizer, all_organizer_permissions=True, all_event_permissions=True, name='Admin team') @pytest.fixture @@ -216,7 +216,7 @@ def test_team_remove_last_admin(event, admin_user, admin_team, client): with scopes_disabled(): assert admin_user in admin_team.members.all() - t2.can_change_teams = True + t2.limit_organizer_permissions = {"organizer.teams:write": True} t2.save() resp = client.post('/control/organizer/dummy/team/{}/'.format(admin_team.pk), { 'remove-member': admin_user.pk @@ -229,17 +229,35 @@ def test_team_remove_last_admin(event, admin_user, admin_team, client): @pytest.mark.django_db def test_create_team(event, admin_user, admin_team, client): client.login(email='dummy@dummy.dummy', password='dummy') - client.post('/control/organizer/dummy/team/add', { + r = client.post('/control/organizer/dummy/team/add', { 'name': 'Foo', - 'can_create_events': 'on', + 'organizer_organizer.events': "create", + 'organizer_organizer.settings.general': "EMPTY", + 'organizer_organizer.teams': "EMPTY", + 'organizer_organizer.giftcards': "EMPTY", + 'organizer_organizer.customers': "EMPTY", + 'organizer_organizer.reusablemedia': "EMPTY", + 'organizer_organizer.devices': "EMPTY", + 'organizer_organizer.seatingplans': "EMPTY", + 'organizer_organizer.outgoingmails': "EMPTY", + 'event_event.settings.general': "write", + 'event_event.settings.payment': "EMPTY", + 'event_event.settings.tax': "EMPTY", + 'event_event.settings.invoicing': "EMPTY", + 'event_event.subevents': "EMPTY", + 'event_event.items': "EMPTY", + 'event_event.orders': "EMPTY", + 'event_event.vouchers': "EMPTY", + 'event_event': "EMPTY", 'limit_events': str(event.pk), - 'can_change_event_settings': 'on' }, follow=True) + assert 'alert-success' in r.content.decode() with scopes_disabled(): t = Team.objects.last() - assert t.can_change_event_settings - assert t.can_create_events - assert not t.can_change_organizer_settings + assert not t.all_event_permissions + assert t.limit_event_permissions == {"event.settings.general:write": True} + assert not t.all_organizer_permissions + assert t.limit_organizer_permissions == {"organizer.events:create": True} assert list(t.limit_events.all()) == [event] assert list(t.members.all()) == [admin_user] @@ -247,15 +265,36 @@ def test_create_team(event, admin_user, admin_team, client): @pytest.mark.django_db def test_update_team(event, admin_user, admin_team, client): client.login(email='dummy@dummy.dummy', password='dummy') - client.post('/control/organizer/dummy/team/{}/edit'.format(admin_team.pk), { + r = client.post('/control/organizer/dummy/team/{}/edit'.format(admin_team.pk), { 'name': 'Admin', - 'can_change_teams': 'on', 'limit_events': str(event.pk), - 'can_change_event_settings': 'on' + 'all_event_permissions': 'on', + 'all_organizer_permissions': '', + 'organizer_organizer.events': "EMPTY", + 'organizer_organizer.settings.general': "EMPTY", + 'organizer_organizer.teams': "write", + 'organizer_organizer.giftcards': "EMPTY", + 'organizer_organizer.customers': "EMPTY", + 'organizer_organizer.reusablemedia': "EMPTY", + 'organizer_organizer.devices': "EMPTY", + 'organizer_organizer.seatingplans': "EMPTY", + 'organizer_organizer.outgoingmails': "EMPTY", + 'event_event.settings.general': "write", + 'event_event.settings.payment': "EMPTY", + 'event_event.settings.tax': "EMPTY", + 'event_event.settings.invoicing': "EMPTY", + 'event_event.subevents': "EMPTY", + 'event_event.items': "EMPTY", + 'event_event.orders': "EMPTY", + 'event_event.vouchers': "EMPTY", + 'event_event': "EMPTY", }, follow=True) + assert 'alert-success' in r.content.decode() admin_team.refresh_from_db() - assert admin_team.can_change_event_settings - assert not admin_team.can_change_organizer_settings + assert admin_team.all_event_permissions + assert admin_team.limit_event_permissions == {} + assert not admin_team.all_organizer_permissions + assert admin_team.limit_organizer_permissions == {"organizer.teams:write": True} with scopes_disabled(): assert list(admin_team.limit_events.all()) == [event] @@ -265,7 +304,23 @@ def test_update_last_team_to_be_no_admin(event, admin_user, admin_team, client): client.login(email='dummy@dummy.dummy', password='dummy') resp = client.post('/control/organizer/dummy/team/{}/edit'.format(admin_team.pk), { 'name': 'Admin', - 'can_change_event_settings': 'on' + 'organizer_organizer.events': "write", + 'organizer_organizer.settings.general': "EMPTY", + 'organizer_organizer.teams': "EMPTY", + 'organizer_organizer.giftcards': "EMPTY", + 'organizer_organizer.customers': "EMPTY", + 'organizer_organizer.reusablemedia': "EMPTY", + 'organizer_organizer.devices': "EMPTY", + 'organizer_organizer.seatingplans': "EMPTY", + 'event_event.settings.general': "write", + 'event_event.settings.payment': "EMPTY", + 'event_event.settings.tax': "EMPTY", + 'event_event.settings.invoicing': "EMPTY", + 'event_event.subevents': "EMPTY", + 'event_event.items': "EMPTY", + 'event_event.orders': "EMPTY", + 'event_event.vouchers': "EMPTY", + 'event_event': "EMPTY", }, follow=True) assert 'alert-danger' in resp.content.decode() diff --git a/src/tests/control/test_user.py b/src/tests/control/test_user.py index 68a14d9dc5..de58e9579e 100644 --- a/src/tests/control/test_user.py +++ b/src/tests/control/test_user.py @@ -482,7 +482,7 @@ class UserSettingsNotificationsTest(SoupTest): organizer=o, name='Dummy', slug='dummy', date_from=now(), plugins='pretix.plugins.banktransfer' ) - t = o.teams.create(can_change_orders=True, all_events=True) + t = o.teams.create(limit_event_permissions={"event.orders:write": True}, all_events=True) t.members.add(self.user) def test_toggle_all(self): diff --git a/src/tests/control/test_views.py b/src/tests/control/test_views.py index d73021dddc..f169df7024 100644 --- a/src/tests/control/test_views.py +++ b/src/tests/control/test_views.py @@ -110,9 +110,8 @@ def logged_in_client(client, event): user = User.objects.create_superuser('dummy@dummy.dummy', 'dummy') t = Team.objects.create( organizer=event.organizer, - all_events=True, can_create_events=True, can_change_teams=True, - can_change_organizer_settings=True, can_change_event_settings=True, can_change_items=True, - can_view_orders=True, can_change_orders=True, can_view_vouchers=True, can_change_vouchers=True + all_event_permissions=True, + all_organizer_permissions=True, ) t.members.add(user) client.force_login(user) diff --git a/src/tests/control/test_vouchers.py b/src/tests/control/test_vouchers.py index 874b445744..80a9b0e94f 100644 --- a/src/tests/control/test_vouchers.py +++ b/src/tests/control/test_vouchers.py @@ -58,7 +58,7 @@ class VoucherFormTest(SoupTestMixin, TransactionTestCase): organizer=self.orga, name='30C3', slug='30c3', date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc), ) - t = Team.objects.create(organizer=self.orga, can_view_vouchers=True, can_change_vouchers=True) + t = Team.objects.create(organizer=self.orga, all_event_permissions=True) t.members.add(self.user) t.limit_events.add(self.event) self.client.login(email='dummy@dummy.dummy', password='dummy') diff --git a/src/tests/control/test_waitinglist.py b/src/tests/control/test_waitinglist.py index c23caeda44..3a496310c4 100644 --- a/src/tests/control/test_waitinglist.py +++ b/src/tests/control/test_waitinglist.py @@ -66,7 +66,7 @@ def env(): event=event, item=item2, email='valid@example.org', voucher=v ) - t = Team.objects.create(organizer=o, can_view_orders=True, can_change_orders=True) + t = Team.objects.create(organizer=o, all_event_permissions=True) t.members.add(user) t.limit_events.add(event) diff --git a/src/tests/control/test_webhooks.py b/src/tests/control/test_webhooks.py index 97f1e773b4..3f8ef1b683 100644 --- a/src/tests/control/test_webhooks.py +++ b/src/tests/control/test_webhooks.py @@ -63,7 +63,7 @@ def admin_user(admin_team): @pytest.fixture def admin_team(organizer): - return Team.objects.create(organizer=organizer, can_change_organizer_settings=True, name='Admin team') + return Team.objects.create(organizer=organizer, all_organizer_permissions=True, name='Admin team') @pytest.mark.django_db diff --git a/src/tests/plugins/autocheckin/conftest.py b/src/tests/plugins/autocheckin/conftest.py index d8b3b9e488..3cd16da3b8 100644 --- a/src/tests/plugins/autocheckin/conftest.py +++ b/src/tests/plugins/autocheckin/conftest.py @@ -68,18 +68,8 @@ def team(organizer): organizer=organizer, name="Test-Team", all_events=True, - can_change_teams=True, - can_manage_gift_cards=True, - can_change_items=True, - can_create_events=True, - can_change_event_settings=True, - can_change_vouchers=True, - can_view_vouchers=True, - can_view_orders=True, - can_change_orders=True, - can_manage_customers=True, - can_manage_reusable_media=True, - can_change_organizer_settings=True, + all_organizer_permissions=True, + all_event_permissions=True, ) diff --git a/src/tests/plugins/autocheckin/test_control.py b/src/tests/plugins/autocheckin/test_control.py index 610400f606..bd416a1407 100644 --- a/src/tests/plugins/autocheckin/test_control.py +++ b/src/tests/plugins/autocheckin/test_control.py @@ -46,24 +46,16 @@ class AutoCheckinFormTest(SoupTest): ) t = Team.objects.create( organizer=self.orga1, - can_change_event_settings=True, - can_view_orders=True, - can_change_items=True, + all_organizer_permissions=True, + all_event_permissions=True, all_events=True, - can_create_events=True, - can_change_orders=True, - can_change_vouchers=True, ) t.members.add(self.user) t = Team.objects.create( organizer=self.orga2, - can_change_event_settings=True, - can_view_orders=True, - can_change_items=True, + all_organizer_permissions=True, + all_event_permissions=True, all_events=True, - can_create_events=True, - can_change_orders=True, - can_change_vouchers=True, ) t.members.add(self.user) self.client.login(email="dummy@dummy.dummy", password="dummy") diff --git a/src/tests/plugins/badges/test_control.py b/src/tests/plugins/badges/test_control.py index 9053e18a16..fbad72b18a 100644 --- a/src/tests/plugins/badges/test_control.py +++ b/src/tests/plugins/badges/test_control.py @@ -53,9 +53,7 @@ class BadgeLayoutFormTest(SoupTest): date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc), ) self.item1 = Item.objects.create(event=self.event1, name="Standard", default_price=0, position=1) - t = Team.objects.create(organizer=self.orga1, can_change_event_settings=True, can_view_orders=True, - can_change_items=True, all_events=True, can_create_events=True, - can_change_orders=True, can_change_vouchers=True) + t = Team.objects.create(organizer=self.orga1, all_events=True, all_event_permissions=True, all_organizer_permissions=True) t.members.add(self.user) t.limit_events.add(self.event1) self.client.login(email='dummy@dummy.dummy', password='dummy') diff --git a/src/tests/plugins/banktransfer/test_actions.py b/src/tests/plugins/banktransfer/test_actions.py index 11dd96311f..3c79aed70f 100644 --- a/src/tests/plugins/banktransfer/test_actions.py +++ b/src/tests/plugins/banktransfer/test_actions.py @@ -41,7 +41,7 @@ def env(): date_from=now(), plugins='pretix.plugins.banktransfer' ) user = User.objects.create_user('dummy@dummy.dummy', 'dummy') - t = Team.objects.create(organizer=event.organizer, can_view_orders=True, can_change_orders=True) + t = Team.objects.create(organizer=event.organizer, all_event_permissions=True) t.members.add(user) t.limit_events.add(event) o1 = Order.objects.create( @@ -274,7 +274,8 @@ def test_assign_order_organizer_no_permission(env, client): state=BankTransaction.STATE_NOMATCH, amount=23, date='unknown') team = env[1].teams.first() - team.can_change_orders = False + team.limit_event_permissions = {} + team.all_event_permissions = False team.save() client.login(email='dummy@dummy.dummy', password='dummy') r = client.post('/control/organizer/{}/banktransfer/action/'.format(env[0].organizer.slug), { @@ -290,7 +291,12 @@ def test_assign_order_organizer_no_permission_for_event(env, client): state=BankTransaction.STATE_NOMATCH, amount=23, date='unknown') team = env[1].teams.first() - team.limit_events.clear() + event2 = Event.objects.create( + organizer=env[0].organizer, name='Dummy2', slug='dummy2', + date_from=now(), plugins='pretix.plugins.banktransfer' + ) + with scopes_disabled(): + team.limit_events.set([event2]) client.login(email='dummy@dummy.dummy', password='dummy') r = json.loads(client.post('/control/organizer/{}/banktransfer/action/'.format(env[0].organizer.slug), { 'action_{}'.format(trans.pk): 'assign:{}-{}'.format(env[0].slug.upper(), env[2].code), diff --git a/src/tests/plugins/banktransfer/test_api.py b/src/tests/plugins/banktransfer/test_api.py index 4300c85257..d008a08792 100644 --- a/src/tests/plugins/banktransfer/test_api.py +++ b/src/tests/plugins/banktransfer/test_api.py @@ -42,7 +42,7 @@ def env(): date_from=now(), plugins='pretix.plugins.banktransfer' ) user = User.objects.create_user('dummy@dummy.dummy', 'dummy') - t = Team.objects.create(organizer=event.organizer, can_view_orders=True, can_change_orders=True) + t = Team.objects.create(organizer=event.organizer, all_event_permissions=True) t.members.add(user) t.limit_events.add(event) o1 = Order.objects.create( diff --git a/src/tests/plugins/banktransfer/test_import.py b/src/tests/plugins/banktransfer/test_import.py index d1bdf60a5d..ff5547e20f 100644 --- a/src/tests/plugins/banktransfer/test_import.py +++ b/src/tests/plugins/banktransfer/test_import.py @@ -61,7 +61,7 @@ def env(): event.settings.invoice_numbers_prefix = 'INV-' event.settings.invoice_numbers_counter_length = 3 user = User.objects.create_user('dummy@dummy.dummy', 'dummy') - t = Team.objects.create(organizer=event.organizer, can_view_orders=True, can_change_orders=True) + t = Team.objects.create(organizer=event.organizer, all_event_permissions=True) t.members.add(user) t.limit_events.add(event) o1 = Order.objects.create( diff --git a/src/tests/plugins/banktransfer/test_refund.py b/src/tests/plugins/banktransfer/test_refund.py index 02b689e8aa..f970f0f3f2 100644 --- a/src/tests/plugins/banktransfer/test_refund.py +++ b/src/tests/plugins/banktransfer/test_refund.py @@ -40,7 +40,7 @@ def env(): date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal' ) user = User.objects.create_user('dummy@dummy.dummy', 'dummy') - t = Team.objects.create(organizer=event.organizer, can_view_orders=True, can_change_orders=True) + t = Team.objects.create(organizer=event.organizer, all_event_permissions=True) t.members.add(user) t.limit_events.add(event) order = Order.objects.create( diff --git a/src/tests/plugins/banktransfer/test_refund_export.py b/src/tests/plugins/banktransfer/test_refund_export.py index 360310cdc4..b6c5cf255e 100644 --- a/src/tests/plugins/banktransfer/test_refund_export.py +++ b/src/tests/plugins/banktransfer/test_refund_export.py @@ -41,7 +41,7 @@ def env(): date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal' ) user = User.objects.create_user('dummy@dummy.dummy', 'dummy') - t = Team.objects.create(organizer=event.organizer, can_view_orders=True, can_change_orders=True) + t = Team.objects.create(organizer=event.organizer, all_event_permissions=True) t.members.add(user) t.limit_events.add(event) order = Order.objects.create( diff --git a/src/tests/plugins/paypal/test_settings.py b/src/tests/plugins/paypal/test_settings.py index eb78411ddf..e6b969c546 100644 --- a/src/tests/plugins/paypal/test_settings.py +++ b/src/tests/plugins/paypal/test_settings.py @@ -51,7 +51,7 @@ def env(client): event.settings.set('attendee_names_asked', False) event.settings.set('payment_paypal__enabled', True) user = User.objects.create_user('dummy@dummy.dummy', 'dummy') - t = Team.objects.create(organizer=event.organizer, can_change_event_settings=True) + t = Team.objects.create(organizer=event.organizer, all_event_permissions=True) t.members.add(user) t.limit_events.add(event) client.force_login(user) diff --git a/src/tests/plugins/paypal/test_webhook.py b/src/tests/plugins/paypal/test_webhook.py index 1d5c74516a..c6dd2223c8 100644 --- a/src/tests/plugins/paypal/test_webhook.py +++ b/src/tests/plugins/paypal/test_webhook.py @@ -41,7 +41,7 @@ def env(): organizer=o, name='Dummy', slug='dummy', plugins='pretix.plugins.paypal', date_from=now(), live=True ) - t = Team.objects.create(organizer=event.organizer, can_view_orders=True, can_change_orders=True) + t = Team.objects.create(organizer=event.organizer, all_event_permissions=True) t.members.add(user) t.limit_events.add(event) o1 = Order.objects.create( diff --git a/src/tests/plugins/paypal2/test_settings.py b/src/tests/plugins/paypal2/test_settings.py index 24a2a74efb..d74950d7ce 100644 --- a/src/tests/plugins/paypal2/test_settings.py +++ b/src/tests/plugins/paypal2/test_settings.py @@ -51,7 +51,7 @@ def env(client): event.settings.set('attendee_names_asked', False) event.settings.set('payment_paypal__enabled', True) user = User.objects.create_user('dummy@dummy.dummy', 'dummy') - t = Team.objects.create(organizer=event.organizer, can_change_event_settings=True) + t = Team.objects.create(organizer=event.organizer, all_event_permissions=True) t.members.add(user) t.limit_events.add(event) client.force_login(user) diff --git a/src/tests/plugins/paypal2/test_webhook.py b/src/tests/plugins/paypal2/test_webhook.py index 2c0cf68a26..b1355018c7 100644 --- a/src/tests/plugins/paypal2/test_webhook.py +++ b/src/tests/plugins/paypal2/test_webhook.py @@ -42,7 +42,7 @@ def env(): organizer=o, name='Dummy', slug='dummy', plugins='pretix.plugins.paypal2', date_from=now(), live=True ) - t = Team.objects.create(organizer=event.organizer, can_view_orders=True, can_change_orders=True) + t = Team.objects.create(organizer=event.organizer, all_event_permissions=True) t.members.add(user) t.limit_events.add(event) o1 = Order.objects.create( diff --git a/src/tests/plugins/sendmail/test_sendmail.py b/src/tests/plugins/sendmail/test_sendmail.py index e1322b393e..c971291d03 100644 --- a/src/tests/plugins/sendmail/test_sendmail.py +++ b/src/tests/plugins/sendmail/test_sendmail.py @@ -47,7 +47,7 @@ from pretix.base.models import Checkin, Item, Order, OrderPosition, Team, User def logged_in_client(client, event): """Returns a logged client""" user = User.objects.create_superuser('dummy@dummy.dummy', 'dummy') - t = Team.objects.create(organizer=event.organizer, can_view_orders=True, can_change_orders=True) + t = Team.objects.create(organizer=event.organizer, all_event_permissions=True) t.members.add(user) t.limit_events.add(event) client.force_login(user) diff --git a/src/tests/plugins/stripe/test_settings.py b/src/tests/plugins/stripe/test_settings.py index 12ecbf4b27..ce634241c3 100644 --- a/src/tests/plugins/stripe/test_settings.py +++ b/src/tests/plugins/stripe/test_settings.py @@ -74,7 +74,7 @@ def env(client): event.settings.set('attendee_names_asked', False) event.settings.set('payment_stripe__enabled', True) user = User.objects.create_user('dummy@dummy.dummy', 'dummy') - t = Team.objects.create(organizer=event.organizer, can_change_event_settings=True) + t = Team.objects.create(organizer=event.organizer, all_event_permissions=True) t.members.add(user) t.limit_events.add(event) client.force_login(user) diff --git a/src/tests/plugins/stripe/test_webhook.py b/src/tests/plugins/stripe/test_webhook.py index 3885e67628..d9f2fa4155 100644 --- a/src/tests/plugins/stripe/test_webhook.py +++ b/src/tests/plugins/stripe/test_webhook.py @@ -41,7 +41,7 @@ def env(): organizer=o, name='Dummy', slug='dummy', plugins='pretix.plugins.stripe', date_from=now(), live=True ) - t = Team.objects.create(organizer=event.organizer, can_view_orders=True, can_change_orders=True) + t = Team.objects.create(organizer=event.organizer, all_event_permissions=True) t.members.add(user) t.limit_events.add(event) o1 = Order.objects.create( diff --git a/src/tests/plugins/ticketoutputpdf/test_api.py b/src/tests/plugins/ticketoutputpdf/test_api.py index 533eb1bf37..e0e8b3d6a7 100644 --- a/src/tests/plugins/ticketoutputpdf/test_api.py +++ b/src/tests/plugins/ticketoutputpdf/test_api.py @@ -43,7 +43,7 @@ def env(): organizer=o, name='Dummy', slug='dummy', date_from=now(), plugins='pretix.plugins.banktransfer' ) - t = Team.objects.create(organizer=event.organizer, can_view_orders=True) + t = Team.objects.create(organizer=event.organizer, all_event_permissions=True) t.limit_events.add(event) item1 = Item.objects.create(event=event, name="Ticket", default_price=23) tl = event.ticket_layouts.create( diff --git a/src/tests/plugins/ticketoutputpdf/test_control.py b/src/tests/plugins/ticketoutputpdf/test_control.py index e0ca09b1df..a0d63cbcb3 100644 --- a/src/tests/plugins/ticketoutputpdf/test_control.py +++ b/src/tests/plugins/ticketoutputpdf/test_control.py @@ -54,9 +54,7 @@ class TicketLayoutFormTest(SoupTest): date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc), ) self.item1 = Item.objects.create(event=self.event1, name="Standard", default_price=0, position=1) - t = Team.objects.create(organizer=self.orga1, can_change_event_settings=True, can_view_orders=True, - can_change_items=True, all_events=True, can_create_events=True, - can_change_vouchers=True, can_change_orders=True) + t = Team.objects.create(organizer=self.orga1, all_event_permissions=True, all_organizer_permissions=True) t.members.add(self.user) t.limit_events.add(self.event1) self.client.login(email='dummy@dummy.dummy', password='dummy') diff --git a/src/tests/plugins/ticketoutputpdf/test_defaults_and_copy.py b/src/tests/plugins/ticketoutputpdf/test_defaults_and_copy.py index d5b12ba90b..b8167fa778 100644 --- a/src/tests/plugins/ticketoutputpdf/test_defaults_and_copy.py +++ b/src/tests/plugins/ticketoutputpdf/test_defaults_and_copy.py @@ -36,7 +36,7 @@ def env(): date_from=now(), plugins='pretix.plugins.ticketoutputpdf' ) user = User.objects.create_user('dummy@dummy.dummy', 'dummy') - t = Team.objects.create(organizer=event.organizer, can_create_events=True, can_change_event_settings=True, can_change_items=True) + t = Team.objects.create(organizer=event.organizer, all_event_permissions=True, all_organizer_permissions=True) t.members.add(user) t.limit_events.add(event) item1 = Item.objects.create(event=event, name="Ticket", default_price=23) diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index 2f389bebe1..58505426e7 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -70,7 +70,7 @@ class EventTestMixin: live=True, ) self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy') - t = Team.objects.create(organizer=self.orga, can_change_event_settings=True) + t = Team.objects.create(organizer=self.orga, all_event_permissions=True) t.members.add(self.user) t.limit_events.add(self.event) diff --git a/src/tests/presale/test_timemachine.py b/src/tests/presale/test_timemachine.py index 92261b328d..e4d66e2df5 100644 --- a/src/tests/presale/test_timemachine.py +++ b/src/tests/presale/test_timemachine.py @@ -28,8 +28,7 @@ class TimemachineTestMixin: @scopes_disabled() def _login_with_permission(self, orga): self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy') - self.team1 = Team.objects.create(organizer=orga, can_create_events=True, can_change_event_settings=True, - can_change_items=True, all_events=True) + self.team1 = Team.objects.create(organizer=orga, all_event_permissions=True, all_events=True) self.team1.members.add(self.user) self.client.login(email='dummy@dummy.dummy', password='dummy') diff --git a/src/tests/testdummy/signals.py b/src/tests/testdummy/signals.py index 3fcc84a8f9..4224d17c97 100644 --- a/src/tests/testdummy/signals.py +++ b/src/tests/testdummy/signals.py @@ -22,11 +22,13 @@ from django.dispatch import receiver from pretix.base.channels import SalesChannelType +from pretix.base.exporter import BaseExporter from pretix.base.invoicing.transmission import ( TransmissionProvider, transmission_providers, ) from pretix.base.models import Invoice from pretix.base.signals import ( + register_data_exporters, register_multievent_data_exporters, register_payment_providers, register_sales_channel_types, register_ticket_outputs, ) @@ -48,6 +50,40 @@ def register_payment_provider(sender, **kwargs): return [DummyPaymentProvider, DummyFullRefundablePaymentProvider, DummyPartialRefundablePaymentProvider] +class DummyOrdersExporter(BaseExporter): + verbose_name = "Dummy orders" + identifier = "dummy_orders" + + +class DummyVoucherExporter(BaseExporter): + verbose_name = "Dummy orders" + identifier = "dummy_vouchers" + + @classmethod + def get_required_event_permission(cls) -> str: + return "event.vouchers:read" + + +@receiver(register_data_exporters, dispatch_uid="dummy_exporter_o") +def register_data_exporters_recv_o(sender, **kwargs): + return DummyOrdersExporter + + +@receiver(register_data_exporters, dispatch_uid="dummy_exporter_v") +def register_data_exporters_recv_v(sender, **kwargs): + return DummyVoucherExporter + + +@receiver(register_multievent_data_exporters, dispatch_uid="dummy_exporter_multi_o") +def register_multievent_data_exporters_recv_o(sender, **kwargs): + return DummyOrdersExporter + + +@receiver(register_multievent_data_exporters, dispatch_uid="dummy_exporter_multi_v") +def register_multievent_data_exporters_recv(sender, **kwargs): + return DummyVoucherExporter + + class FoobazSalesChannel(SalesChannelType): identifier = "baz" verbose_name = "Foobar"