Files
pretix_original/src/pretix/control/navigation.py
Raphael Michel df0b580dd6 Pluggable permissions (#5728)
* Data model draft

* Refactor query and assignment usages of old permissions

* Backend UI

* API serializer

* Big string replace

* Docs, tests and fixes for teams api

* Update docs for device auth

* Eliminate old names

* Make tests pass

* Use new permissions, remove inconsistencies

* Add test for translations

* Show plugin permissions

* Add permission for seating plans

* Fix plugin activation

* Fix failing test

* Refactor to permission groups

* Update doc/api/resources/devices.rst

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

* Update doc/api/resources/events.rst

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

* Update src/pretix/api/serializers/organizer.py

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

* Fix typo

* Fix python version compat

* Replacement after rebase

* Add proper permission handling for exports

* Docs for exporters

* Runtime linting of permission names

* Fix typos

* Show export page even without orders permission

* More legacy compat

* Do not strongly validate before plugins are loaded

* Rebase migration

* Add permission for outgoing mails

* Review notes

* Update doc/api/resources/teams.rst

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* Clean up logic around exporters

* Review and failures

* Fix migration leading to forbidden combination

* Handle permissions on event copying

* Remove print-statements

* Make test clearer

* Review feedback

* Add AnyPermissionOf

* migration safety

---------

Co-authored-by: luelista <weller@rami.io>
Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-03-17 14:43:56 +01:00

741 lines
28 KiB
Python

#
# 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 <https://pretix.eu/about/en/license>.
#
# 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
# <https://www.gnu.org/licenses/>.
#
from django.http import HttpRequest
from django.urls import reverse
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.control.signals import (
nav_event, nav_event_settings, nav_global, nav_organizer,
)
def get_event_navigation(request: HttpRequest):
url = request.resolver_match
if not url:
return []
nav = [
{
'label': _('Dashboard'),
'url': reverse('control:event.index', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': (url.url_name == 'event.index'),
'icon': 'dashboard',
}
]
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={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name == 'event.settings.plugins',
},
{
'label': _('Tickets'),
'url': reverse('control:event.settings.tickets', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name == 'event.settings.tickets',
},
{
'label': _('Email'),
'url': reverse('control:event.settings.mail', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name == 'event.settings.mail',
}
]
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={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name == 'event.settings.cancel',
},
{
'label': _('Widget'),
'url': reverse('control:event.settings.widget', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'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': event_settings[0]["url"],
'active': False,
'icon': 'wrench',
'children': event_settings
})
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 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'),
'url': reverse('control:event.orders', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name in ('event.orders', 'event.order', 'event.orders.search') or "event.order." in url.url_name,
},
{
'label': _('Overview'),
'url': reverse('control:event.orders.overview', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.overview' in url.url_name,
},
{
'label': _('Refunds'),
'url': reverse('control:event.orders.refunds', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.refunds' in url.url_name,
},
{
'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,
},
{
'label': _('Waiting list'),
'url': reverse('control:event.orders.waitinglist', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.waitinglist' in url.url_name,
},
]
if 'event.orders:write' in request.eventpermset:
children.append({
'label': _('Import'),
'url': reverse('control:event.orders.import', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.import' in url.url_name,
})
nav.append({
'label': _('Orders'),
'url': reverse('control:event.orders', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': False,
'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 'event.vouchers:read' in request.eventpermset:
nav.append({
'label': _('Vouchers'),
'url': reverse('control:event.vouchers', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': False,
'icon': 'tags',
'children': [
{
'label': _('All vouchers'),
'url': reverse('control:event.vouchers', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name != 'event.vouchers.tags' and "event.vouchers" in url.url_name,
},
{
'label': _('Tags'),
'url': reverse('control:event.vouchers.tags', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.vouchers.tags' in url.url_name,
},
]
})
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={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': False,
'icon': 'check-square-o',
'children': [
{
'label': _('Check-in lists'),
'url': reverse('control:event.orders.checkinlists', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.checkinlists' in url.url_name,
},
{
'label': _('Check-in history'),
'url': reverse('control:event.orders.checkins', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.checkins' in url.url_name,
},
]
})
merge_in(nav, sorted(
sum((list(a[1]) for a in nav_event.send(request.event, request=request)), []),
key=lambda r: (1 if r.get('parent') else 0, r['label'])
))
return nav
def get_global_navigation(request):
url = request.resolver_match
if not url:
return []
has_staff_session = request.user.has_active_staff_session(request.session.session_key)
nav = [
{
'label': _('Dashboard'),
'url': reverse('control:index'),
'active': (url.url_name == 'index'),
'icon': 'dashboard',
},
]
if request.user.is_in_any_teams:
nav += [
{
'label': _('Events'),
'url': reverse('control:events'),
'active': 'events' in url.url_name,
'icon': 'calendar',
},
{
'label': _('Organizers'),
'url': reverse('control:organizers'),
'active': 'organizers' in url.url_name,
'icon': 'group',
},
{
'label': _('Search'),
'url': reverse('control:search.orders'),
'active': False,
'icon': 'search',
'children': [
{
'label': _('Orders'),
'url': reverse('control:search.orders'),
'active': 'search.orders' in url.url_name,
'icon': 'search',
},
{
'label': _('Payments'),
'url': reverse('control:search.payments'),
'active': 'search.payments' in url.url_name,
'icon': 'search',
},
]
},
]
nav += [
{
'label': _('User settings'),
'url': reverse('control:user.settings'),
'active': False,
'icon': 'user',
'children': [
{
'label': _('General'),
'url': reverse('control:user.settings'),
'active': 'user.settings' == url.url_name,
},
{
'label': _('Notifications'),
'url': reverse('control:user.settings.notifications'),
'active': 'user.settings.notifications' == url.url_name,
},
{
'label': _('2FA'),
'url': reverse('control:user.settings.2fa'),
'active': 'user.settings.2fa' in url.url_name,
},
{
'label': _('Authorized apps'),
'url': reverse('control:user.settings.oauth.list'),
'active': 'user.settings.oauth' in url.url_name,
},
{
'label': _('Account history'),
'url': reverse('control:user.settings.history'),
'active': 'user.settings.history' in url.url_name,
},
]
},
]
if has_staff_session:
nav.append({
'label': _('Users'),
'url': reverse('control:users'),
'active': False,
'icon': 'user',
'children': [
{
'label': _('All users'),
'url': reverse('control:users'),
'active': ('users' in url.url_name),
},
{
'label': _('Admin sessions'),
'url': reverse('control:user.sudo.list'),
'active': ('sudo' in url.url_name),
},
]
})
nav.append({
'label': _('Global settings'),
'url': reverse('control:global.settings'),
'active': False,
'icon': 'wrench',
'children': [
{
'label': _('Settings'),
'url': reverse('control:global.settings'),
'active': (url.url_name == 'global.settings'),
},
{
'label': _('Update check'),
'url': reverse('control:global.update'),
'active': (url.url_name == 'global.update'),
},
{
'label': _('License check'),
'url': reverse('control:global.license'),
'active': (url.url_name == 'global.license'),
},
{
'label': _('System report'),
'url': reverse('control:global.sysreport'),
'active': (url.url_name == 'global.sysreport'),
},
{
'label': _('Data sync problems'),
'url': reverse('control:global.datasync.failedjobs'),
'active': (url.url_name == 'global.datasync.failedjobs'),
},
]
})
merge_in(nav, sorted(
sum((list(a[1]) for a in nav_global.send(request, request=request)), []),
key=lambda r: (1 if r.get('parent') else 0, r['label'])
))
return nav
def get_organizer_navigation(request):
url = request.resolver_match
if not url:
return []
nav = [
{
'label': _('Events'),
'url': reverse('control:organizer', kwargs={
'organizer': request.organizer.slug
}),
'active': url.url_name == 'organizer',
'icon': 'calendar',
},
]
if 'organizer.settings.general:write' in request.orgapermset:
nav.append({
'label': _('Settings'),
'url': reverse('control:organizer.edit', kwargs={
'organizer': request.organizer.slug
}),
'icon': 'wrench',
'children': [
{
'label': _('General'),
'url': reverse('control:organizer.edit', kwargs={
'organizer': request.organizer.slug
}),
'active': url.url_name == 'organizer.edit',
},
{
'label': _('Plugins'),
'url': reverse('control:organizer.settings.plugins', kwargs={
'organizer': request.organizer.slug,
}),
'active': url.url_name == 'organizer.settings.plugins' or url.url_name == 'organizer.settings.plugin-events',
},
{
'label': _('Event metadata'),
'url': reverse('control:organizer.properties', kwargs={
'organizer': request.organizer.slug
}),
'active': url.url_name.startswith('organizer.propert'),
},
{
'label': _('Email'),
'url': reverse('control:organizer.settings.mail', kwargs={
'organizer': request.organizer.slug,
}),
'active': url.url_name == 'organizer.settings.mail',
},
{
'label': _('Sales channels'),
'url': reverse('control:organizer.channels', kwargs={
'organizer': request.organizer.slug
}),
'active': url.url_name.startswith('organizer.channel'),
},
{
'label': _('Webhooks'),
'url': reverse('control:organizer.webhooks', kwargs={
'organizer': request.organizer.slug
}),
'active': 'organizer.webhook' in url.url_name,
'icon': 'bolt',
},
]
})
if 'organizer.teams:write' in request.orgapermset:
nav.append({
'label': _('Teams'),
'url': reverse('control:organizer.teams', kwargs={
'organizer': request.organizer.slug
}),
'active': 'organizer.team' in url.url_name and url.namespace == 'control',
'icon': 'group',
})
if 'organizer.giftcards:read' in request.orgapermset or 'organizer.giftcards:write' in request.orgapermset:
children = []
children.append({
'label': _('Gift cards'),
'url': reverse('control:organizer.giftcards', kwargs={
'organizer': request.organizer.slug
}),
'active': 'organizer.giftcard' in url.url_name and 'acceptance' not in url.url_name,
'children': children,
})
if 'organizer.settings.general:write' in request.orgapermset:
children.append(
{
'label': _('Acceptance'),
'url': reverse('control:organizer.giftcards.acceptance', kwargs={
'organizer': request.organizer.slug
}),
'active': 'organizer.giftcards.acceptance' in url.url_name,
}
)
nav.append({
'label': _('Gift cards'),
'url': reverse('control:organizer.giftcards', kwargs={
'organizer': request.organizer.slug
}),
'icon': 'credit-card',
'children': children,
})
if request.organizer.settings.customer_accounts:
children = []
if 'organizer.customers:read' in request.orgapermset or 'organizer.customers:write' in request.orgapermset:
children.append(
{
'label': _('Customers'),
'url': reverse('control:organizer.customers', kwargs={
'organizer': request.organizer.slug
}),
'active': 'organizer.customer' in url.url_name,
}
)
if 'organizer.settings.general:write' in request.orgapermset:
children.append(
{
'label': _('Membership types'),
'url': reverse('control:organizer.membershiptypes', kwargs={
'organizer': request.organizer.slug
}),
'active': 'organizer.membershiptype' in url.url_name,
}
)
children.append(
{
'label': _('SSO clients'),
'url': reverse('control:organizer.ssoclients', kwargs={
'organizer': request.organizer.slug
}),
'active': 'organizer.ssoclient' in url.url_name,
}
)
children.append(
{
'label': _('SSO providers'),
'url': reverse('control:organizer.ssoproviders', kwargs={
'organizer': request.organizer.slug
}),
'active': 'organizer.ssoprovider' in url.url_name,
}
)
if children:
nav.append({
'label': _('Customer accounts'),
'url': reverse('control:organizer.customers', kwargs={
'organizer': request.organizer.slug
}),
'icon': 'user',
'children': children,
})
if request.organizer.settings.reusable_media_active:
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 'organizer.devices:read' in request.orgapermset or 'organizer.devices:write' in request.orgapermset:
nav.append({
'label': _('Devices'),
'url': reverse('control:organizer.devices', kwargs={
'organizer': request.organizer.slug
}),
'icon': 'tablet',
'children': [
{
'label': _('Devices'),
'url': reverse('control:organizer.devices', kwargs={
'organizer': request.organizer.slug
}),
'active': 'organizer.device' in url.url_name,
},
{
'label': _('Gates'),
'url': reverse('control:organizer.gates', kwargs={
'organizer': request.organizer.slug
}),
'active': 'organizer.gate' in url.url_name,
}
]
})
nav.append({
'label': _('Export'),
'url': reverse('control:organizer.export', kwargs={
'organizer': request.organizer.slug,
}),
'active': 'organizer.export' in url.url_name,
'icon': 'download',
})
if 'organizer.settings.general:write' in request.orgapermset:
merge_in(nav, [{
'parent': reverse('control:organizer.export', kwargs={
'organizer': request.organizer.slug,
}),
'label': _('Data sync problems'),
'url': reverse('control:organizer.datasync.failedjobs', kwargs={
'organizer': request.organizer.slug,
}),
'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={
'organizer': request.organizer.slug,
}),
'active': 'organizer.outgoingmail' in url.url_name,
'icon': 'send',
})
merge_in(nav, sorted(
sum((list(a[1]) for a in nav_organizer.send(request.organizer, request=request, organizer=request.organizer)),
[]),
key=lambda r: (1 if r.get('parent') else 0, r['label'])
))
return nav
def merge_in(nav, newnav):
for item in newnav:
if 'parent' in item:
parents = [n for n in nav if n['url'] == item['parent']]
if parents:
if 'children' not in parents[0]:
parents[0]['children'] = [
dict(parents[0])
]
parents[0]['active'] = False
parents[0]['children'].append(item)
continue
nav.append(item)