mirror of
https://github.com/pretix/pretix.git
synced 2026-07-15 06:51:54 +00:00
Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cee5f524b | |||
| 3a1a6b988e | |||
| bdc720f5a2 | |||
| 583184af38 | |||
| 63d9f7cea8 | |||
| c3d6fb1bd6 | |||
| bb43acd257 | |||
| e0744951e8 | |||
| fef1e356f7 | |||
| 81775f5d2d | |||
| 4bbcc398cc | |||
| 58840a5fd6 | |||
| e1b8e16a34 | |||
| 98fa6512e9 | |||
| 142f10c8cf | |||
| 2adc0d8f90 | |||
| 26ae459c96 | |||
| 2b5eec797d | |||
| c9f560feb2 | |||
| cea335e4b3 | |||
| aa2d387d54 | |||
| 95ac6bd3c8 | |||
| d475cba820 | |||
| bb8f50a4df | |||
| df0b580dd6 | |||
| eddde2b6c0 | |||
| a924d0a266 | |||
| 0754e9d049 | |||
| 0ebdab5389 | |||
| 16245aa516 | |||
| 35c5f02b59 | |||
| 11c332dfd6 | |||
| bf80dc37c5 | |||
| b95847c395 | |||
| b939b63606 | |||
| dfaa4c3359 | |||
| 8fc2e78a58 | |||
| 21bd00e7e6 | |||
| ed1966bc96 | |||
| fad5284f25 | |||
| f57530d3ff | |||
| 1427edf5ab | |||
| 4898475d56 | |||
| cdacc84553 | |||
| ef483d5229 | |||
| ad6f5a7b54 | |||
| ecc49d453d | |||
| c45070b190 | |||
| aea2a1ca10 | |||
| 3df9d86216 | |||
| c850a35235 | |||
| d791b9e108 | |||
| 2c9802d1cb | |||
| c39f1bfcc2 | |||
| 894128deab | |||
| d794738b8f | |||
| c62a6359e1 | |||
| e62bed7092 | |||
| 477601e9f9 | |||
| 6024fad056 | |||
| 8945e55900 | |||
| 7251ae953d | |||
| 3c1b806c8e | |||
| 3352ee2bbe | |||
| af28785fb9 | |||
| 54e4957e89 | |||
| f3597f1a44 | |||
| 2dade31f23 | |||
| 2e01887e79 | |||
| 5a7e7fbde3 | |||
| 7b296107c5 | |||
| 4f449ce6b4 | |||
| e6ea8fb5bf | |||
| 504191c005 | |||
| 547910beec | |||
| eef1560ede | |||
| 3d68bbb619 | |||
| dc4556d428 | |||
| 00cb77de8f | |||
| 99bd78f2c6 | |||
| d9b691690e | |||
| b1b2a688a8 | |||
| 3f92868dba | |||
| 961253bac4 | |||
| f3eb2fba6d | |||
| 4d8c22a839 | |||
| d544df098c | |||
| 6cfdfc2cd6 | |||
| 55299b1eae | |||
| 97857e7a67 | |||
| 469b777dcf | |||
| 333dc56ef7 | |||
| 2898f06f56 | |||
| 0d522c0eed | |||
| ee44d4c968 | |||
| 3d37f62c51 | |||
| 94dd5b0350 |
@@ -1,5 +1,6 @@
|
||||
doc/
|
||||
env/
|
||||
node_modules/
|
||||
res/
|
||||
local/
|
||||
.git/
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
[*.{js,jsx,ts,tsx,vue}]
|
||||
indent_style = tab
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
@@ -46,4 +46,7 @@ jobs:
|
||||
- name: Run build
|
||||
run: python -m build
|
||||
- name: Check files
|
||||
run: unzip -l dist/pretix*whl | grep node_modules || exit 1
|
||||
run: |
|
||||
for pat in 'static.dist/vite/widget/widget.js' 'static.dist/vite/control/assets/checkinrules/main-' 'static.dist/vite/control/assets/webcheckin/main-'; do
|
||||
unzip -l dist/pretix*whl | grep -q "$pat" || { echo "Missing: $pat"; exit 1; }
|
||||
done
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
run: make all compress
|
||||
- name: Run tests
|
||||
working-directory: ./src
|
||||
run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test -n 3 -p no:sugar --cov=./ --cov-report=xml tests --maxfail=100
|
||||
run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test -n 3 -p no:sugar --cov=./ --cov-report=xml tests --ignore=tests/e2e --maxfail=100
|
||||
- name: Run concurrency tests
|
||||
working-directory: ./src
|
||||
run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test tests/concurrency_tests/ --reuse-db
|
||||
@@ -82,3 +82,46 @@ jobs:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
if: matrix.database == 'postgres' && matrix.python-version == '3.11'
|
||||
e2e:
|
||||
runs-on: ubuntu-22.04
|
||||
name: E2E Tests
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: pretix
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres -d pretix"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install system dependencies
|
||||
run: sudo apt update && sudo apt install -y gettext
|
||||
- name: Install Python dependencies
|
||||
run: pip3 install uv && uv pip install --system -e ".[dev]" psycopg2-binary
|
||||
- name: Install JS dependencies
|
||||
working-directory: ./src
|
||||
run: make npminstall
|
||||
- name: Compile
|
||||
working-directory: ./src
|
||||
run: make all compress
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install
|
||||
- name: Run E2E tests
|
||||
working-directory: ./src
|
||||
run: PRETIX_CONFIG_FILE=tests/ci_postgres.cfg py.test tests/e2e/ -v --maxfail=10
|
||||
|
||||
@@ -24,5 +24,6 @@ local/
|
||||
.project
|
||||
.pydevproject
|
||||
.DS_Store
|
||||
node_modules/
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/*
|
||||
+7
-3
@@ -20,11 +20,11 @@ RUN apt-get update && \
|
||||
supervisor \
|
||||
libmaxminddb0 \
|
||||
libmaxminddb-dev \
|
||||
zlib1g-dev \
|
||||
nodejs \
|
||||
npm && \
|
||||
zlib1g-dev && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
dpkg-reconfigure locales && \
|
||||
locale-gen C.UTF-8 && \
|
||||
/usr/sbin/update-locale LANG=C.UTF-8 && \
|
||||
@@ -49,6 +49,10 @@ COPY deployment/docker/production_settings.py /pretix/src/production_settings.py
|
||||
COPY pyproject.toml /pretix/pyproject.toml
|
||||
COPY _build /pretix/_build
|
||||
COPY src /pretix/src
|
||||
COPY package.json /pretix/package.json
|
||||
COPY package-lock.json /pretix/package-lock.json
|
||||
COPY tsconfig.json /pretix/tsconfig.json
|
||||
COPY vite.config.ts /pretix/vite.config.ts
|
||||
|
||||
RUN pip3 install -U \
|
||||
pip \
|
||||
|
||||
@@ -48,3 +48,8 @@ recursive-include src Makefile
|
||||
recursive-exclude doc *
|
||||
recursive-exclude deployment *
|
||||
recursive-exclude res *
|
||||
|
||||
include package.json
|
||||
include package-lock.json
|
||||
include tsconfig.json
|
||||
include vite.config.ts
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -117,6 +117,8 @@ cancellation_date datetime Time of order c
|
||||
reliable for orders that have been cancelled,
|
||||
reactivated and cancelled again.
|
||||
plugin_data object Additional data added by plugins.
|
||||
use_gift_cards list of strings List of unique gift card secrets that are used to pay
|
||||
for this order.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
@@ -156,6 +158,10 @@ plugin_data object Additional data
|
||||
|
||||
The ``tax_rounding_mode`` attribute has been added.
|
||||
|
||||
.. versionchanged:: 2026.03
|
||||
|
||||
The ``use_gift_cards`` attribute has been added.
|
||||
|
||||
.. _order-position-resource:
|
||||
|
||||
Order position resource
|
||||
@@ -987,8 +993,6 @@ Creating orders
|
||||
|
||||
* does not support file upload questions
|
||||
|
||||
* does not support redeeming gift cards
|
||||
|
||||
* does not support or validate memberships
|
||||
|
||||
|
||||
@@ -1095,6 +1099,14 @@ Creating orders
|
||||
whether these emails are enabled for certain sales channels. If set to ``null``, behavior will be controlled by pretix'
|
||||
settings based on the sales channels (added in pretix 4.7). Defaults to ``false``.
|
||||
Used to be ``send_mail`` before pretix 3.14.
|
||||
* ``use_gift_cards`` (optional) The provided gift cards will be used to pay for this order. They will be debited and
|
||||
all the necessary payment records for these transactions will be created. The gift cards will be used in sequence to
|
||||
pay for the order. Processing of the gift cards stops as soon as the order is payed for. All gift card transactions
|
||||
are listed under ``payments`` in the response.
|
||||
This option can only be used with orders that are in the pending state.
|
||||
The ``use_gift_cards`` attribute can not be combined with ``payment_info`` and ``payment_provider`` fields. If the
|
||||
order isn't completely paid after its creation with ``use_gift_cards``, then a subsequent request to the payment
|
||||
endpoint is needed.
|
||||
|
||||
If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually
|
||||
to incrementing integers starting with ``1``. Then, you can reference one of these
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+72
-15
@@ -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,
|
||||
...
|
||||
}
|
||||
|
||||
@@ -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'):
|
||||
...
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
""""""""""""
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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 <user-teams>`_
|
||||
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')
|
||||
<QuerySet: …>
|
||||
|
||||
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)
|
||||
<QuerySet: …>
|
||||
|
||||
>>> 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 ``<group_name>:<action>``. 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/
|
||||
@@ -0,0 +1,108 @@
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import globals from 'globals'
|
||||
import js from '@eslint/js'
|
||||
import ts from 'typescript-eslint'
|
||||
import stylistic from '@stylistic/eslint-plugin'
|
||||
import vue from 'eslint-plugin-vue'
|
||||
import vuePug from 'eslint-plugin-vue-pug'
|
||||
|
||||
const ignores = globalIgnores([
|
||||
'**/node_modules',
|
||||
'**/dist'
|
||||
])
|
||||
|
||||
export default defineConfig([
|
||||
ignores,
|
||||
...ts.config(
|
||||
js.configs.recommended,
|
||||
ts.configs.recommended
|
||||
),
|
||||
stylistic.configs.customize({
|
||||
indent: 'tab',
|
||||
braceStyle: '1tbs',
|
||||
quoteProps: 'as-needed'
|
||||
}),
|
||||
...vue.configs['flat/recommended'],
|
||||
...vuePug.configs['flat/recommended'],
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
localStorage: false,
|
||||
$: 'readonly',
|
||||
$$: 'readonly',
|
||||
$ref: 'readonly',
|
||||
$computed: 'readonly',
|
||||
},
|
||||
parserOptions: {
|
||||
parser: '@typescript-eslint/parser'
|
||||
}
|
||||
},
|
||||
|
||||
rules: {
|
||||
'no-debugger': 'off',
|
||||
curly: 0,
|
||||
'no-return-assign': 0,
|
||||
'no-console': 'off',
|
||||
'vue/require-default-prop': 0,
|
||||
'vue/require-v-for-key': 0,
|
||||
'vue/valid-v-for': 'warn',
|
||||
'vue/no-reserved-keys': 0,
|
||||
'vue/no-setup-props-destructure': 0,
|
||||
'vue/multi-word-component-names': 0,
|
||||
'vue/max-attributes-per-line': 0,
|
||||
'vue/attribute-hyphenation': ['warn', 'never'],
|
||||
'vue/v-on-event-hyphenation': ['warn', 'never'],
|
||||
'import/first': 0,
|
||||
'@typescript-eslint/ban-ts-comment': 0,
|
||||
'@typescript-eslint/no-explicit-any': 0,
|
||||
'no-use-before-define': 'off',
|
||||
'no-var': 'error',
|
||||
|
||||
'@typescript-eslint/no-use-before-define': ['error', {
|
||||
typedefs: false,
|
||||
functions: false,
|
||||
}],
|
||||
|
||||
'@typescript-eslint/no-unused-vars': ['error', {
|
||||
args: 'all',
|
||||
argsIgnorePattern: '^_',
|
||||
caughtErrors: 'all',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true
|
||||
}],
|
||||
|
||||
'@stylistic/comma-dangle': 0,
|
||||
'@stylistic/space-before-function-paren': ['error', 'always'],
|
||||
'@stylistic/max-statements-per-line': ['error', { max: 1, ignoredNodes: ['BreakStatement'] }],
|
||||
'@stylistic/member-delimiter-style': 0,
|
||||
'@stylistic/arrow-parens': 0,
|
||||
'@stylistic/generator-star-spacing': 0,
|
||||
'@stylistic/yield-star-spacing': ['error', 'after'],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'src/pretix/static/pretixcontrol/js/ui/checkinrules/**/*.vue',
|
||||
'src/pretix/plugins/webcheckin/**/*.vue',
|
||||
],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
moment: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'src/pretix/static/pretixpresale/widget/**/*.{ts,vue}',
|
||||
],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
LANG: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
Generated
+4784
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "pretix",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"homepage": "https://github.com/pretix/pretix#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/pretix/pretix/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/pretix/pretix.git"
|
||||
},
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"author": "",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"directories": {
|
||||
"doc": "doc"
|
||||
},
|
||||
"scripts": {
|
||||
"dev:control": "vite",
|
||||
"dev:widget": "vite src/pretix/static/pretixpresale/widget",
|
||||
"build": "npm run build:control -s && npm run build:widget -s",
|
||||
"build:control": "vite build",
|
||||
"build:widget": "vite build src/pretix/static/pretixpresale/widget",
|
||||
"lint:eslint": "eslint src/pretix/static/pretixpresale/widget src/pretix/static/pretixcontrol/js/ui/checkinrules src/pretix/plugins/webcheckin",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.30",
|
||||
"vue-slicksort": "^2.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@types/jquery": "^3.5.33",
|
||||
"@types/moment": "^2.11.29",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
"@vue/language-plugin-pug": "^3.2.5",
|
||||
"eslint": "^10.0.3",
|
||||
"eslint-plugin-vue": "^10.8.0",
|
||||
"eslint-plugin-vue-pug": "^1.0.0-alpha.5",
|
||||
"globals": "^17.4.0",
|
||||
"pug": "^3.0.3",
|
||||
"sass-embedded": "^1.98.0",
|
||||
"stylus": "^0.64.0",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -54,7 +54,7 @@ dependencies = [
|
||||
"django-phonenumber-field==8.4.*",
|
||||
"django-redis==6.0.*",
|
||||
"django-scopes==2.0.*",
|
||||
"django-statici18n==2.6.*",
|
||||
"django-statici18n==2.7.*",
|
||||
"djangorestframework==3.16.*",
|
||||
"dnspython==2.8.*",
|
||||
"drf_ujson2==1.7.*",
|
||||
@@ -73,7 +73,7 @@ dependencies = [
|
||||
"packaging",
|
||||
"paypalrestsdk==1.13.*",
|
||||
"paypal-checkout-serversdk==1.0.*",
|
||||
"PyJWT==2.11.*",
|
||||
"PyJWT==2.12.*",
|
||||
"phonenumberslite==9.0.*",
|
||||
"Pillow==12.1.*",
|
||||
"pretix-plugin-build",
|
||||
@@ -123,6 +123,7 @@ dev = [
|
||||
"pytest-mock==3.15.*",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist==3.8.*",
|
||||
"pytest-playwright",
|
||||
"pytest==9.0.*",
|
||||
"responses",
|
||||
]
|
||||
|
||||
@@ -37,4 +37,9 @@ ignore =
|
||||
CONTRIBUTING.md
|
||||
Dockerfile
|
||||
SECURITY.md
|
||||
eslint.config.mjs
|
||||
package-lock.json
|
||||
package.json
|
||||
tsconfig.json
|
||||
vite.config.js
|
||||
|
||||
|
||||
+6
-6
@@ -9,10 +9,10 @@ localegen:
|
||||
./manage.py makemessages --keep-pot --ignore "pretix/static/npm_dir/*" $(LNGS)
|
||||
./manage.py makemessages --keep-pot -d djangojs --ignore "pretix/static/npm_dir/*" --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS)
|
||||
|
||||
staticfiles: jsi18n
|
||||
staticfiles: npminstall npmbuild jsi18n
|
||||
./manage.py collectstatic --noinput
|
||||
|
||||
compress: npminstall
|
||||
compress:
|
||||
./manage.py compress
|
||||
|
||||
jsi18n: localecompile
|
||||
@@ -25,8 +25,8 @@ coverage:
|
||||
coverage run -m py.test
|
||||
|
||||
npminstall:
|
||||
# keep this in sync with pretix/_build.py!
|
||||
mkdir -p pretix/static.dist/node_prefix/
|
||||
cp -r pretix/static/npm_dir/* pretix/static.dist/node_prefix/
|
||||
npm ci --prefix=pretix/static.dist/node_prefix
|
||||
npm ci
|
||||
|
||||
npmbuild:
|
||||
npm run build
|
||||
|
||||
|
||||
@@ -37,9 +37,11 @@ INSTALLED_APPS = [
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.humanize',
|
||||
# pretix needs to go before staticfiles
|
||||
# so we can override the runserver command
|
||||
'pretix.base',
|
||||
'django.contrib.staticfiles',
|
||||
'pretix.control',
|
||||
'pretix.presale',
|
||||
'pretix.multidomain',
|
||||
@@ -243,7 +245,6 @@ STORAGES = {
|
||||
|
||||
COMPRESS_PRECOMPILERS = (
|
||||
('text/x-scss', 'django_libsass.SassCompiler'),
|
||||
('text/vue', 'pretix.helpers.compressor.VueCompiler'),
|
||||
)
|
||||
|
||||
COMPRESS_OFFLINE_CONTEXT = {
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
#
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from setuptools.command.build import build
|
||||
from setuptools.command.build_ext import build_ext
|
||||
|
||||
here = os.path.abspath(os.path.dirname(__file__))
|
||||
project_root = os.path.abspath(os.path.join(here, '..', '..'))
|
||||
npm_installed = False
|
||||
|
||||
|
||||
@@ -35,14 +35,14 @@ def npm_install():
|
||||
global npm_installed
|
||||
|
||||
if not npm_installed:
|
||||
# keep this in sync with Makefile!
|
||||
node_prefix = os.path.join(here, 'static.dist', 'node_prefix')
|
||||
os.makedirs(node_prefix, exist_ok=True)
|
||||
shutil.copytree(os.path.join(here, 'static', 'npm_dir'), node_prefix, dirs_exist_ok=True)
|
||||
subprocess.check_call('npm ci', shell=True, cwd=node_prefix)
|
||||
subprocess.check_call('npm ci', shell=True, cwd=project_root)
|
||||
npm_installed = True
|
||||
|
||||
|
||||
def npm_build():
|
||||
subprocess.check_call('npm run build', shell=True, cwd=project_root)
|
||||
|
||||
|
||||
class CustomBuild(build):
|
||||
def run(self):
|
||||
if "src" not in os.listdir(".") or "pretix" not in os.listdir("src"):
|
||||
@@ -62,6 +62,7 @@ class CustomBuild(build):
|
||||
settings.COMPRESS_OFFLINE = True
|
||||
|
||||
npm_install()
|
||||
npm_build()
|
||||
management.call_command('compilemessages', verbosity=1)
|
||||
management.call_command('compilejsi18n', verbosity=1)
|
||||
management.call_command('collectstatic', verbosity=1, interactive=False)
|
||||
|
||||
@@ -47,3 +47,5 @@ HAS_MEMCACHED = False
|
||||
HAS_CELERY = False
|
||||
HAS_GEOIP = False
|
||||
SENTRY_ENABLED = False
|
||||
VITE_DEV_MODE = False
|
||||
VITE_IGNORE = False
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,29 @@ 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 for e in self.ex.events.filter(pk__in=instance["events"])]
|
||||
instance = super().to_representation(instance)
|
||||
return instance
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if isinstance(data, QueryDict):
|
||||
data = data.copy()
|
||||
@@ -95,6 +106,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 +150,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:
|
||||
|
||||
@@ -65,8 +65,9 @@ def form_field_to_serializer_field(field):
|
||||
if isinstance(field, m_from):
|
||||
return m_to(
|
||||
required=field.required,
|
||||
allow_null=not field.required,
|
||||
allow_null=not field.required and not isinstance(field, forms.BooleanField),
|
||||
validators=field.validators,
|
||||
initial=field.initial,
|
||||
**{kwarg: getattr(field, kwarg, None) for kwarg in m_kwargs}
|
||||
)
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ from pretix.base.models import (
|
||||
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
|
||||
SalesChannel,
|
||||
)
|
||||
from pretix.base.models.items import Questionnaire, QuestionnaireChild
|
||||
|
||||
|
||||
class InlineItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
@@ -540,6 +541,7 @@ class LegacyDependencyValueField(serializers.CharField):
|
||||
class QuestionSerializer(I18nAwareModelSerializer):
|
||||
options = InlineQuestionOptionSerializer(many=True, required=False)
|
||||
identifier = serializers.CharField(allow_null=True)
|
||||
internal_name = serializers.CharField(allow_null=True, source='question', read_only=True)
|
||||
dependency_value = LegacyDependencyValueField(source='dependency_values', required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
@@ -548,7 +550,7 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
'ask_during_checkin', 'show_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
|
||||
'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min',
|
||||
'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max',
|
||||
'valid_string_length_max', 'valid_file_portrait')
|
||||
'valid_string_length_max', 'valid_file_portrait', 'internal_name',)
|
||||
|
||||
def validate_identifier(self, value):
|
||||
Question._clean_identifier(self.context['event'], value, self.instance)
|
||||
@@ -624,6 +626,160 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
return question
|
||||
|
||||
|
||||
class QuestionRefField(serializers.PrimaryKeyRelatedField):
|
||||
def to_representation(self, qc):
|
||||
if not qc:
|
||||
return None
|
||||
elif qc.system_question:
|
||||
return qc.system_question
|
||||
elif qc.user_question_id:
|
||||
return qc.user_question_id
|
||||
else:
|
||||
return None
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if type(data) == int:
|
||||
return {'user_question': super().to_internal_value(data), 'system_question': None}
|
||||
elif type(data) == str or data is None:
|
||||
return {'user_question': None, 'system_question': data}
|
||||
else:
|
||||
self.fail('incorrect_type', data_type=type(data).__name__)
|
||||
|
||||
def use_pk_only_optimization(self):
|
||||
return self.source == '*'
|
||||
|
||||
|
||||
class InlineQuestionnaireChildSerializer(I18nAwareModelSerializer):
|
||||
question = QuestionRefField(source='*', queryset=Question.objects.none())
|
||||
dependency_question = QuestionRefField(allow_null=True, required=False, queryset=Question.objects.none())
|
||||
|
||||
class Meta:
|
||||
model = QuestionnaireChild
|
||||
fields = ('question', 'required', 'label', 'help_text', 'dependency_question', 'dependency_values')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["question"].queryset = self.context["event"].questions.all()
|
||||
self.fields["dependency_question"].queryset = self.context["event"].questions.all()
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
event = self.context['event']
|
||||
|
||||
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
full_data.update(data)
|
||||
|
||||
if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
|
||||
raise ValidationError('Dependencies are not supported during check-in.')
|
||||
|
||||
dep = full_data.get('dependency_question')
|
||||
if dep:
|
||||
if dep.ask_during_checkin:
|
||||
raise ValidationError(_('Question cannot depend on a question asked during check-in.'))
|
||||
|
||||
seen_ids = {self.instance.pk} if self.instance else set()
|
||||
while dep:
|
||||
if dep.pk in seen_ids:
|
||||
raise ValidationError(_('Circular dependency between questions detected.'))
|
||||
seen_ids.add(dep.pk)
|
||||
dep = dep.dependency_question
|
||||
|
||||
return data
|
||||
|
||||
def validate_dependency_question(self, value):
|
||||
if value:
|
||||
if value.type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE):
|
||||
raise ValidationError('Question dependencies can only be set to boolean or choice questions.')
|
||||
if value == self.instance:
|
||||
raise ValidationError('A question cannot depend on itself.')
|
||||
return value
|
||||
|
||||
|
||||
class QuestionnaireSerializer(I18nAwareModelSerializer):
|
||||
limit_sales_channels = serializers.SlugRelatedField(
|
||||
slug_field="identifier",
|
||||
queryset=SalesChannel.objects.none(),
|
||||
required=False,
|
||||
allow_empty=True,
|
||||
many=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Questionnaire
|
||||
fields = ('id', 'type', 'internal_name', 'items', 'position', 'all_sales_channels', 'limit_sales_channels', 'children')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.fields['children'] = InlineQuestionnaireChildSerializer(many=True, required=True, context=kwargs['context'], partial=False)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
event = self.context['event']
|
||||
|
||||
#full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
#full_data.update(data)
|
||||
|
||||
#if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
|
||||
# raise ValidationError('Dependencies are not supported during check-in.')
|
||||
|
||||
#if full_data.get('ask_during_checkin') and full_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
|
||||
# raise ValidationError(_('This type of question cannot be asked during check-in.'))
|
||||
|
||||
#if full_data.get('show_during_checkin') and full_data.get('type') in Question.SHOW_DURING_CHECKIN_UNSUPPORTED:
|
||||
# raise ValidationError(_('This type of question cannot be shown during check-in.'))
|
||||
|
||||
#Question.clean_items(event, full_data.get('items') or [])
|
||||
return data
|
||||
|
||||
def validate_children(self, value):
|
||||
prev_questions = {}
|
||||
for child in value:
|
||||
if child.get('dependency_question'):
|
||||
if (child['dependency_question']['user_question'] or child['dependency_question']['system_question']) not in prev_questions:
|
||||
raise ValidationError('A question can only depend on a previous question from the same questionnaire.')
|
||||
|
||||
if child['user_question']:
|
||||
prev_questions[child['user_question']] = child
|
||||
if child['system_question']:
|
||||
prev_questions[child['system_question']] = child
|
||||
return value
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
children_data = validated_data.pop('children') if 'children' in validated_data else []
|
||||
questionnaire = super().create(validated_data)
|
||||
self.set_children(questionnaire, children_data)
|
||||
return questionnaire
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
children_data = validated_data.pop('children', None)
|
||||
questionnaire = super().update(instance, validated_data)
|
||||
if children_data is not None:
|
||||
self.set_children(questionnaire, children_data)
|
||||
return questionnaire
|
||||
|
||||
def set_children(self, questionnaire, new_data):
|
||||
result = []
|
||||
child_serializer = self.fields['children'].child
|
||||
existing = questionnaire.children.all()
|
||||
for i, d in enumerate(new_data):
|
||||
d['questionnaire'] = questionnaire
|
||||
d['position'] = i + 1
|
||||
d.setdefault('required', False)
|
||||
d.setdefault('help_text', None)
|
||||
d.setdefault('dependency_question', None)
|
||||
d.setdefault('dependency_values', None)
|
||||
updatable = min(len(existing), len(new_data))
|
||||
for i in range(0, updatable):
|
||||
result.append(child_serializer.update(existing[i], new_data[i]))
|
||||
for i in range(updatable, len(new_data)):
|
||||
result.append(child_serializer.create(new_data[i]))
|
||||
for i in range(updatable, len(existing)):
|
||||
existing[i].delete()
|
||||
return result
|
||||
|
||||
|
||||
class QuotaSerializer(I18nAwareModelSerializer):
|
||||
available = serializers.BooleanField(read_only=True)
|
||||
available_number = serializers.IntegerField(read_only=True)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -53,7 +53,7 @@ from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.invoicing.transmission import get_transmission_types
|
||||
from pretix.base.models import (
|
||||
CachedFile, Checkin, Customer, Device, Invoice, InvoiceAddress,
|
||||
CachedFile, Checkin, Customer, Device, GiftCard, Invoice, InvoiceAddress,
|
||||
InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
|
||||
QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule,
|
||||
Voucher,
|
||||
@@ -62,6 +62,7 @@ from pretix.base.models.orders import (
|
||||
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
|
||||
PrintLog, RevokedTicketSecret, Transaction,
|
||||
)
|
||||
from pretix.base.payment import GiftCardPayment, PaymentException
|
||||
from pretix.base.pdf import get_images, get_variables
|
||||
from pretix.base.services.cart import error_messages
|
||||
from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects
|
||||
@@ -614,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)
|
||||
@@ -1200,6 +1201,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
tax_rounding_mode = serializers.ChoiceField(choices=ROUNDING_MODES, allow_null=True, required=False,)
|
||||
locale = serializers.ChoiceField(choices=[], required=False, allow_null=True)
|
||||
use_gift_cards = serializers.ListField(child=serializers.CharField(required=False), required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -1215,7 +1217,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
||||
'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date',
|
||||
'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at',
|
||||
'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode')
|
||||
'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode', 'use_gift_cards')
|
||||
|
||||
def validate_payment_provider(self, pp):
|
||||
if pp is None:
|
||||
@@ -1310,6 +1312,14 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
payment_date = validated_data.pop('payment_date', now())
|
||||
force = validated_data.pop('force', False)
|
||||
simulate = validated_data.pop('simulate', False)
|
||||
gift_card_secrets = validated_data.pop('use_gift_cards') if 'use_gift_cards' in validated_data else []
|
||||
|
||||
if (payment_provider is not None or payment_info != '{}') and len(gift_card_secrets) > 0:
|
||||
raise ValidationError({"use_gift_cards": ['The attribute use_gift_cards is not compatible with payment_provider or payment_info']})
|
||||
if validated_data.get('status') != Order.STATUS_PENDING and len(gift_card_secrets) > 0:
|
||||
raise ValidationError({"use_gift_cards": ['The attribute use_gift_cards is only supported for orders that are created as pending']})
|
||||
if len(set(gift_card_secrets)) != len(gift_card_secrets):
|
||||
raise ValidationError({"use_gift_cards": ['Multiple copies of the same gift card secret are not allowed']})
|
||||
|
||||
if not validated_data.get("sales_channel"):
|
||||
validated_data["sales_channel"] = self.context['event'].organizer.sales_channels.get(identifier="web")
|
||||
@@ -1794,6 +1804,45 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
if order.total != Decimal('0.00') and order.event.currency == "XXX":
|
||||
raise ValidationError('Paid products not supported without a valid currency.')
|
||||
|
||||
for gift_card_secret in gift_card_secrets:
|
||||
try:
|
||||
if order.status != Order.STATUS_PAID:
|
||||
gift_card_payment_provider = GiftCardPayment(event=order.event)
|
||||
|
||||
gc = order.event.organizer.accepted_gift_cards.get(
|
||||
secret=gift_card_secret
|
||||
)
|
||||
|
||||
payment = order.payments.create(
|
||||
amount=min(order.pending_sum, gc.value),
|
||||
provider=gift_card_payment_provider.identifier,
|
||||
info_data={
|
||||
'gift_card': gc.pk,
|
||||
'gift_card_secret': gc.secret,
|
||||
'retry': True
|
||||
},
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED
|
||||
)
|
||||
gift_card_payment_provider.execute_payment(request=None, payment=payment, is_early_special_case=True)
|
||||
|
||||
if order.pending_sum <= Decimal('0.00'):
|
||||
order.status = Order.STATUS_PAID
|
||||
|
||||
except PaymentException:
|
||||
pass
|
||||
|
||||
except GiftCard.DoesNotExist as e:
|
||||
payment = order.payments.create(
|
||||
amount=order.pending_sum,
|
||||
provider=GiftCardPayment.identifier,
|
||||
info_data={
|
||||
'gift_card_secret': gift_card_secret,
|
||||
},
|
||||
state=OrderPayment.PAYMENT_STATE_CREATED
|
||||
)
|
||||
payment.fail(info={**payment.info_data, 'error': str(e)},
|
||||
send_mail=False)
|
||||
|
||||
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID and not validated_data.get('require_approval'):
|
||||
order.status = Order.STATUS_PAID
|
||||
order.save()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -79,7 +79,8 @@ event_router.register(r'subevents', event.SubEventViewSet)
|
||||
event_router.register(r'clone', event.CloneEventViewSet)
|
||||
event_router.register(r'items', item.ItemViewSet)
|
||||
event_router.register(r'categories', item.ItemCategoryViewSet)
|
||||
event_router.register(r'questions', item.QuestionViewSet)
|
||||
event_router.register(r'datafields', item.QuestionViewSet)
|
||||
event_router.register(r'questionnaires', item.QuestionnaireViewSet)
|
||||
event_router.register(r'discounts', discount.DiscountViewSet)
|
||||
event_router.register(r'quotas', item.QuotaViewSet)
|
||||
event_router.register(r'vouchers', voucher.VoucherViewSet)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,13 +47,14 @@ from pretix.api.pagination import TotalOrderingFilter
|
||||
from pretix.api.serializers.item import (
|
||||
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
|
||||
ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer,
|
||||
QuestionOptionSerializer, QuestionSerializer, QuotaSerializer,
|
||||
QuestionOptionSerializer, QuestionSerializer, QuestionnaireSerializer, QuotaSerializer,
|
||||
)
|
||||
from pretix.api.views import ConditionalListView
|
||||
from pretix.base.models import (
|
||||
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemProgramTime,
|
||||
ItemVariation, Question, QuestionOption, Quota,
|
||||
)
|
||||
from pretix.base.models.items import Questionnaire
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
from pretix.helpers.i18n import i18ncomp
|
||||
@@ -99,7 +100,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 +164,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 +235,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 +287,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 +340,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 +399,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 +454,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 +498,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)
|
||||
@@ -538,6 +539,51 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
class QuestionnaireViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = QuestionnaireSerializer
|
||||
queryset = Questionnaire.objects.none()
|
||||
#filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
|
||||
#filterset_class = QuestionFilter
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('position', 'id')
|
||||
permission = None
|
||||
write_permission = 'event.items:write'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.questionnaires.prefetch_related('children').all()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.questionnaire.added',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
return ctx
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.log_action(
|
||||
'pretix.event.questionnaire.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
instance.log_action(
|
||||
'pretix.event.questionnaire.deleted',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
|
||||
pass
|
||||
|
||||
@@ -564,7 +610,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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -315,8 +315,9 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
for id, vn in payment_methods:
|
||||
headers.append(_('Paid by {method}').format(method=vn))
|
||||
|
||||
# get meta_data labels from first cached event
|
||||
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
|
||||
if self.event_object_cache:
|
||||
# get meta_data labels from first cached event if any
|
||||
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
|
||||
yield headers
|
||||
|
||||
full_fee_sum_cache = {
|
||||
@@ -457,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'):
|
||||
@@ -503,8 +504,9 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
headers.append(_('External customer ID'))
|
||||
headers.append(_('Payment providers'))
|
||||
|
||||
# get meta_data labels from first cached event
|
||||
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
|
||||
if self.event_object_cache:
|
||||
# get meta_data labels from first cached event if any
|
||||
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
|
||||
yield headers
|
||||
|
||||
yield self.ProgressSetTotal(total=qs.count())
|
||||
@@ -560,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'):
|
||||
@@ -707,9 +709,9 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
_('Position order link')
|
||||
]
|
||||
|
||||
# get meta_data labels from first cached event
|
||||
meta_data_labels = next(iter(self.event_object_cache.values())).meta_data.keys()
|
||||
if has_subevents:
|
||||
# get meta_data labels from first cached event
|
||||
meta_data_labels = next(iter(self.event_object_cache.values())).meta_data.keys()
|
||||
headers += meta_data_labels
|
||||
yield headers
|
||||
|
||||
@@ -1237,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 = [
|
||||
@@ -1344,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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1415,6 +1415,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
if not data.get(r):
|
||||
raise ValidationError({r: _("This field is required for the selected type of invoice transmission.")})
|
||||
|
||||
transmission_type.validate_invoice_address_data(data)
|
||||
self.instance.transmission_type = transmission_type.identifier
|
||||
self.instance.transmission_info = transmission_type.form_data_to_transmission_info(data)
|
||||
elif transmission_type.is_exclusive(self.event, data.get("country"), data.get("is_business")):
|
||||
|
||||
@@ -33,8 +33,7 @@ from pretix.base.invoicing.transmission import (
|
||||
transmission_types,
|
||||
)
|
||||
from pretix.base.models import Invoice, InvoiceAddress
|
||||
from pretix.base.services.mail import mail, render_mail
|
||||
from pretix.helpers.format import format_map
|
||||
from pretix.base.services.mail import mail
|
||||
|
||||
|
||||
@transmission_types.new()
|
||||
@@ -134,9 +133,7 @@ class EmailTransmissionProvider(TransmissionProvider):
|
||||
subject = invoice.order.event.settings.get('mail_subject_order_invoice', as_type=LazyI18nString)
|
||||
|
||||
# Do not set to completed because that is done by the email sending task
|
||||
subject = format_map(subject, context)
|
||||
email_content = render_mail(template, context)
|
||||
mail(
|
||||
outgoing_mail = mail(
|
||||
[recipient],
|
||||
subject,
|
||||
template,
|
||||
@@ -151,19 +148,10 @@ class EmailTransmissionProvider(TransmissionProvider):
|
||||
plain_text_only=True,
|
||||
no_order_links=True,
|
||||
)
|
||||
invoice.order.log_action(
|
||||
'pretix.event.order.email.invoice',
|
||||
user=None,
|
||||
auth=None,
|
||||
data={
|
||||
'subject': subject,
|
||||
'message': email_content,
|
||||
'position': None,
|
||||
'recipient': recipient,
|
||||
'invoices': [invoice.pk],
|
||||
'attach_tickets': False,
|
||||
'attach_ical': False,
|
||||
'attach_other_files': [],
|
||||
'attach_cached_files': [],
|
||||
}
|
||||
)
|
||||
if outgoing_mail:
|
||||
invoice.order.log_action(
|
||||
'pretix.event.order.email.invoice',
|
||||
user=None,
|
||||
auth=None,
|
||||
data=outgoing_mail.log_data()
|
||||
)
|
||||
|
||||
@@ -204,6 +204,12 @@ class PeppolTransmissionType(TransmissionType):
|
||||
}
|
||||
return base | {"transmission_peppol_participant_id"}
|
||||
|
||||
def validate_invoice_address_data(self, address_data: dict):
|
||||
# Special case Belgium: If a Belgian business ID is used as Peppol ID, it should match the VAT ID
|
||||
if address_data.get("transmission_peppol_participant_id").startswith("0208:") and address_data.get("vat_id"):
|
||||
if address_data["vat_id"].removeprefix("BE") != address_data["transmission_peppol_participant_id"].removeprefix("0208:"):
|
||||
raise ValidationError({"transmission_peppol_participant_id": _("The Peppol participant ID does not match your VAT ID.")})
|
||||
|
||||
def pdf_watermark(self) -> str:
|
||||
return pgettext("peppol_invoice", "Visual copy")
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from typing import Optional
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_countries.fields import Country
|
||||
|
||||
from pretix.base.models import Invoice, InvoiceAddress
|
||||
from pretix.base.models import Invoice
|
||||
from pretix.base.signals import EventPluginRegistry, Registry
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ class TransmissionType:
|
||||
def invoice_address_form_fields_visible(self, country: Country, is_business: bool) -> set:
|
||||
return set(self.invoice_address_form_fields.keys())
|
||||
|
||||
def validate_address(self, ia: InvoiceAddress):
|
||||
def validate_invoice_address_data(self, address_data: dict):
|
||||
pass
|
||||
|
||||
@property
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
|
||||
"""This command supersedes the Django-inbuilt runserver command.
|
||||
|
||||
It runs the local frontend server, if node is installed and the setting
|
||||
is set.
|
||||
"""
|
||||
import atexit
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles.management.commands.runserver import (
|
||||
Command as Parent,
|
||||
)
|
||||
from django.utils.autoreload import DJANGO_AUTORELOAD_ENV
|
||||
|
||||
|
||||
class Command(Parent):
|
||||
def handle(self, *args, **options):
|
||||
# Only start Vite in the non-main process of the autoreloader
|
||||
if settings.VITE_DEV_MODE and os.environ.get(DJANGO_AUTORELOAD_ENV) != "true":
|
||||
# Start the vite server in the background
|
||||
vite_server = subprocess.Popen(
|
||||
["npm", "run", "dev:control"],
|
||||
cwd=Path(__file__).parent.parent.parent.parent.parent
|
||||
)
|
||||
|
||||
def cleanup():
|
||||
vite_server.terminate()
|
||||
try:
|
||||
vite_server.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
vite_server.kill()
|
||||
|
||||
atexit.register(cleanup)
|
||||
|
||||
super().handle(*args, **options)
|
||||
@@ -280,11 +280,11 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
|
||||
h = {
|
||||
'default-src': ["{static}"],
|
||||
'script-src': ['{static}'],
|
||||
'script-src': ["{static}"] + (["http://localhost:5173", "ws://localhost:5173"] if settings.VITE_DEV_MODE else []),
|
||||
'object-src': ["'none'"],
|
||||
'frame-src': ['{static}'],
|
||||
'style-src': ["{static}", "{media}"],
|
||||
'connect-src': ["{dynamic}", "{media}"],
|
||||
'style-src': ["{static}", "{media}"] + (["'unsafe-inline'"] if settings.VITE_DEV_MODE else []),
|
||||
'connect-src': ["{dynamic}", "{media}"] + (["http://localhost:5173", "ws://localhost:5173"] if settings.VITE_DEV_MODE else []),
|
||||
'img-src': ["{static}", "{media}", "data:"] + img_src,
|
||||
'font-src': ["{static}"] + list(font_src),
|
||||
'media-src': ["{static}", "data:"],
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,162 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-19 14:24
|
||||
import json
|
||||
from collections import namedtuple
|
||||
from itertools import chain, groupby
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import i18nfield.fields
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
import pretix.base.models.base
|
||||
import pretix.base.models.fields
|
||||
|
||||
|
||||
FakeQuestion = namedtuple(
|
||||
'FakeQuestion', 'id question position required'
|
||||
)
|
||||
|
||||
|
||||
def get_fake_questions(settings):
|
||||
def b(s):
|
||||
return s == 'True'
|
||||
fq = []
|
||||
sqo = json.loads(settings.get('system_question_order', '{}'))
|
||||
_ = LazyI18nString.from_gettext
|
||||
|
||||
if b(settings.get('attendee_names_asked', 'True')):
|
||||
fq.append(FakeQuestion('attendee_name_parts', _('Attendee name'), sqo.get('attendee_name_parts', 0), b(settings.get('attendee_names_required'))))
|
||||
|
||||
if b(settings.get('attendee_emails_asked')):
|
||||
fq.append(FakeQuestion('attendee_email', _('Attendee email'), sqo.get('attendee_email', 0), b(settings.get('attendee_emails_required'))))
|
||||
|
||||
if b(settings.get('attendee_company_asked')):
|
||||
fq.append(FakeQuestion('company', _('Company'), sqo.get('company', 0), b(settings.get('attendee_company_required'))))
|
||||
|
||||
if b(settings.get('attendee_addresses_asked')):
|
||||
fq.append(FakeQuestion('street', _('Street'), sqo.get('street', 0), b(settings.get('attendee_addresses_required'))))
|
||||
fq.append(FakeQuestion('zipcode', _('ZIP code'), sqo.get('zipcode', 0), b(settings.get('attendee_addresses_required'))))
|
||||
fq.append(FakeQuestion('city', _('City'), sqo.get('city', 0), b(settings.get('attendee_addresses_required'))))
|
||||
fq.append(FakeQuestion('country', _('Country'), sqo.get('country', 0), b(settings.get('attendee_addresses_required'))))
|
||||
return fq
|
||||
|
||||
|
||||
def migrate_questions_forward(apps, schema_editor):
|
||||
Event = apps.get_model("pretixbase", "Event")
|
||||
Item = apps.get_model("pretixbase", "Item")
|
||||
Question = apps.get_model("pretixbase", "Question")
|
||||
Questionnaire = apps.get_model("pretixbase", "Questionnaire")
|
||||
QuestionnaireChild = apps.get_model("pretixbase", "QuestionnaireChild")
|
||||
EventSettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
|
||||
|
||||
for event in Event.objects.iterator():
|
||||
# get relevant settings
|
||||
settings = {
|
||||
setting.key: setting.value for setting in EventSettingsStore.objects.filter(object_id=event.id, key__in=(
|
||||
'system_question_order', 'attendee_names_asked', 'attendee_names_required', 'attendee_emails_asked', 'attendee_emails_required',
|
||||
'attendee_company_asked', 'attendee_company_required', 'attendee_addresses_asked', 'attendee_addresses_required',
|
||||
))
|
||||
}
|
||||
|
||||
# get all questions (user-defined and system provided), along with the products for which they're asked
|
||||
questions = event.questions.all()
|
||||
children = sorted(chain((
|
||||
(item, q.position, q.id, q)
|
||||
for q in questions
|
||||
for item in q.items.values_list('id', 'internal_name', 'name')
|
||||
), (
|
||||
(item, q.position, None, q)
|
||||
for q in get_fake_questions(settings)
|
||||
for item in event.items.filter(personalized=True).values_list('id', 'internal_name', 'name')
|
||||
)), key=lambda t: (t[0], t[1], t[2]))
|
||||
|
||||
# group by item, creating a unique questionnaire per item
|
||||
item_questionnaires = (([t[3] for t in children], item_id) for item_id, children in groupby(children, key=lambda t: t[0]))
|
||||
|
||||
# group again, merging all questionnaires with identical children
|
||||
merged_questionnaires = groupby(sorted(item_questionnaires, key=lambda t: [q.id for q in t[0]]), key=lambda t: t[0])
|
||||
for children, iterator in merged_questionnaires:
|
||||
items = [item for _c, item in iterator]
|
||||
|
||||
# create questionnaires and children
|
||||
questionnaire = Questionnaire.objects.create(
|
||||
event=event, type='PS', position=0, all_sales_channels=True,
|
||||
internal_name=', '.join(str(iname or name) for (id, iname, name) in items)
|
||||
)
|
||||
questionnaire.items.set([id for (id, iname, name) in items])
|
||||
deps = {}
|
||||
for position, child in enumerate(children):
|
||||
if isinstance(child, FakeQuestion):
|
||||
QuestionnaireChild.objects.create(
|
||||
questionnaire=questionnaire,
|
||||
position=position + 1,
|
||||
system_question=child.id,
|
||||
required=child.required,
|
||||
label=child.question,
|
||||
)
|
||||
else:
|
||||
deps[child.id] = QuestionnaireChild.objects.create(
|
||||
questionnaire=questionnaire,
|
||||
position=position + 1,
|
||||
user_question=child,
|
||||
required=child.required,
|
||||
label=child.question,
|
||||
help_text=child.help_text,
|
||||
dependency_question=deps[child.dependency_question.id] if child.dependency_question else None,
|
||||
dependency_values=child.dependency_values,
|
||||
)
|
||||
|
||||
|
||||
def migrate_questions_backward(apps, schema_editor):
|
||||
pass # as long as we don't delete the old columns, this is a no op. after that, it gets complicated...
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0298_pluggable_permissions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Questionnaire',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('internal_name', models.CharField(max_length=255)),
|
||||
('type', models.CharField(max_length=5)),
|
||||
('position', models.PositiveIntegerField(default=0)),
|
||||
('all_sales_channels', models.BooleanField(default=True)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questionnaires', to='pretixbase.event')),
|
||||
('items', models.ManyToManyField(related_name='questionnaires', to='pretixbase.item')),
|
||||
('limit_sales_channels', models.ManyToManyField(to='pretixbase.saleschannel')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='QuestionnaireChild',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('position', models.PositiveIntegerField(default=0)),
|
||||
('system_question', models.CharField(max_length=25, null=True)),
|
||||
('required', models.BooleanField(default=False)),
|
||||
('label', i18nfield.fields.I18nTextField()),
|
||||
('help_text', i18nfield.fields.I18nTextField(null=True)),
|
||||
('dependency_values', pretix.base.models.fields.MultiStringField(default=[])),
|
||||
('dependency_question', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dependent_questions', to='pretixbase.questionnairechild')),
|
||||
('questionnaire', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='children', to='pretixbase.questionnaire')),
|
||||
('user_question', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='references', to='pretixbase.question')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.RunPython(
|
||||
migrate_questions_forward,
|
||||
migrate_questions_backward,
|
||||
),
|
||||
# TODO remove old columns from Question model
|
||||
]
|
||||
@@ -49,6 +49,7 @@ from django.core.exceptions import BadRequest, PermissionDenied
|
||||
from django.db import IntegrityError, models, transaction
|
||||
from django.db.models import Q
|
||||
from django.utils.crypto import get_random_string, salted_hmac
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_otp.models import Device
|
||||
@@ -212,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.
|
||||
@@ -472,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:
|
||||
@@ -486,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:
|
||||
@@ -501,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
|
||||
@@ -513,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
|
||||
|
||||
@@ -524,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
|
||||
"""
|
||||
@@ -533,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
|
||||
|
||||
@@ -565,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))
|
||||
@@ -605,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):
|
||||
@@ -667,6 +690,11 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
self.session_token = generate_session_token()
|
||||
self.save(update_fields=['session_token'])
|
||||
|
||||
@cached_property
|
||||
@scopes_disabled()
|
||||
def is_in_any_teams(self):
|
||||
return self.teams.exists()
|
||||
|
||||
|
||||
class UserKnownLoginSource(models.Model):
|
||||
user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
+115
-16
@@ -1595,10 +1595,12 @@ class ItemBundle(models.Model):
|
||||
|
||||
class Question(LoggedModel):
|
||||
"""
|
||||
A question is an input field that can be used to extend a ticket by custom information,
|
||||
e.g. "Attendee age". The answers are found next to the position. The answers may be found
|
||||
in QuestionAnswers, attached to OrderPositions/CartPositions. A question can allow one of
|
||||
several input types, currently:
|
||||
A question is a data field that can be used to extend an order or a ticket by custom
|
||||
information, e.g. "Attendee age". To be actually useful, questions need to be added to
|
||||
one or multiple Questionnaires. The answers may be found in QuestionAnswers, attached
|
||||
to Orders, OrderPositions or CartPositions.
|
||||
|
||||
A question can allow one of several input types, currently:
|
||||
|
||||
* a number (``TYPE_NUMBER``)
|
||||
* a one-line string (``TYPE_STRING``)
|
||||
@@ -1667,7 +1669,7 @@ class Question(LoggedModel):
|
||||
related_name="questions",
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
question = I18nTextField(
|
||||
question = I18nTextField( # to be renamed to 'internal_name'
|
||||
verbose_name=_("Question")
|
||||
)
|
||||
identifier = models.CharField(
|
||||
@@ -1682,7 +1684,7 @@ class Question(LoggedModel):
|
||||
),
|
||||
],
|
||||
)
|
||||
help_text = I18nTextField(
|
||||
help_text = I18nTextField( # to be removed
|
||||
verbose_name=_("Help text"),
|
||||
help_text=_("If the question needs to be explained or clarified, do it here!"),
|
||||
null=True, blank=True,
|
||||
@@ -1692,22 +1694,22 @@ class Question(LoggedModel):
|
||||
choices=TYPE_CHOICES,
|
||||
verbose_name=_("Question type")
|
||||
)
|
||||
required = models.BooleanField(
|
||||
required = models.BooleanField( # to be removed, -> QuestionnaireChild
|
||||
default=False,
|
||||
verbose_name=_("Required question")
|
||||
)
|
||||
items = models.ManyToManyField(
|
||||
items = models.ManyToManyField( # to be removed, -> Questionnaire
|
||||
Item,
|
||||
related_name='questions',
|
||||
verbose_name=_("Products"),
|
||||
blank=True,
|
||||
help_text=_('This question will be asked to buyers of the selected products')
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
position = models.PositiveIntegerField( # to be removed, -> Questionnaire + QuestionnaireChild
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
)
|
||||
ask_during_checkin = models.BooleanField(
|
||||
ask_during_checkin = models.BooleanField( # to be removed
|
||||
verbose_name=_('Ask during check-in instead of in the ticket buying process'),
|
||||
help_text=_('Not supported by all check-in apps for all question types.'),
|
||||
default=False
|
||||
@@ -1717,7 +1719,7 @@ class Question(LoggedModel):
|
||||
help_text=_('Not supported by all check-in apps for all question types.'),
|
||||
default=False
|
||||
)
|
||||
hidden = models.BooleanField(
|
||||
hidden = models.BooleanField( # to be removed
|
||||
verbose_name=_('Hidden question'),
|
||||
help_text=_('This question will only show up in the backend.'),
|
||||
default=False
|
||||
@@ -1726,10 +1728,10 @@ class Question(LoggedModel):
|
||||
verbose_name=_('Print answer on invoices'),
|
||||
default=False
|
||||
)
|
||||
dependency_question = models.ForeignKey(
|
||||
dependency_question = models.ForeignKey( # to be removed, -> QuestionnaireChild
|
||||
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
|
||||
)
|
||||
dependency_values = MultiStringField(default=[])
|
||||
dependency_values = MultiStringField(default=[]) # to be removed, -> QuestionnaireChild
|
||||
valid_number_min = models.DecimalField(decimal_places=6, max_digits=30, null=True, blank=True,
|
||||
verbose_name=_('Minimum value'),
|
||||
help_text=_('Currently not supported in our apps and during check-in'))
|
||||
@@ -1763,9 +1765,9 @@ class Question(LoggedModel):
|
||||
objects = ScopedManager(organizer='event__organizer')
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Question")
|
||||
verbose_name_plural = _("Questions")
|
||||
ordering = ('position', 'id')
|
||||
verbose_name = _("Data field")
|
||||
verbose_name_plural = _("Data fields")
|
||||
ordering = ('question', 'id')
|
||||
unique_together = (('event', 'identifier'),)
|
||||
|
||||
def __str__(self):
|
||||
@@ -1990,6 +1992,103 @@ class QuestionOption(models.Model):
|
||||
ordering = ('position', 'id')
|
||||
|
||||
|
||||
class Questionnaire(LoggedModel):
|
||||
TYPE_ORDER_SALE = "OS"
|
||||
TYPE_ORDER_POSITION_SALE = "PS"
|
||||
TYPE_ORDER_POSITION_ATTENDEE_ONLY = "PA"
|
||||
TYPE_ORDER_POSITION_CHECKIN = "PC"
|
||||
TYPE_CHOICES = (
|
||||
(TYPE_ORDER_SALE, _("Order-wide, before purchase")),
|
||||
(TYPE_ORDER_POSITION_SALE, _("Per product, before purchase")),
|
||||
(TYPE_ORDER_POSITION_ATTENDEE_ONLY, _("Per product, via attendee link")),
|
||||
(TYPE_ORDER_POSITION_CHECKIN, _("Per product, at check-in")),
|
||||
)
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
related_name="questionnaires",
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
internal_name = models.CharField(
|
||||
verbose_name=_("Internal name"),
|
||||
max_length=255,
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=5,
|
||||
choices=TYPE_CHOICES,
|
||||
verbose_name=_("Questionnaire type")
|
||||
)
|
||||
items = models.ManyToManyField(
|
||||
Item,
|
||||
related_name='questionnaires',
|
||||
verbose_name=_("Products"),
|
||||
blank=True,
|
||||
help_text=_('This questionnaire will be asked to buyers of the selected products')
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
)
|
||||
all_sales_channels = models.BooleanField(
|
||||
verbose_name=_("Sell on all sales channels the product is sold on"),
|
||||
default=True,
|
||||
)
|
||||
limit_sales_channels = models.ManyToManyField(
|
||||
"SalesChannel",
|
||||
verbose_name=_("Restrict to specific sales channels"),
|
||||
help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is '
|
||||
'selected here but not on product level, the variation will not be available.'),
|
||||
blank=True,
|
||||
)
|
||||
|
||||
|
||||
class QuestionnaireChild(LoggedModel):
|
||||
SYSTEM_QUESTION_CHOICES = (
|
||||
('attendee_name_parts', _('Attendee name')),
|
||||
('attendee_email', _('Attendee email')),
|
||||
('company', _('Company')),
|
||||
('street', _('Street')),
|
||||
('zipcode', _('ZIP code')),
|
||||
('city', _('City')),
|
||||
('country', _('Country')),
|
||||
)
|
||||
questionnaire = models.ForeignKey(
|
||||
Questionnaire,
|
||||
related_name="children",
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
position = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name=_("Position")
|
||||
)
|
||||
user_question = models.ForeignKey(
|
||||
Question,
|
||||
related_name="references",
|
||||
on_delete=models.CASCADE,
|
||||
null=True, blank=True,
|
||||
)
|
||||
system_question = models.CharField(
|
||||
max_length=25,
|
||||
choices=SYSTEM_QUESTION_CHOICES,
|
||||
null=True, blank=True,
|
||||
)
|
||||
required = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Required question")
|
||||
)
|
||||
label = I18nTextField(
|
||||
verbose_name=_("Question")
|
||||
)
|
||||
help_text = I18nTextField(
|
||||
verbose_name=_("Help text"),
|
||||
help_text=_("If the question needs to be explained or clarified, do it here!"),
|
||||
null=True, blank=True,
|
||||
)
|
||||
dependency_question = models.ForeignKey(
|
||||
'QuestionnaireChild', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
|
||||
)
|
||||
dependency_values = MultiStringField(default=[])
|
||||
|
||||
|
||||
class Quota(LoggedModel):
|
||||
"""
|
||||
A quota is a "pool of tickets". It is there to limit the number of items
|
||||
|
||||
@@ -220,3 +220,20 @@ class OutgoingMail(models.Model):
|
||||
error_log_action_type = 'pretix.email.error'
|
||||
log_target = None
|
||||
return log_target, error_log_action_type
|
||||
|
||||
def log_data(self):
|
||||
return {
|
||||
"subject": self.subject,
|
||||
"message": self.body_plain,
|
||||
"to": self.to,
|
||||
"cc": self.cc,
|
||||
"bcc": self.bcc,
|
||||
|
||||
"invoices": [i.pk for i in self.should_attach_invoices.all()],
|
||||
"attach_tickets": self.should_attach_tickets,
|
||||
"attach_ical": self.should_attach_ical,
|
||||
"attach_other_files": self.should_attach_other_files,
|
||||
"attach_cached_files": [cf.filename for cf in self.should_attach_cached_files.all()],
|
||||
|
||||
"position": self.orderposition.positionid if self.orderposition else None,
|
||||
}
|
||||
|
||||
@@ -87,7 +87,6 @@ from pretix.base.timemachine import time_machine_now
|
||||
|
||||
from ...helpers import OF_SELF
|
||||
from ...helpers.countries import CachedCountries, FastCountryField
|
||||
from ...helpers.format import FormattedString, format_map
|
||||
from ...helpers.names import build_name
|
||||
from ...testutils.middleware import debugflags_var
|
||||
from ._transactions import (
|
||||
@@ -1167,7 +1166,7 @@ class Order(LockModel, LoggedModel):
|
||||
only be attached for this position and child positions, the link will only point to the
|
||||
position and the attendee email will be used if available.
|
||||
"""
|
||||
from pretix.base.services.mail import mail, render_mail
|
||||
from pretix.base.services.mail import mail
|
||||
|
||||
if not self.email and not (position and position.attendee_email):
|
||||
return
|
||||
@@ -1177,32 +1176,20 @@ class Order(LockModel, LoggedModel):
|
||||
if position and position.attendee_email:
|
||||
recipient = position.attendee_email
|
||||
|
||||
email_content = render_mail(template, context)
|
||||
if not isinstance(subject, FormattedString):
|
||||
subject = format_map(subject, context)
|
||||
mail(
|
||||
outgoing_mail = mail(
|
||||
recipient, subject, template, context,
|
||||
self.event, self.locale, self, headers=headers, sender=sender,
|
||||
invoices=invoices, attach_tickets=attach_tickets,
|
||||
position=position, auto_email=auto_email, attach_ical=attach_ical,
|
||||
attach_other_files=attach_other_files, attach_cached_files=attach_cached_files,
|
||||
)
|
||||
self.log_action(
|
||||
log_entry_type,
|
||||
user=user,
|
||||
auth=auth,
|
||||
data={
|
||||
'subject': subject,
|
||||
'message': email_content,
|
||||
'position': position.positionid if position else None,
|
||||
'recipient': recipient,
|
||||
'invoices': [i.pk for i in invoices] if invoices else [],
|
||||
'attach_tickets': attach_tickets,
|
||||
'attach_ical': attach_ical,
|
||||
'attach_other_files': attach_other_files,
|
||||
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
|
||||
}
|
||||
)
|
||||
if outgoing_mail:
|
||||
self.log_action(
|
||||
log_entry_type,
|
||||
user=user,
|
||||
auth=auth,
|
||||
data=outgoing_mail.log_data(),
|
||||
)
|
||||
|
||||
def resend_link(self, user=None, auth=None):
|
||||
with language(self.locale, self.event.settings.region):
|
||||
@@ -2900,17 +2887,14 @@ class OrderPosition(AbstractPosition):
|
||||
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download
|
||||
:param attach_ical: Attach relevant ICS files
|
||||
"""
|
||||
from pretix.base.services.mail import mail, render_mail
|
||||
from pretix.base.services.mail import mail
|
||||
|
||||
if not self.attendee_email:
|
||||
return
|
||||
|
||||
with language(self.order.locale, self.order.event.settings.region):
|
||||
recipient = self.attendee_email
|
||||
email_content = render_mail(template, context)
|
||||
if not isinstance(subject, FormattedString):
|
||||
subject = format_map(subject, context)
|
||||
mail(
|
||||
outgoing_mail = mail(
|
||||
recipient, subject, template, context,
|
||||
self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
|
||||
position=self,
|
||||
@@ -2919,21 +2903,13 @@ class OrderPosition(AbstractPosition):
|
||||
attach_ical=attach_ical,
|
||||
attach_other_files=attach_other_files,
|
||||
)
|
||||
self.order.log_action(
|
||||
log_entry_type,
|
||||
user=user,
|
||||
auth=auth,
|
||||
data={
|
||||
'subject': subject,
|
||||
'message': email_content,
|
||||
'recipient': recipient,
|
||||
'invoices': [i.pk for i in invoices] if invoices else [],
|
||||
'attach_tickets': attach_tickets,
|
||||
'attach_ical': attach_ical,
|
||||
'attach_other_files': attach_other_files,
|
||||
'attach_cached_files': [],
|
||||
}
|
||||
)
|
||||
if outgoing_mail:
|
||||
self.order.log_action(
|
||||
log_entry_type,
|
||||
user=user,
|
||||
auth=auth,
|
||||
data=outgoing_mail.log_data(),
|
||||
)
|
||||
|
||||
def resend_link(self, user=None, auth=None):
|
||||
|
||||
|
||||
+141
-103
@@ -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()
|
||||
|
||||
@@ -34,10 +34,9 @@ from phonenumber_field.modelfields import PhoneNumberField
|
||||
from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import User, Voucher
|
||||
from pretix.base.services.mail import mail, render_mail
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.helpers import OF_SELF
|
||||
|
||||
from ...helpers.format import format_map
|
||||
from ...helpers.names import build_name
|
||||
from .base import LoggedModel
|
||||
from .event import Event, SubEvent
|
||||
@@ -273,9 +272,7 @@ class WaitingListEntry(LoggedModel):
|
||||
with language(self.locale, self.event.settings.region):
|
||||
recipient = self.email
|
||||
|
||||
email_content = render_mail(template, context)
|
||||
subject = format_map(subject, context)
|
||||
mail(
|
||||
outgoing_mail = mail(
|
||||
recipient, subject, template, context,
|
||||
self.event,
|
||||
self.locale,
|
||||
@@ -285,18 +282,13 @@ class WaitingListEntry(LoggedModel):
|
||||
attach_other_files=attach_other_files,
|
||||
attach_cached_files=attach_cached_files,
|
||||
)
|
||||
self.log_action(
|
||||
log_entry_type,
|
||||
user=user,
|
||||
auth=auth,
|
||||
data={
|
||||
'subject': subject,
|
||||
'message': email_content,
|
||||
'recipient': recipient,
|
||||
'attach_other_files': attach_other_files,
|
||||
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
|
||||
}
|
||||
)
|
||||
if outgoing_mail:
|
||||
self.log_action(
|
||||
log_entry_type,
|
||||
user=user,
|
||||
auth=auth,
|
||||
data=outgoing_mail.log_data(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def clean_itemvar(event, item, variation):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1295,6 +1295,7 @@ class ManualPayment(BasePaymentProvider):
|
||||
|
||||
def format_map(self, order, payment):
|
||||
return {
|
||||
# Possible placeholder injection, we should make sure to never include user-controlled variables here
|
||||
'order': order.code,
|
||||
'amount': payment.amount,
|
||||
'currency': self.event.currency,
|
||||
@@ -1525,16 +1526,26 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
def payment_control_render(self, request, payment) -> str:
|
||||
from .models import GiftCard
|
||||
|
||||
if 'gift_card' in payment.info_data:
|
||||
gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card'))
|
||||
if any(key in payment.info_data for key in ('gift_card', 'error')):
|
||||
template = get_template('pretixcontrol/giftcards/payment.html')
|
||||
|
||||
ctx = {
|
||||
'request': request,
|
||||
'event': self.event,
|
||||
'gc': gc,
|
||||
**({'error': payment.info_data[
|
||||
'error']} if 'error' in payment.info_data else {}),
|
||||
**({'gift_card_secret': payment.info_data[
|
||||
'gift_card_secret']} if 'gift_card_secret' in payment.info_data else {})
|
||||
}
|
||||
return template.render(ctx)
|
||||
|
||||
try:
|
||||
gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card'))
|
||||
ctx = {
|
||||
'gc': gc,
|
||||
}
|
||||
except GiftCard.DoesNotExist:
|
||||
pass
|
||||
finally:
|
||||
return template.render(ctx)
|
||||
|
||||
def payment_control_render_short(self, payment: OrderPayment) -> str:
|
||||
d = payment.info_data
|
||||
@@ -1549,12 +1560,16 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
try:
|
||||
gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card'))
|
||||
except GiftCard.DoesNotExist:
|
||||
return {}
|
||||
return {
|
||||
**({'error': payment.info_data[
|
||||
'error']} if 'error' in payment.info_data else {})
|
||||
}
|
||||
return {
|
||||
'gift_card': {
|
||||
'id': gc.pk,
|
||||
'secret': gc.secret,
|
||||
'organizer': gc.issuer.slug
|
||||
'organizer': gc.issuer.slug,
|
||||
** ({'error': payment.info_data['error']} if 'error' in payment.info_data else {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1626,6 +1641,8 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
raise PaymentException(_("This gift card does not support this currency."))
|
||||
if not gc.accepted_by(self.event.organizer):
|
||||
raise PaymentException(_("This gift card is not accepted by this event organizer."))
|
||||
if gc.value <= Decimal("0.00"):
|
||||
raise PaymentException(_("All credit on this gift card has been used."))
|
||||
if payment.amount > gc.value:
|
||||
raise PaymentException(_("This gift card was used in the meantime. Please try again."))
|
||||
if gc.testmode and not payment.order.testmode:
|
||||
@@ -1655,7 +1672,7 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
}
|
||||
)
|
||||
except PaymentException as e:
|
||||
payment.fail(info={'error': str(e)})
|
||||
payment.fail(info={**payment.info_data, 'error': str(e)}, send_mail=not is_early_special_case)
|
||||
raise e
|
||||
|
||||
def payment_is_valid_session(self, request: HttpRequest) -> bool:
|
||||
|
||||
@@ -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 <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/>.
|
||||
#
|
||||
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")),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -45,7 +45,6 @@ from pretix.base.services.tax import split_fee_for_taxes
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.format import format_map
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -55,7 +54,7 @@ def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: Lazy
|
||||
email_context = get_email_context(event_or_subevent=subevent or wle.event, event=wle.event)
|
||||
mail(
|
||||
wle.email,
|
||||
format_map(subject, email_context),
|
||||
str(subject),
|
||||
message,
|
||||
email_context,
|
||||
wle.event,
|
||||
@@ -73,9 +72,8 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
|
||||
|
||||
email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount,
|
||||
order=order, position_or_address=ia, event=order.event)
|
||||
real_subject = format_map(subject, email_context)
|
||||
order.send_mail(
|
||||
real_subject, message, email_context,
|
||||
subject, message, email_context,
|
||||
'pretix.event.order.email.event_canceled',
|
||||
user,
|
||||
)
|
||||
@@ -85,14 +83,13 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
|
||||
continue
|
||||
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
||||
real_subject = format_map(subject, email_context)
|
||||
email_context = get_email_context(event_or_subevent=p.subevent or order.event,
|
||||
event=order.event,
|
||||
refund_amount=refund_amount,
|
||||
position_or_address=p,
|
||||
order=order, position=p)
|
||||
order.send_mail(
|
||||
real_subject, message, email_context,
|
||||
subject, message, email_context,
|
||||
'pretix.event.order.email.event_canceled',
|
||||
position=p,
|
||||
user=user
|
||||
|
||||
@@ -334,7 +334,8 @@ def _check_position_constraints(
|
||||
raise CartPositionError(error_messages['voucher_invalid_subevent'])
|
||||
|
||||
# Voucher expired
|
||||
if voucher and voucher.valid_until and voucher.valid_until < time_machine_now_dt:
|
||||
# (checked using real_now_dt as vouchers influence quota calculations)
|
||||
if voucher and voucher.valid_until and voucher.valid_until < real_now_dt:
|
||||
raise CartPositionError(error_messages['voucher_expired'])
|
||||
|
||||
# Subevent has been disabled
|
||||
|
||||
@@ -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() if event_qs else organizer.events.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] and not staff_session:
|
||||
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,
|
||||
|
||||
@@ -149,13 +149,13 @@ def prefix_subject(settings_holder, subject, highlight=False):
|
||||
return subject
|
||||
|
||||
|
||||
def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, LazyI18nString],
|
||||
def mail(email: Union[str, Sequence[str]], subject: Union[str, FormattedString], template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None,
|
||||
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None,
|
||||
customer: Customer = None, invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None,
|
||||
attach_ical=False, attach_cached_files: Sequence = None, attach_other_files: list=None,
|
||||
plain_text_only=False, no_order_links=False, cc: Sequence[str]=None, bcc: Sequence[str]=None,
|
||||
sensitive: bool=False):
|
||||
sensitive: bool=False) -> Optional[OutgoingMail]:
|
||||
"""
|
||||
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
|
||||
|
||||
@@ -335,14 +335,26 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
should_attach_other_files=attach_other_files or [],
|
||||
sensitive=sensitive,
|
||||
)
|
||||
m._prefetched_objects_cache = {}
|
||||
if invoices and not position:
|
||||
m.should_attach_invoices.add(*invoices)
|
||||
# Hack: For logging, we'll later make a `should_attach_invoices.all()` call. We can prevent a useless
|
||||
# DB query by filling the cache
|
||||
m._prefetched_objects_cache[m.should_attach_invoices.prefetch_cache_name] = invoices
|
||||
else:
|
||||
m._prefetched_objects_cache[m.should_attach_invoices.prefetch_cache_name] = Invoice.objects.none()
|
||||
if attach_cached_files:
|
||||
cf_list = []
|
||||
for cf in attach_cached_files:
|
||||
if not isinstance(cf, CachedFile):
|
||||
m.should_attach_cached_files.add(CachedFile.objects.get(pk=cf))
|
||||
else:
|
||||
m.should_attach_cached_files.add(cf)
|
||||
cf = CachedFile.objects.get(pk=cf)
|
||||
m.should_attach_cached_files.add(cf)
|
||||
cf_list.append(cf)
|
||||
# Hack: For logging, we'll later make a `should_attach_cached_files.all()` call. We can prevent a useless
|
||||
# DB query by filling the cache
|
||||
m._prefetched_objects_cache[m.should_attach_cached_files.prefetch_cache_name] = cf_list
|
||||
else:
|
||||
m._prefetched_objects_cache[m.should_attach_cached_files.prefetch_cache_name] = CachedFile.objects.none()
|
||||
|
||||
send_task = mail_send_task.si(
|
||||
outgoing_mail=m.id
|
||||
@@ -364,6 +376,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
lambda: chain(*task_chain).apply_async()
|
||||
)
|
||||
|
||||
return m
|
||||
|
||||
|
||||
class CustomEmail(EmailMultiAlternatives):
|
||||
def _create_mime_attachment(self, content, mimetype):
|
||||
@@ -498,16 +512,17 @@ def mail_send_task(self, **kwargs) -> bool:
|
||||
|
||||
# Attach calendar files
|
||||
if outgoing_mail.should_attach_ical and outgoing_mail.order:
|
||||
fname = re.sub('[^a-zA-Z0-9 ]', '-', unidecode(pgettext('attachment_filename', 'Calendar invite')))
|
||||
icals = get_private_icals(
|
||||
outgoing_mail.event,
|
||||
[outgoing_mail.orderposition] if outgoing_mail.orderposition else outgoing_mail.order.positions.all()
|
||||
)
|
||||
for i, cal in enumerate(icals):
|
||||
name = '{}{}.ics'.format(fname, f'-{i + 1}' if i > 0 else '')
|
||||
content = cal.serialize()
|
||||
mimetype = 'text/calendar'
|
||||
email.attach(name, content, mimetype)
|
||||
with language(outgoing_mail.order.locale, outgoing_mail.event.settings.region):
|
||||
fname = re.sub('[^a-zA-Z0-9 ]', '-', unidecode(pgettext('attachment_filename', 'Calendar invite')))
|
||||
icals = get_private_icals(
|
||||
outgoing_mail.event,
|
||||
[outgoing_mail.orderposition] if outgoing_mail.orderposition else outgoing_mail.order.positions.all()
|
||||
)
|
||||
for i, cal in enumerate(icals):
|
||||
name = '{}{}.ics'.format(fname, f'-{i + 1}' if i > 0 else '')
|
||||
content = cal.serialize()
|
||||
mimetype = 'text/calendar'
|
||||
email.attach(name, content, mimetype)
|
||||
|
||||
invoices_to_mark_transmitted = []
|
||||
for inv in outgoing_mail.should_attach_invoices.all():
|
||||
|
||||
@@ -1799,8 +1799,6 @@ class OrderChangeManager:
|
||||
tax_rule = tax_rules.get(pos.pk, pos.tax_rule)
|
||||
if not tax_rule:
|
||||
continue
|
||||
if not pos.price:
|
||||
continue
|
||||
|
||||
try:
|
||||
new_rate = tax_rule.tax_rate_for(ia)
|
||||
@@ -1817,7 +1815,9 @@ class OrderChangeManager:
|
||||
override_tax_rate=new_rate, override_tax_code=new_code)
|
||||
self._totaldiff_guesstimate += new_tax.gross - pos.price
|
||||
self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price))
|
||||
self._invoice_dirty = True
|
||||
if pos.price:
|
||||
# We do not consider the invoice dirty if only 0€-valued taxes are changed
|
||||
self._invoice_dirty = True
|
||||
|
||||
def cancel_fee(self, fee: OrderFee):
|
||||
self._totaldiff_guesstimate -= fee.value
|
||||
|
||||
@@ -39,7 +39,7 @@ def vouchers_send(event: Event, vouchers: list, subject: str, message: str, reci
|
||||
with language(event.settings.locale):
|
||||
email_context = get_email_context(event=event, name=r.get('name') or '',
|
||||
voucher_list=[v.code for v in voucher_list])
|
||||
mail(
|
||||
outgoing_mail = mail(
|
||||
r['email'],
|
||||
subject,
|
||||
LazyI18nString(message),
|
||||
@@ -60,8 +60,8 @@ def vouchers_send(event: Event, vouchers: list, subject: str, message: str, reci
|
||||
data={
|
||||
'recipient': r['email'],
|
||||
'name': r.get('name'),
|
||||
'subject': subject,
|
||||
'message': message,
|
||||
'subject': outgoing_mail.subject,
|
||||
'message': outgoing_mail.body_plain,
|
||||
},
|
||||
save=False
|
||||
))
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -363,7 +363,7 @@ class EmailAddressShredder(BaseDataShredder):
|
||||
le.save(update_fields=['data', 'shredded'])
|
||||
else:
|
||||
shred_log_fields(le, banlist=[
|
||||
'recipient', 'message', 'subject', 'full_mail', 'old_email', 'new_email'
|
||||
'recipient', 'message', 'subject', 'full_mail', 'old_email', 'new_email', 'bcc', 'cc',
|
||||
])
|
||||
|
||||
|
||||
|
||||
@@ -305,6 +305,19 @@ class GlobalSignal(django.dispatch.Signal):
|
||||
response = receiver(signal=self, sender=sender, **named)
|
||||
return response
|
||||
|
||||
def _live_receivers(self, sender):
|
||||
# Ensure consistent sorting of receivers
|
||||
orig_list = super()._live_receivers(sender)
|
||||
sorted_list = sorted(
|
||||
orig_list,
|
||||
key=lambda receiver: (
|
||||
0 if any(receiver.__module__.startswith(m) for m in settings.CORE_MODULES) else 1,
|
||||
receiver.__module__,
|
||||
receiver.__name__,
|
||||
)
|
||||
)
|
||||
return sorted_list
|
||||
|
||||
|
||||
class DeprecatedSignal(GlobalSignal):
|
||||
|
||||
@@ -561,6 +574,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 +1131,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.
|
||||
"""
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<meta charset="utf-8">
|
||||
<link rel="icon" href="{% static "pretixbase/img/favicon.ico" %}">
|
||||
{% block custom_header %}{% endblock %}
|
||||
{% if css_theme %}
|
||||
<link rel="stylesheet" type="text/css" href="{{ css_theme }}" />
|
||||
{% endif %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
|
||||
import json
|
||||
import logging
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django import template
|
||||
from django.conf import settings
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
register = template.Library()
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
_MANIFEST = {}
|
||||
# TODO more os.path.join ?
|
||||
MANIFEST_PATH = settings.STATIC_ROOT + "/vite/control/.vite/manifest.json"
|
||||
MANIFEST_BASE = "vite/control/"
|
||||
|
||||
# We're building the manifest if we don't have a dev server running AND if we're
|
||||
# not currently running `rebuild` (which creates the manifest in the first place).
|
||||
if not settings.VITE_DEV_MODE and not settings.VITE_IGNORE:
|
||||
try:
|
||||
with open(MANIFEST_PATH) as fp:
|
||||
_MANIFEST = json.load(fp)
|
||||
except Exception as e:
|
||||
LOGGER.warning(f"Error reading vite manifest at {MANIFEST_PATH}: {str(e)}")
|
||||
|
||||
|
||||
def generate_script_tag(path, attrs):
|
||||
all_attrs = " ".join(f'{key}="{value}"' for key, value in attrs.items())
|
||||
if settings.VITE_DEV_MODE:
|
||||
src = urljoin(settings.VITE_DEV_SERVER, path)
|
||||
else:
|
||||
src = urljoin(settings.STATIC_URL, path)
|
||||
return f'<script {all_attrs} src="{src}"></script>'
|
||||
|
||||
|
||||
def generate_css_tags(asset, already_processed=None):
|
||||
"""Recursively builds all CSS tags used in a given asset.
|
||||
|
||||
Ignore the side effects."""
|
||||
tags = []
|
||||
manifest_entry = _MANIFEST[asset]
|
||||
if already_processed is None:
|
||||
already_processed = []
|
||||
|
||||
# Put our own CSS file first for specificity
|
||||
if "css" in manifest_entry:
|
||||
for css_path in manifest_entry["css"]:
|
||||
if css_path not in already_processed:
|
||||
full_path = urljoin(settings.STATIC_URL, MANIFEST_BASE + css_path)
|
||||
tags.append(f'<link rel="stylesheet" href="{full_path}" />')
|
||||
already_processed.append(css_path)
|
||||
|
||||
# Import each file only one by way of side effects in already_processed
|
||||
if "imports" in manifest_entry:
|
||||
for import_path in manifest_entry["imports"]:
|
||||
tags += generate_css_tags(import_path, already_processed)
|
||||
|
||||
return tags
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
@mark_safe
|
||||
def vite_asset(path):
|
||||
"""
|
||||
Generates one <script> tag and <link> tags for each of the CSS dependencies.
|
||||
"""
|
||||
|
||||
if not path:
|
||||
return ""
|
||||
|
||||
if settings.VITE_DEV_MODE:
|
||||
return generate_script_tag(path, {"type": "module"})
|
||||
|
||||
manifest_entry = _MANIFEST.get(path)
|
||||
if not manifest_entry:
|
||||
raise RuntimeError(f"Cannot find {path} in Vite manifest at {MANIFEST_PATH}")
|
||||
|
||||
tags = generate_css_tags(path)
|
||||
tags.append(
|
||||
generate_script_tag(
|
||||
MANIFEST_BASE + manifest_entry["file"], {"type": "module", "crossorigin": ""}
|
||||
)
|
||||
)
|
||||
return "".join(tags)
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
@mark_safe
|
||||
def vite_hmr():
|
||||
if not settings.VITE_DEV_MODE:
|
||||
return ""
|
||||
return generate_script_tag("@vite/client", {"type": "module"})
|
||||
+57
-26
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -104,6 +104,12 @@ class GlobalSettingsForm(SettingsForm):
|
||||
help_text=_("Will be served at {domain}/.well-known/apple-developer-merchantid-domain-association").format(
|
||||
domain=settings.SITE_URL
|
||||
)
|
||||
)),
|
||||
('widget_vite_origins', forms.CharField(
|
||||
widget=forms.Textarea(attrs={'rows': '3'}),
|
||||
required=False,
|
||||
label=_("Vite widget origins"),
|
||||
help_text=_("One origin per line (e.g. https://example.com). Requests from these origins will be served the new vite-based widget."),
|
||||
))
|
||||
])
|
||||
responses = register_global_settings.send(self)
|
||||
|
||||
@@ -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 = '<span class="fa fa-puzzle-piece text-muted" data-toggle="tooltip" title="{}"></span> ' + source
|
||||
params.insert(0, _("Provided by a plugin"))
|
||||
|
||||
if p.help_text:
|
||||
source += ' <span class="fa fa-info-circle text-muted" data-toggle="tooltip" title="{}"></span>'
|
||||
params.append(p.help_text)
|
||||
|
||||
source += ' (<code>{}</code>)'
|
||||
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} '
|
||||
'<span class="fa fa-question-circle fa-fw text-muted" data-toggle="tooltip"'
|
||||
' data-placement="right" title="{help_text}"></span>',
|
||||
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} '
|
||||
'<span class="fa fa-question-circle fa-fw text-muted" data-toggle="tooltip"'
|
||||
' data-placement="right" title="{help_text}"></span>',
|
||||
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):
|
||||
|
||||
|
||||
@@ -641,6 +641,7 @@ class TeamMembershipLogEntryType(LogEntryType):
|
||||
'pretix.team.member.added': _('{user} has been added to the team.'),
|
||||
'pretix.team.member.removed': _('{user} has been removed from the team.'),
|
||||
'pretix.team.invite.created': _('{user} has been invited to the team.'),
|
||||
'pretix.team.invite.deleted': _('Invite for {user} has been deleted.'),
|
||||
'pretix.team.invite.resent': _('Invite for {user} has been resent.'),
|
||||
})
|
||||
class CoreTeamMembershipLogEntryType(TeamMembershipLogEntryType):
|
||||
@@ -849,6 +850,9 @@ class OrganizerPluginStateLogEntryType(LogEntryType):
|
||||
'pretix.event.question.option.added': _('An answer option has been added to the question.'),
|
||||
'pretix.event.question.option.deleted': _('An answer option has been removed from the question.'),
|
||||
'pretix.event.question.option.changed': _('An answer option has been changed.'),
|
||||
'pretix.event.questionnaire.added': _('A questionnaire has been created.'),
|
||||
'pretix.event.questionnaire.deleted': _('A questionnaire has been deleted.'),
|
||||
'pretix.event.questionnaire.changed': _('A questionnaire has been changed.'),
|
||||
'pretix.event.permissions.added': _('A user has been added to the event team.'),
|
||||
'pretix.event.permissions.invited': _('A user has been invited to the event team.'),
|
||||
'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
+182
-153
@@ -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': _('Questionnaires'),
|
||||
'url': reverse('control:event.items.questionnaires', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.event.organizer.slug,
|
||||
}),
|
||||
'active': 'event.items.questionnaires' in url.url_name or '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={
|
||||
@@ -340,38 +362,43 @@ def get_global_navigation(request):
|
||||
'active': (url.url_name == 'index'),
|
||||
'icon': 'dashboard',
|
||||
},
|
||||
{
|
||||
'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',
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
if request.user.is_in_any_teams or request.user.is_staff:
|
||||
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'),
|
||||
@@ -480,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={
|
||||
@@ -534,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={
|
||||
@@ -544,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'),
|
||||
@@ -554,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'),
|
||||
@@ -575,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'),
|
||||
@@ -585,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'),
|
||||
@@ -624,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={
|
||||
@@ -667,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,
|
||||
@@ -679,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={
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{% block content %}
|
||||
<h1>
|
||||
{% 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 %}
|
||||
<a href="{% url "control:event.orders.checkinlists.edit" event=request.event.slug organizer=request.event.organizer.slug list=checkinlist.pk %}"
|
||||
class="btn btn-default">
|
||||
<span class="fa fa-wrench"></span>
|
||||
@@ -87,7 +87,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
{% 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 %}
|
||||
<label aria-label="{% trans "select all rows for batch-operation" %}"
|
||||
class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
|
||||
{% endif %}
|
||||
@@ -132,7 +132,7 @@
|
||||
{% for e in entries %}
|
||||
<tr>
|
||||
<td>
|
||||
{% 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 %}
|
||||
<input type="checkbox" name="checkin" id="id_checkin" class="" value="{{ e.pk }}"/>
|
||||
{% endif %}
|
||||
</td>
|
||||
@@ -207,7 +207,7 @@
|
||||
</table>
|
||||
</div>
|
||||
<div class="batch-select-actions">
|
||||
{% 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 %}
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
<span class="fa fa-sign-in" aria-hidden="true"></span>
|
||||
{% trans "Check-In selected attendees" %}
|
||||
@@ -217,7 +217,7 @@
|
||||
{% trans "Check-Out selected attendees" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if "can_change_orders" in request.eventpermset %}
|
||||
{% if "event.orders:write" in request.eventpermset %}
|
||||
<button type="submit" class="btn btn-danger btn-save" name="revert"
|
||||
formaction="{% url "control:event.orders.checkinlists.bulk_revert" event=request.event.slug organizer=request.event.organizer.slug list=checkinlist.pk %}"
|
||||
data-no-asynctask
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% load bootstrap3 %}
|
||||
{% load static %}
|
||||
{% load compress %}
|
||||
{% load vite %}
|
||||
{% block title %}
|
||||
{% if checkinlist %}
|
||||
{% blocktrans with name=checkinlist.name %}Check-in list: {{ name }}{% endblocktrans %}
|
||||
@@ -74,45 +75,8 @@
|
||||
{% bootstrap_field form.ignore_in_statistics layout="control" %}
|
||||
|
||||
<h3>{% trans "Custom check-in rule" %}</h3>
|
||||
<div id="rules-editor" class="form-inline">
|
||||
<div>
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li role="presentation" class="active">
|
||||
<a href="#rules-edit" role="tab" data-toggle="tab">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
</li>
|
||||
<li role="presentation">
|
||||
<a href="#rules-viz" role="tab" data-toggle="tab">
|
||||
<span class="fa fa-eye"></span>
|
||||
{% trans "Visualize" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Tab panes -->
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane active" id="rules-edit">
|
||||
<checkin-rules-editor></checkin-rules-editor>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="rules-viz">
|
||||
<checkin-rules-visualization></checkin-rules-visualization>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="alert alert-info" v-if="missingItems.length">
|
||||
<p>
|
||||
{% trans "Your rule always filters by product or variation, but the following products or variations are not contained in any of your rule parts so people with these tickets will not get in:" %}
|
||||
</p>
|
||||
<ul>
|
||||
<li v-for="h in missingItems">{{ "{" }}{h}{{ "}" }}</li>
|
||||
</ul>
|
||||
<p>
|
||||
{% trans "Please double-check if this was intentional." %}
|
||||
</p>
|
||||
</div>
|
||||
<div id="rules-editor">
|
||||
<!-- Vue app mount point -->
|
||||
</div>
|
||||
<div class="disabled-withoutjs sr-only">
|
||||
{{ form.rules }}
|
||||
@@ -127,11 +91,6 @@
|
||||
</form>
|
||||
{{ items|json_script:"items" }}
|
||||
|
||||
{% if DEBUG %}
|
||||
<script type="text/javascript" src="{% static "vuejs/vue.js" %}"></script>
|
||||
{% else %}
|
||||
<script type="text/javascript" src="{% static "vuejs/vue.min.js" %}"></script>
|
||||
{% endif %}
|
||||
{% compress js %}
|
||||
<script type="text/javascript" src="{% static "d3/d3.v6.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "d3/d3-color.v2.js" %}"></script>
|
||||
@@ -144,15 +103,6 @@
|
||||
<script type="text/javascript" src="{% static "d3/d3-drag.v2.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "d3/d3-zoom.v2.js" %}"></script>
|
||||
{% endcompress %}
|
||||
{% compress js %}
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules/jsonlogic-boolalg.js" %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/datetimefield.vue' %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/timefield.vue' %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/lookup-select2.vue' %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/checkin-rule.vue' %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/checkin-rules-editor.vue' %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/viz-node.vue' %}"></script>
|
||||
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/checkin-rules-visualization.vue' %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules.js" %}"></script>
|
||||
{% endcompress %}
|
||||
{% vite_hmr %}
|
||||
{% vite_asset "src/pretix/static/pretixcontrol/js/ui/checkinrules/index.ts" %}
|
||||
{% endblock %}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user