Compare commits

..
Author SHA1 Message Date
Raphael Michel dd3821983f Remove back link from 404 error page (#23222967)
I've kept it for 400/403/500/csrffail for now, because they also have a
"try again" link. Yes, both things have browser buttons, but they make
it a *little* clearer to technical users what one could to next, and
especially on csrffail, "step back" is always possible and possibly actually
helpful.
2026-02-17 09:31:24 +01:00
459 changed files with 215657 additions and 264274 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
name: Packaging name: Packaging
strategy: strategy:
matrix: matrix:
python-version: ["3.13"] python-version: ["3.11"]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
+4 -4
View File
@@ -24,10 +24,10 @@ jobs:
name: Check gettext syntax name: Check gettext syntax
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.13 - name: Set up Python 3.11
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.13 python-version: 3.11
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: ~/.cache/pip path: ~/.cache/pip
@@ -49,10 +49,10 @@ jobs:
name: Spellcheck name: Spellcheck
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.13 - name: Set up Python 3.11
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.13 python-version: 3.11
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: ~/.cache/pip path: ~/.cache/pip
+6 -6
View File
@@ -24,10 +24,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.13 - name: Set up Python 3.11
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.13 python-version: 3.11
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: ~/.cache/pip path: ~/.cache/pip
@@ -44,10 +44,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.13 - name: Set up Python 3.11
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.13 python-version: 3.11
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: ~/.cache/pip path: ~/.cache/pip
@@ -64,10 +64,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.13 - name: Set up Python 3.11
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.13 python-version: 3.11
- name: Install Dependencies - name: Install Dependencies
run: pip3 install licenseheaders run: pip3 install licenseheaders
- name: Run licenseheaders - name: Run licenseheaders
+2 -4
View File
@@ -23,15 +23,13 @@ jobs:
name: Tests name: Tests
strategy: strategy:
matrix: matrix:
python-version: ["3.11", "3.13", "3.14"] python-version: ["3.10", "3.11", "3.13"]
database: [sqlite, postgres] database: [sqlite, postgres]
exclude: exclude:
- database: sqlite - database: sqlite
python-version: "3.10" python-version: "3.10"
- database: sqlite - database: sqlite
python-version: "3.11" python-version: "3.11"
- database: sqlite
python-version: "3.12"
services: services:
postgres: postgres:
image: postgres:15 image: postgres:15
@@ -83,4 +81,4 @@ jobs:
file: src/coverage.xml file: src/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false fail_ci_if_error: false
if: matrix.database == 'postgres' && matrix.python-version == '3.13' if: matrix.database == 'postgres' && matrix.python-version == '3.11'
+5 -10
View File
@@ -1,16 +1,11 @@
Contributing to pretix Contributing to pretix
====================== ======================
Welcome to pretix, we are happy that you would like to contribute. Hey there and welcome to pretix!
Before you do so, please make sure to read the following documents:
- [Contribution workflow](https://docs.pretix.eu/dev/development/contribution/general.html) * We've got a contributors guide in [our documentation](https://docs.pretix.eu/dev/development/contribution/) together with notes on the [development setup](https://docs.pretix.eu/dev/development/setup.html).
- [AI-assisted contribution policy](https://docs.pretix.eu/dev/development/contribution/ai.html)
- [Coding style and quality](https://docs.pretix.eu/dev/development/contribution/style.html)
- [Development setup](https://docs.pretix.eu/dev/development/setup.html)
- [Code of Conduct](https://docs.pretix.eu/dev/development/contribution/codeofconduct.html)
Before we can accept your first PR we'll need you to sign [our **Contributor License Agreement** (CLA)](https://pretix.eu/about/en/cla). * Please note that we have a [Code of Conduct](https://docs.pretix.eu/dev/development/contribution/codeofconduct.html) in place that applies to all project contributions, including issues, pull requests, etc.
You can find more information about the how and why in our [License FAQ](https://docs.pretix.eu/trust/licensing/faq/) and in our [license change blog post](https://pretix.eu/about/en/blog/20210412-license/).
* Before we can accept a PR from you we'll need you to sign [our CLA](https://pretix.eu/about/en/cla). You can find more information about the how and why in our [License FAQ](https://docs.pretix.eu/trust/licensing/faq/) and in our [license change blog post](https://pretix.eu/about/en/blog/20210412-license/).
**Before contributing new functionality, always open a discussion first.**
+1 -2
View File
@@ -1,4 +1,4 @@
FROM python:3.13-trixie FROM python:3.11-bookworm
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
@@ -31,7 +31,6 @@ RUN apt-get update && \
mkdir /etc/pretix && \ mkdir /etc/pretix && \
mkdir /data && \ mkdir /data && \
useradd -ms /bin/bash -d /pretix -u 15371 pretixuser && \ useradd -ms /bin/bash -d /pretix -u 15371 pretixuser && \
chmod 0755 /pretix && \
echo 'pretixuser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/supervisord' >> /etc/sudoers && \ echo 'pretixuser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/supervisord' >> /etc/sudoers && \
mkdir /static && \ mkdir /static && \
mkdir /etc/supervisord mkdir /etc/supervisord
+4 -5
View File
@@ -197,11 +197,10 @@ Permissions & security profiles
Device authentication is currently hardcoded to grant the following permissions: Device authentication is currently hardcoded to grant the following permissions:
* Read event meta data and products etc. * View event meta data and products etc.
* Read and write orders * View orders
* Read and write gift cards * Change orders
* Read and write reusable media * Manage gift cards
* Read vouchers
Devices cannot change events or products and cannot access vouchers. Devices cannot change events or products and cannot access vouchers.
+1 -1
View File
@@ -30,7 +30,7 @@ software_brand string Device software
software_version string Device software version (read-only) software_version string Device software version (read-only)
created datetime Creation time created datetime Creation time
initialized datetime Time of initialization (or ``null``) initialized datetime Time of initialization (or ``null``)
initialization_token string Token for initialization (field invisible without write permission) initialization_token string Token for initialization
revoked boolean Whether this device no longer has access revoked boolean Whether this device no longer has access
security_profile string The name of a supported security profile restricting API access security_profile string The name of a supported security profile restricting API access
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
+14 -2
View File
@@ -65,6 +65,8 @@ Endpoints
Returns a list of all events within a given organizer the authenticated user/token has access to. 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**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
@@ -159,6 +161,8 @@ Endpoints
Returns information on one event, identified by its slug. Returns information on one event, identified by its slug.
Permission required: "Can change event settings"
**Example request**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
@@ -230,6 +234,8 @@ Endpoints
Please note that events cannot be created as 'live' using this endpoint. Quotas and payment must be added to the 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. event before sales can go live.
Permission required: "Can create events"
**Example request**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
@@ -332,6 +338,8 @@ Endpoints
Please note that you can only copy from events under the same organizer this way. Use the ``clone_from`` parameter 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. when creating a new event for this instead.
Permission required: "Can create events"
**Example request**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
@@ -425,6 +433,8 @@ Endpoints
Updates an event Updates an event
Permission required: "Can change event settings"
**Example request**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
@@ -500,6 +510,8 @@ Endpoints
Delete an event. Note that events with orders cannot be deleted to ensure data integrity. Delete an event. Note that events with orders cannot be deleted to ensure data integrity.
Permission required: "Can change event settings"
**Example request**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
@@ -549,6 +561,8 @@ organizer level.
Get current values of event settings. Get current values of event settings.
Permission required: "Can change event settings" (Exception: with device auth, *some* settings can always be *read*.)
**Example request**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
@@ -601,8 +615,6 @@ organizer level.
Updates event settings. Note that ``PUT`` is not allowed here, only ``PATCH``. 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:: .. warning::
Settings can be stored at different levels in pretix. If a value is not set on event level, a default setting Settings can be stored at different levels in pretix. If a value is not set on event level, a default setting
+6 -13
View File
@@ -16,7 +16,6 @@ Field Type Description
id integer Internal ID of the program time id integer Internal ID of the program time
start datetime The start date time for this program time slot. start datetime The start date time for this program time slot.
end datetime The end date time for this program time slot. end datetime The end date time for this program time slot.
location multi-lingual string The program time slot's location (or ``null``)
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: TODO .. versionchanged:: TODO
@@ -55,20 +54,17 @@ Endpoints
{ {
"id": 2, "id": 2,
"start": "2025-08-14T22:00:00Z", "start": "2025-08-14T22:00:00Z",
"end": "2025-08-15T00:00:00Z", "end": "2025-08-15T00:00:00Z"
"location": null
}, },
{ {
"id": 3, "id": 3,
"start": "2025-08-12T22:00:00Z", "start": "2025-08-12T22:00:00Z",
"end": "2025-08-13T22:00:00Z", "end": "2025-08-13T22:00:00Z"
"location": null
}, },
{ {
"id": 14, "id": 14,
"start": "2025-08-15T22:00:00Z", "start": "2025-08-15T22:00:00Z",
"end": "2025-08-17T22:00:00Z", "end": "2025-08-17T22:00:00Z"
"location": null
} }
] ]
} }
@@ -103,8 +99,7 @@ Endpoints
{ {
"id": 1, "id": 1,
"start": "2025-08-15T22:00:00Z", "start": "2025-08-15T22:00:00Z",
"end": "2025-10-27T23:00:00Z", "end": "2025-10-27T23:00:00Z"
"location": null
} }
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
@@ -130,8 +125,7 @@ Endpoints
{ {
"start": "2025-08-15T10:00:00Z", "start": "2025-08-15T10:00:00Z",
"end": "2025-08-15T22:00:00Z", "end": "2025-08-15T22:00:00Z"
"location": null
} }
**Example response**: **Example response**:
@@ -145,8 +139,7 @@ Endpoints
{ {
"id": 17, "id": 17,
"start": "2025-08-15T10:00:00Z", "start": "2025-08-15T10:00:00Z",
"end": "2025-08-15T22:00:00Z", "end": "2025-08-15T22:00:00Z"
"location": null
} }
:param organizer: The ``slug`` field of the organizer of the event/item to create a program time for :param organizer: The ``slug`` field of the organizer of the event/item to create a program time for
+2 -64
View File
@@ -117,8 +117,6 @@ cancellation_date datetime Time of order c
reliable for orders that have been cancelled, reliable for orders that have been cancelled,
reactivated and cancelled again. reactivated and cancelled again.
plugin_data object Additional data added by plugins. 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.
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
@@ -158,10 +156,6 @@ use_gift_cards list of strings List of unique
The ``tax_rounding_mode`` attribute has been added. 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:
Order position resource Order position resource
@@ -993,6 +987,8 @@ Creating orders
* does not support file upload questions * does not support file upload questions
* does not support redeeming gift cards
* does not support or validate memberships * does not support or validate memberships
@@ -1099,14 +1095,6 @@ Creating orders
whether these emails are enabled for certain sales channels. If set to ``null``, behavior will be controlled by pretix' 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``. settings based on the sales channels (added in pretix 4.7). Defaults to ``false``.
Used to be ``send_mail`` before pretix 3.14. 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 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 to incrementing integers starting with ``1``. Then, you can reference one of these
@@ -1731,56 +1719,6 @@ List of all order positions
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/orderpositions/
Returns a list of all order positions within all events of a given organizer (with sufficient access permissions).
The supported query parameters and output format of this endpoint are almost identical to those of the list endpoint
within an event.
The only changes are that responses also contain the ``event`` attribute in each result and that the 'pdf_data'
parameter is not supported.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/orderpositions/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
X-Page-Generated: 2017-12-01T10:00:00Z
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id:": 23442
"event": "sampleconf",
"order": "ABC12",
"positionid": 1,
"canceled": false,
"item": 1345,
...
}
]
}
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
Fetching individual positions Fetching individual positions
----------------------------- -----------------------------
+4
View File
@@ -110,6 +110,8 @@ Endpoints
Updates an organizer. Currently only the ``plugins`` field may be updated. Updates an organizer. Currently only the ``plugins`` field may be updated.
Permission required: "Can change organizer settings"
**Example request**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
@@ -170,6 +172,8 @@ information about the properties.
Get current values of organizer settings. Get current values of organizer settings.
Permission required: "Can change organizer settings"
**Example request**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
+1 -1
View File
@@ -154,7 +154,7 @@ Endpoints
.. http:post:: /api/v1/organizers/(organizer)/reusablemedia/lookup/ .. 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 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, therefore this endpoint requires write permissions. medium behind the scenes.
This endpoint, and this endpoint only, might return media from a different organizer if there is a cross-acceptance 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, agreement. In this case, only linked gift cards will be returned, no order position or customer records,
+6
View File
@@ -154,6 +154,8 @@ Endpoints
Creates a new subevent. Creates a new subevent.
Permission required: "Can create events"
**Example request**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
@@ -298,6 +300,8 @@ Endpoints
provide all fields of the resource, other fields will be reset to default. With ``PATCH``, you only need to provide 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. the fields that you want to change.
Permission required: "Can change event settings"
**Example request**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
@@ -369,6 +373,8 @@ Endpoints
Delete a sub-event. Note that events with orders cannot be deleted to ensure data integrity. Delete a sub-event. Note that events with orders cannot be deleted to ensure data integrity.
Permission required: "Can change event settings"
**Example request**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
+15 -72
View File
@@ -24,58 +24,21 @@ all_events boolean Whether this te
limit_events list List of event slugs this team has access to limit_events list List of event slugs this team has access to
require_2fa boolean Whether members of this team are required to use require_2fa boolean Whether members of this team are required to use
two-factor authentication two-factor authentication
all_event_permissions bool Whether members of this team are granted all event-level can_create_events boolean
permissions, including future additions can_change_teams boolean
limit_event_permissions list of strings The event-level permissions team members are granted can_change_organizer_settings boolean
all_organizer_permissions bool Whether members of this team are granted all organizer-level can_manage_customers boolean
permissions, including future additions can_manage_reusable_media boolean
all_organizer_permissions list of strings The organizer-level permissions team members are granted can_manage_gift_cards boolean
can_create_events boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``. can_change_event_settings boolean
can_change_teams boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``. can_change_items boolean
can_change_organizer_settings boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``. can_view_orders boolean
can_manage_customers boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``. can_change_orders boolean
can_manage_reusable_media boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``. can_view_vouchers boolean
can_manage_gift_cards boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``. can_change_vouchers boolean
can_change_event_settings boolean **DEPRECATED**. Legacy interface, use ``limit_event_permissions``. can_checkin_orders boolean
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 Team member resource
-------------------- --------------------
@@ -158,10 +121,6 @@ Team endpoints
"all_events": true, "all_events": true,
"limit_events": [], "limit_events": [],
"require_2fa": true, "require_2fa": true,
"all_event_permissions": true,
"limit_event_permissions": [],
"all_organizer_permissions": true,
"limit_organizer_permissions": [],
"can_create_events": true, "can_create_events": true,
... ...
} }
@@ -200,10 +159,6 @@ Team endpoints
"all_events": true, "all_events": true,
"limit_events": [], "limit_events": [],
"require_2fa": true, "require_2fa": true,
"all_event_permissions": true,
"limit_event_permissions": [],
"all_organizer_permissions": true,
"limit_organizer_permissions": [],
"can_create_events": true, "can_create_events": true,
... ...
} }
@@ -232,10 +187,7 @@ Team endpoints
"all_events": true, "all_events": true,
"limit_events": [], "limit_events": [],
"require_2fa": true, "require_2fa": true,
"all_event_permissions": true, "can_create_events": true,
"limit_event_permissions": [],
"all_organizer_permissions": true,
"limit_organizer_permissions": [],
... ...
} }
@@ -253,10 +205,6 @@ Team endpoints
"all_events": true, "all_events": true,
"limit_events": [], "limit_events": [],
"require_2fa": true, "require_2fa": true,
"all_event_permissions": true,
"limit_event_permissions": [],
"all_organizer_permissions": true,
"limit_organizer_permissions": [],
"can_create_events": true, "can_create_events": true,
... ...
} }
@@ -284,8 +232,7 @@ Team endpoints
Content-Length: 94 Content-Length: 94
{ {
"all_organizer_permissions": false, "can_create_events": true
"limit_organizer_permissions": ["organizer.events:create"]
} }
**Example response**: **Example response**:
@@ -302,10 +249,6 @@ Team endpoints
"all_events": true, "all_events": true,
"limit_events": [], "limit_events": [],
"require_2fa": true, "require_2fa": true,
"all_event_permissions": true,
"limit_event_permissions": [],
"all_organizer_permissions": false,
"limit_organizer_permissions": ["organizer.events:create"],
"can_create_events": true, "can_create_events": true,
... ...
} }
+7 -7
View File
@@ -55,12 +55,12 @@ your views:
) )
class AdminView(EventPermissionRequiredMixin, View): class AdminView(EventPermissionRequiredMixin, View):
permission = 'event.orders:read' permission = 'can_view_orders'
... ...
@event_permission_required('event.orders:read') @event_permission_required('can_view_orders')
def admin_view(request, organizer, event): 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') @receiver(nav_event, dispatch_uid='friends_tickets_nav')
def navbar_info(sender, request, **kwargs): def navbar_info(sender, request, **kwargs):
url = resolve(request.path_info) url = resolve(request.path_info)
if not request.user.has_event_permission(request.organizer, request.event, 'event.vouchers:read'): if not request.user.has_event_permission(request.organizer, request.event, 'can_change_vouchers'):
return [] return []
return [{ return [{
'label': _('My plugin view'), '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): class MySettingsView(EventSettingsViewMixin, EventSettingsFormView):
model = Event model = Event
permission = 'event.settings.general:write' permission = 'can_change_settings'
form_class = MySettingsForm form_class = MySettingsForm
template_name = 'my_plugin/settings.html' 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 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. .event`` and ``request.organizer`` are available as usual.
To require a special permission like ``event.orders:read``, you do not need to inherit from a special ViewSet base To require a special permission like ``can_view_orders``, you do not need to inherit from a special ViewSet base
class, you can just set the ``permission`` attribute on your viewset: class, you can just set the ``permission`` attribute on your viewset:
.. code-block:: python .. code-block:: python
class MyViewSet(ModelViewSet): class MyViewSet(ModelViewSet):
permission = 'event.orders:read' permission = 'can_view_orders'
... ...
If you want to check the permission only for some methods of your viewset, you have to do it yourself. Note here that 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 .. code-block:: python
perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken) else request.user) perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken) else request.user)
if perm_holder.has_event_permission(request.event.organizer, request.event, 'event.orders:read'): if perm_holder.has_event_permission(request.event.organizer, request.event, 'can_view_orders'):
... ...
-16
View File
@@ -80,24 +80,8 @@ The exporter class
.. autoattribute:: category .. autoattribute:: category
.. autoattribute:: feature
.. autoattribute:: export_form_fields .. autoattribute:: export_form_fields
.. autoattribute:: repeatable_read
.. automethod:: render .. automethod:: render
This is an abstract method, you **must** override this! 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
+1 -2
View File
@@ -14,8 +14,7 @@ Core
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, notification, :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, item_copy_data, register_sales_channel_types, register_global_settings, quota_availability, global_email_filter,
register_ticket_secret_generators, gift_card_transaction_display, 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 Order events
"""""""""""" """"""""""""
-24
View File
@@ -1,24 +0,0 @@
.. _`aipolicy`:
AI-assisted contribution policy
===============================
pretix is maintained by humans.
Every discussion, issue, and pull request is read and reviewed by humans (and sometimes machines, too).
We ask you to respect the time and effort put in by these humans by not sending low-effort, unqualified work, since it puts the burden of validation on the maintainer.
Therefore, the pretix project has strict rules for AI usage:
- **All AI usage in any form must be disclosed.** You must state the tool you used (e.g. Claude Code, Cursor, Amp) along with the extent that the work was AI-assisted.
- **The human-in-the-loop must fully understand all code.** If you can't explain what your changes do and how they interact with the greater system without the aid of AI tools, do not contribute to this project.
- **Issues and discussions can use AI assistance but must have a full human-in-the-loop.** This means that any content generated with AI must have been reviewed and edited by a human before submission. AI is very good at being overly verbose and including noise that distracts from the main point. Humans must do their research and trim this down.
- **No AI-generated media is allowed (art, images, videos, audio, etc.).** Text and code are the only acceptable AI-generated content, per the other rules in this policy.
- **Bad AI drivers will be excluded from the project.** People who produce bad contributions that are clearly AI (slop) will be blocked from our organization without warning.
This policy was inspired by the `ghostty project`_.
.. _ghostty project: https://github.com/ghostty-org/ghostty/blob/main/AI_POLICY.md
+11 -30
View File
@@ -1,39 +1,23 @@
Contribution workflow General remarks
===================== ===============
You are interested in contributing to pretix? That is awesome! You are interested in contributing to pretix? That is awesome!
If youre new to contributing to open source software, dont be afraid. Well happily review your code and give you If youre new to contributing to open source software, dont be afraid. Well happily review your code and give you
constructive and friendly feedback on your changes. Every contribution should go through the following steps. constructive and friendly feedback on your changes.
Discussion & Design First of all, you'll need pretix running locally on your machine. Head over to :ref:`devsetup` to learn how to do this.
-------------------
pretix is a large and mature project with more of a decade of history and hopefully many more decades to come.
Keeping pretix in good shape over long timeframes is first and foremost a fight against complexity.
With every additional feature, complexity grows, and both features and complexity are hard to remove.
Even if you are doing the initial work of the contribution, accepting the contribution is not free for us.
Not only will we need to maintain the feature, but every feature adds cost to the maintenance of every other feature it interacts with, and every feature adds effort for users to understand how pretix works.
Therefore, we must carefully select what features we add, based on how well they fit the system in general and of how much use they will be to our larger user base.
We strongly ask you to **create a discussion on GitHub for every new feature idea** outlining the use case and the proposed implementation design.
Pull requests without prior discussion will likely just be closed.
For bug fixes and very minor changes, you can skip this step and open a PR right away.
Development
-----------
To develop your contribution, you'll need pretix running locally on your machine. Head over to :ref:`devsetup` to learn how to do this.
If you run into any problems on your way, please do not hesitate to ask us anytime! If you run into any problems on your way, please do not hesitate to ask us anytime!
While developing, please have a look at our :ref:`aipolicy` and our guidelines on :ref:`codestyle`. Please note that we bound ourselves to a :ref:`coc` that applies to all communication around the project. You can be
assured that we will not tolerate any form of harassment.
Sending a patch Sending a patch
--------------- ---------------
Once you have a first draft of your changes, please `create a pull request`_ on our `GitHub repository`_. If you improved pretix in any way, we'd be very happy if you contribute it
back to the main code base! The easiest way to do so is to `create a pull request`_
on our `GitHub repository`_.
We recommend that you create a feature branch for every issue you work on so the changes can We recommend that you create a feature branch for every issue you work on so the changes can
be reviewed individually. be reviewed individually.
@@ -41,17 +25,14 @@ Please use the test suite to check whether your changes break any existing featu
the code style checks to confirm you are consistent with pretix's coding style. You'll the code style checks to confirm you are consistent with pretix's coding style. You'll
find instructions on this in the :ref:`checksandtests` section of the development setup guide. find instructions on this in the :ref:`checksandtests` section of the development setup guide.
We automatically run the tests and the code style check on every pull request through GitHub Actions and we wont We automatically run the tests and the code style check on every pull request on Travis CI and we wont
accept any pull requests without all tests passing. However, if you don't find out *why* they are not passing, accept any pull requests without all tests passing. However, if you don't find out *why* they are not passing,
just send the pull request and tell us we'll be glad to help. just send the pull request and tell us we'll be glad to help.
If you add a new feature, please include appropriate documentation into your patch. If you fix a bug, If you add a new feature, please include appropriate documentation into your patch. If you fix a bug,
please include a regression test, i.e. a test that fails without your changes and passes after applying your changes. please include a regression test, i.e. a test that fails without your changes and passes after applying your changes.
Again: If you get stuck, do not hesitate to contact us through GitHub discussions. Again: If you get stuck, do not hesitate to contact any of us, or Raphael personally at mail@raphaelmichel.de.
Please note that we bound ourselves to a :ref:`coc` that applies to all communication around the project. You can be
assured that we will not tolerate any form of harassment.
.. _create a pull request: https://help.github.com/articles/creating-a-pull-request/ .. _create a pull request: https://help.github.com/articles/creating-a-pull-request/
.. _GitHub repository: https://github.com/pretix/pretix .. _GitHub repository: https://github.com/pretix/pretix
-1
View File
@@ -6,5 +6,4 @@ Contributing to pretix
general general
style style
ai
codeofconduct codeofconduct
+2 -2
View File
@@ -1,7 +1,5 @@
.. spelling:word-list:: Rebase rebasing .. spelling:word-list:: Rebase rebasing
.. _`codestyle`:
Coding style and quality Coding style and quality
======================== ========================
@@ -30,6 +28,8 @@ Code
Commits and Pull Requests Commits and Pull Requests
------------------------- -------------------------
Most commits should start as pull requests, therefore this applies to the titles of pull requests as well since Most commits should start as pull requests, therefore this applies to the titles of pull requests as well since
the pull request title will become the commit message on merge. We prefer merging with GitHub's "Squash and merge" the pull request title will become the commit message on merge. We prefer merging with GitHub's "Squash and merge"
feature if the PR contains multiple commits that do not carry value to keep. If there is value in keeping the feature if the PR contains multiple commits that do not carry value to keep. If there is value in keeping the
+1 -1
View File
@@ -196,7 +196,7 @@ A simple implementation could look like this:
.. code-block:: python .. code-block:: python
class MyNotificationType(NotificationType): class MyNotificationType(NotificationType):
required_permission = "event.orders:read" required_permission = "can_view_orders"
action_type = "pretix.event.order.paid" action_type = "pretix.event.order.paid"
verbose_name = _("Order has been paid") verbose_name = _("Order has been paid")
+18 -71
View File
@@ -2,7 +2,7 @@ Permissions
=========== ===========
pretix uses a fine-grained permission system to control who is allowed to control what parts of the system. pretix uses a fine-grained permission system to control who is allowed to control what parts of the system.
The central concept here is the concept of *Teams*. You can read more on `configuring teams and permissions`_ The central concept here is the concept of *Teams*. You can read more on `configuring teams and permissions <user-teams>`_
and the :class:`pretix.base.models.Team` model in the respective parts of the documentation. The basic digest is: 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 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. 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): class MyOrgaView(OrganizerPermissionRequiredMixin, View):
permission = 'organizer.settings.general:write' permission = 'can_change_organizer_settings'
# Only users with the permission ``organizer.settings.general:write`` on # Only users with the permission ``can_change_organizer_settings`` on
# this organizer can access this # 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 # Only users with *any* permission on this organizer can access this
@organizer_permission_required('organizer.settings.general:write') @organizer_permission_required('can_change_organizer_settings')
def my_orga_view(request, organizer, **kwargs): def my_orga_view(request, organizer, **kwargs):
# Only users with the permission ``organizer.settings.general:write`` on # Only users with the permission ``can_change_organizer_settings`` on
# this organizer can access this # this organizer can access this
@@ -56,8 +56,8 @@ Of course, the same is available on event level:
class MyEventView(EventPermissionRequiredMixin, View): class MyEventView(EventPermissionRequiredMixin, View):
permission = 'event.settings.general:write' permission = 'can_change_event_settings'
# Only users with the permission ``event.settings.general:write`` on # Only users with the permission ``can_change_event_settings`` on
# this event can access this # this event can access this
@@ -65,16 +65,13 @@ Of course, the same is available on event level:
permission = None permission = None
# Only users with *any* permission on this event can access this # 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('event.settings.general:write') @event_permission_required('can_change_event_settings')
def my_event_view(request, organizer, **kwargs): def my_event_view(request, organizer, **kwargs):
# Only users with the permission ``event.settings.general:write`` on # Only users with the permission ``can_change_event_settings`` on
# this event can access this # this event can access this
@event_permission_required() @event_permission_required()
def my_other_event_view(request, organizer, **kwargs): def my_other_event_view(request, organizer, **kwargs):
# Only users with *any* permission on this event can access this # Only users with *any* permission on this event can access this
@@ -124,7 +121,7 @@ When creating your own ``viewset`` using Django REST framework, you just need to
and pretix will check it automatically for you:: and pretix will check it automatically for you::
class MyModelViewSet(viewsets.ReadOnlyModelViewSet): class MyModelViewSet(viewsets.ReadOnlyModelViewSet):
permission = 'event.orders:read' permission = 'can_view_orders'
Checking permission in code Checking permission in code
--------------------------- ---------------------------
@@ -139,12 +136,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:: Return all users that are in a team with a specific permission for this event::
>>> event.get_users_with_permission('event.orders:read') >>> event.get_users_with_permission('can_change_event_settings')
<QuerySet: …> <QuerySet: …>
Determine if a user has a certain permission for a specific event:: Determine if a user has a certain permission for a specific event::
>>> user.has_event_permission(organizer, event, 'event.orders:read', request=request) >>> user.has_event_permission(organizer, event, 'can_change_event_settings', request=request)
True True
Determine if a user has any permission for a specific event:: Determine if a user has any permission for a specific event::
@@ -156,27 +153,27 @@ In the two previous commands, the ``request`` argument is optional, but required
The same method exists for organizer-level permissions:: The same method exists for organizer-level permissions::
>>> user.has_organizer_permission(organizer, 'event.orders:read', request=request) >>> user.has_organizer_permission(organizer, 'can_change_event_settings', request=request)
True True
Sometimes, it might be more useful to get the set of permissions at once:: Sometimes, it might be more useful to get the set of permissions at once::
>>> user.get_event_permission_set(organizer, event) >>> user.get_event_permission_set(organizer, event)
{'event.settings.general:write', 'event.orders:read', 'event.orders:write'} {'can_change_event_settings', 'can_view_orders', 'can_change_orders'}
>>> user.get_organizer_permission_set(organizer, event) >>> user.get_organizer_permission_set(organizer, event)
{'organizer.settings.general:write', 'organizer.events:create'} {'can_change_organizer_settings', 'can_create_events'}
Within a view on the ``/control`` subpath, the results of these two methods are already available in the 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:: ``request.eventpermset`` and ``request.orgapermset`` properties. This makes it convenient to query them in templates::
{% if "event.orders:write" in request.eventpermset %} {% if "can_change_orders" in request.eventpermset %}
{% endif %} {% endif %}
You can also do the reverse to get any events a user has access to:: You can also do the reverse to get any events a user has access to::
>>> user.get_events_with_permission('event.settings.general:write', request=request) >>> user.get_events_with_permission('can_change_event_settings', request=request)
<QuerySet: …> <QuerySet: …>
>>> user.get_events_with_any_permission(request=request) >>> user.get_events_with_any_permission(request=request)
@@ -198,53 +195,3 @@ 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, 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 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. 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/
+18 -19
View File
@@ -3,7 +3,7 @@ name = "pretix"
dynamic = ["version"] dynamic = ["version"]
description = "Reinventing presales, one ticket at a time" description = "Reinventing presales, one ticket at a time"
readme = "README.rst" readme = "README.rst"
requires-python = ">=3.11" requires-python = ">=3.10"
license = {file = "LICENSE"} license = {file = "LICENSE"}
keywords = ["tickets", "web", "shop", "ecommerce"] keywords = ["tickets", "web", "shop", "ecommerce"]
authors = [ authors = [
@@ -19,11 +19,10 @@ classifiers = [
"Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
"Environment :: Web Environment", "Environment :: Web Environment",
"License :: OSI Approved :: GNU Affero General Public License v3", "License :: OSI Approved :: GNU Affero General Public License v3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Framework :: Django :: 4.2",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Framework :: Django :: 5.2",
] ]
dependencies = [ dependencies = [
@@ -33,11 +32,11 @@ dependencies = [
"bleach==6.3.*", "bleach==6.3.*",
"celery==5.6.*", "celery==5.6.*",
"chardet==5.2.*", "chardet==5.2.*",
"cryptography>=47.0.0", "cryptography>=44.0.0",
"css-inline==0.20.*", "css-inline==0.20.*",
"defusedcsv>=3.0.0", "defusedcsv>=1.1.0",
"dnspython==2.*", "dnspython==2.*",
"Django[argon2]==5.2.*", "Django[argon2]==4.2.*,>=4.2.26",
"django-bootstrap3==26.1", "django-bootstrap3==26.1",
"django-compressor==4.6.0", "django-compressor==4.6.0",
"django-countries==8.2.*", "django-countries==8.2.*",
@@ -55,12 +54,12 @@ dependencies = [
"django-phonenumber-field==8.4.*", "django-phonenumber-field==8.4.*",
"django-redis==6.0.*", "django-redis==6.0.*",
"django-scopes==2.0.*", "django-scopes==2.0.*",
"django-statici18n==2.7.*", "django-statici18n==2.6.*",
"djangorestframework==3.16.*", "djangorestframework==3.16.*",
"dnspython==2.8.*", "dnspython==2.8.*",
"drf_ujson2==1.7.*", "drf_ujson2==1.7.*",
"geoip2==5.*", "geoip2==5.*",
"importlib_metadata==9.*", # Polyfill, we can probably drop this once we require Python 3.10+ "importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+
"isoweek", "isoweek",
"jsonschema", "jsonschema",
"kombu==5.6.*", "kombu==5.6.*",
@@ -74,11 +73,11 @@ dependencies = [
"packaging", "packaging",
"paypalrestsdk==1.13.*", "paypalrestsdk==1.13.*",
"paypal-checkout-serversdk==1.0.*", "paypal-checkout-serversdk==1.0.*",
"PyJWT==2.12.*", "PyJWT==2.11.*",
"phonenumberslite==9.0.*", "phonenumberslite==9.0.*",
"Pillow==12.2.*", "Pillow==12.1.*",
"pretix-plugin-build", "pretix-plugin-build",
"protobuf==7.34.*", "protobuf==6.33.*",
"psycopg2-binary", "psycopg2-binary",
"pycountry", "pycountry",
"pycparser==3.0", "pycparser==3.0",
@@ -90,14 +89,14 @@ dependencies = [
"pytz-deprecation-shim==0.1.*", "pytz-deprecation-shim==0.1.*",
"pyuca", "pyuca",
"qrcode==8.2", "qrcode==8.2",
"redis==7.4.*", "redis==7.1.*",
"reportlab==4.4.*", "reportlab==4.4.*",
"requests==2.32.*", "requests==2.32.*",
"sentry-sdk==2.58.*", "sentry-sdk==2.52.*",
"sepaxml==2.7.*", "sepaxml==2.7.*",
"stripe==7.9.*", "stripe==7.9.*",
"text-unidecode==1.*", "text-unidecode==1.*",
"tlds>=2026041800", "tlds>=2020041600",
"tqdm==4.*", "tqdm==4.*",
"ua-parser==1.0.*", "ua-parser==1.0.*",
"vobject==0.9.*", "vobject==0.9.*",
@@ -111,13 +110,13 @@ dev = [
"aiohttp==3.13.*", "aiohttp==3.13.*",
"coverage", "coverage",
"coveralls", "coveralls",
"fakeredis==2.34.*", "fakeredis==2.33.*",
"flake8==7.3.*", "flake8==7.3.*",
"freezegun", "freezegun",
"isort==8.0.*", "isort==7.0.*",
"pep8-naming==0.15.*", "pep8-naming==0.15.*",
"potypo", "potypo",
"pytest-asyncio>=1.3.0", "pytest-asyncio>=0.24",
"pytest-cache", "pytest-cache",
"pytest-cov", "pytest-cov",
"pytest-django==4.*", "pytest-django==4.*",
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # 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/>. # <https://www.gnu.org/licenses/>.
# #
__version__ = "2026.5.0.dev0" __version__ = "2026.2.0.dev0"
+6 -8
View File
@@ -36,9 +36,7 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission
from pretix.api.models import OAuthAccessToken from pretix.api.models import OAuthAccessToken
from pretix.base.models import Device, Event, User from pretix.base.models import Device, Event, User
from pretix.base.models.auth import ( from pretix.base.models.auth import SuperuserPermissionSet
EventPermissionSet, OrganizerPermissionSet, SuperuserPermissionSet,
)
from pretix.base.models.organizer import TeamAPIToken from pretix.base.models.organizer import TeamAPIToken
from pretix.helpers.security import ( from pretix.helpers.security import (
Session2FASetupRequired, SessionInvalid, SessionPasswordChangeRequired, Session2FASetupRequired, SessionInvalid, SessionPasswordChangeRequired,
@@ -87,7 +85,7 @@ class EventPermission(BasePermission):
if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key): if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key):
request.eventpermset = SuperuserPermissionSet() request.eventpermset = SuperuserPermissionSet()
else: else:
request.eventpermset = EventPermissionSet(perm_holder.get_event_permission_set(request.organizer, request.event)) request.eventpermset = perm_holder.get_event_permission_set(request.organizer, request.event)
if isinstance(required_permission, (list, tuple)): if isinstance(required_permission, (list, tuple)):
if not any(p in request.eventpermset for p in required_permission): if not any(p in request.eventpermset for p in required_permission):
@@ -102,7 +100,7 @@ class EventPermission(BasePermission):
if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key): if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key):
request.orgapermset = SuperuserPermissionSet() request.orgapermset = SuperuserPermissionSet()
else: else:
request.orgapermset = OrganizerPermissionSet(perm_holder.get_organizer_permission_set(request.organizer)) request.orgapermset = perm_holder.get_organizer_permission_set(request.organizer)
if isinstance(required_permission, (list, tuple)): if isinstance(required_permission, (list, tuple)):
if not any(p in request.eventpermset for p in required_permission): if not any(p in request.eventpermset for p in required_permission):
@@ -126,12 +124,12 @@ class EventCRUDPermission(EventPermission):
def has_permission(self, request, view): def has_permission(self, request, view):
if not super(EventCRUDPermission, self).has_permission(request, view): if not super(EventCRUDPermission, self).has_permission(request, view):
return False return False
elif view.action == 'create' and 'organizer.events:create' not in request.orgapermset: elif view.action == 'create' and 'can_create_events' not in request.orgapermset:
return False return False
elif view.action == 'destroy' and 'event.settings.general:write' not in request.eventpermset: elif view.action == 'destroy' and 'can_change_event_settings' not in request.eventpermset:
return False return False
elif view.action in ['update', 'partial_update'] \ elif view.action in ['update', 'partial_update'] \
and 'event.settings.general:write' not in request.eventpermset: and 'can_change_event_settings' not in request.eventpermset:
return False return False
return True return True
+9 -12
View File
@@ -300,7 +300,7 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
def ignored_meta_properties(self): def ignored_meta_properties(self):
perm_holder = (self.context['request'].auth if isinstance(self.context['request'].auth, (Device, TeamAPIToken)) perm_holder = (self.context['request'].auth if isinstance(self.context['request'].auth, (Device, TeamAPIToken))
else self.context['request'].user) else self.context['request'].user)
if perm_holder.has_organizer_permission(self.context['request'].organizer, 'organizer.settings.general:write', request=self.context['request']): if perm_holder.has_organizer_permission(self.context['request'].organizer, 'can_change_organizer_settings', request=self.context['request']):
return [] return []
return [k for k, p in self.meta_properties.items() if p.protected] 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) date_admission = validated_data.pop('date_admission', None)
new_event = super().create({**validated_data, 'plugins': None}) new_event = super().create({**validated_data, 'plugins': None})
event = self.context['event'] event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first()
new_event.copy_data_from(event, skip_meta_data='meta_data' in validated_data) new_event.copy_data_from(event, skip_meta_data='meta_data' in validated_data)
if plugins is not None: if plugins is not None:
@@ -561,7 +561,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
def ignored_meta_properties(self): def ignored_meta_properties(self):
perm_holder = (self.context['request'].auth if isinstance(self.context['request'].auth, (Device, TeamAPIToken)) perm_holder = (self.context['request'].auth if isinstance(self.context['request'].auth, (Device, TeamAPIToken))
else self.context['request'].user) else self.context['request'].user)
if perm_holder.has_organizer_permission(self.context['request'].organizer, 'organizer.settings.general:write', request=self.context['request']): if perm_holder.has_organizer_permission(self.context['request'].organizer, 'can_change_organizer_settings', request=self.context['request']):
return [] return []
return [k for k, p in self.meta_properties.items() if p.protected] return [k for k, p in self.meta_properties.items() if p.protected]
@@ -707,10 +707,7 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
class EventSettingsSerializer(SettingsSerializer): class EventSettingsSerializer(SettingsSerializer):
default_write_permission = 'event.settings.general:write'
default_fields = [ 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', 'imprint_url',
'checkout_email_helptext', 'checkout_email_helptext',
'presale_has_ended_text', 'presale_has_ended_text',
@@ -1083,16 +1080,16 @@ class SeatSerializer(I18nAwareModelSerializer):
def prefetch_expanded_data(self, items, request, expand_fields): def prefetch_expanded_data(self, items, request, expand_fields):
if 'orderposition' in expand_fields: if 'orderposition' in expand_fields:
if 'event.orders:read' not in request.eventpermset: if 'can_view_orders' not in request.eventpermset:
raise PermissionDenied('event.orders:read permission required for expand=orderposition') raise PermissionDenied('can_view_orders permission required for expand=orderposition')
prefetch_by_id(items, OrderPosition.objects.prefetch_related('order'), 'orderposition_id', 'orderposition') prefetch_by_id(items, OrderPosition.objects.prefetch_related('order'), 'orderposition_id', 'orderposition')
if 'cartposition' in expand_fields: if 'cartposition' in expand_fields:
if 'event.orders:read' not in request.eventpermset: if 'can_view_orders' not in request.eventpermset:
raise PermissionDenied('event.orders:read permission required for expand=cartposition') raise PermissionDenied('can_view_orders permission required for expand=cartposition')
prefetch_by_id(items, CartPosition.objects, 'cartposition_id', 'cartposition') prefetch_by_id(items, CartPosition.objects, 'cartposition_id', 'cartposition')
if 'voucher' in expand_fields: if 'voucher' in expand_fields:
if 'event.vouchers:read' not in request.eventpermset: if 'can_view_vouchers' not in request.eventpermset:
raise PermissionDenied('event.vouchers:read permission required for expand=voucher') raise PermissionDenied('can_view_vouchers permission required for expand=voucher')
prefetch_by_id(items, Voucher.objects, 'voucher_id', 'voucher') prefetch_by_id(items, Voucher.objects, 'voucher_id', 'voucher')
def __init__(self, instance, *args, **kwargs): def __init__(self, instance, *args, **kwargs):
+7 -33
View File
@@ -27,9 +27,7 @@ from rest_framework.exceptions import ValidationError
from pretix.api.serializers.forms import form_field_to_serializer_field from pretix.api.serializers.forms import form_field_to_serializer_field
from pretix.base.exporter import OrganizerLevelExportMixin from pretix.base.exporter import OrganizerLevelExportMixin
from pretix.base.models import ( from pretix.base.models import ScheduledEventExport, ScheduledOrganizerExport
Event, ScheduledEventExport, ScheduledOrganizerExport,
)
from pretix.base.timeframes import SerializerDateFrameField from pretix.base.timeframes import SerializerDateFrameField
@@ -56,29 +54,20 @@ class ExporterSerializer(serializers.Serializer):
class JobRunSerializer(serializers.Serializer): class JobRunSerializer(serializers.Serializer):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
ex = self.ex = kwargs.pop('exporter') ex = kwargs.pop('exporter')
events = kwargs.pop('events', None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if ex.is_multievent and not isinstance(ex, OrganizerLevelExportMixin): if events is not None and not isinstance(ex, OrganizerLevelExportMixin):
self.fields["all_events"] = serializers.BooleanField(
required=False,
)
self.fields["events"] = serializers.SlugRelatedField( self.fields["events"] = serializers.SlugRelatedField(
queryset=ex.events, queryset=events,
required=False, required=False,
allow_empty=True, allow_empty=False,
slug_field='slug', slug_field='slug',
many=True many=True
) )
for k, v in ex.export_form_fields.items(): for k, v in ex.export_form_fields.items():
self.fields[k] = form_field_to_serializer_field(v) 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): def to_internal_value(self, data):
if isinstance(data, QueryDict): if isinstance(data, QueryDict):
data = data.copy() data = data.copy()
@@ -106,14 +95,6 @@ class JobRunSerializer(serializers.Serializer):
data[fk] = f'{d_from.isoformat() if d_from else ""}/{d_to.isoformat() if d_to else ""}' data[fk] = f'{d_from.isoformat() if d_from else ""}/{d_to.isoformat() if d_to else ""}'
data = super().to_internal_value(data) 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 return data
def is_valid(self, raise_exception=False): def is_valid(self, raise_exception=False):
@@ -150,20 +131,13 @@ class ScheduledExportSerializer(serializers.ModelSerializer):
exporter = self.context['exporters'].get(identifier) exporter = self.context['exporters'].get(identifier)
if exporter: if exporter:
try: try:
attrs["export_form_data"] = JobRunSerializer(exporter=exporter).to_internal_value(attrs["export_form_data"]) JobRunSerializer(exporter=exporter).to_internal_value(attrs["export_form_data"])
except ValidationError as e: except ValidationError as e:
raise ValidationError({"export_form_data": e.detail}) raise ValidationError({"export_form_data": e.detail})
else: else:
raise ValidationError({"export_identifier": ["Unknown exporter."]}) raise ValidationError({"export_identifier": ["Unknown exporter."]})
return attrs 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): def validate_mail_additional_recipients(self, value):
d = value.replace(' ', '') d = value.replace(' ', '')
if len(d.split(',')) > 25: if len(d.split(',')) > 25:
+1 -2
View File
@@ -65,9 +65,8 @@ def form_field_to_serializer_field(field):
if isinstance(field, m_from): if isinstance(field, m_from):
return m_to( return m_to(
required=field.required, required=field.required,
allow_null=not field.required and not isinstance(field, forms.BooleanField), allow_null=not field.required,
validators=field.validators, validators=field.validators,
initial=field.initial,
**{kwarg: getattr(field, kwarg, None) for kwarg in m_kwargs} **{kwarg: getattr(field, kwarg, None) for kwarg in m_kwargs}
) )
+2 -2
View File
@@ -191,7 +191,7 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer):
class InlineItemProgramTimeSerializer(serializers.ModelSerializer): class InlineItemProgramTimeSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ItemProgramTime model = ItemProgramTime
fields = ('start', 'end', 'location') fields = ('start', 'end')
class ItemBundleSerializer(serializers.ModelSerializer): class ItemBundleSerializer(serializers.ModelSerializer):
@@ -222,7 +222,7 @@ class ItemBundleSerializer(serializers.ModelSerializer):
class ItemProgramTimeSerializer(serializers.ModelSerializer): class ItemProgramTimeSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ItemProgramTime model = ItemProgramTime
fields = ('id', 'start', 'end', 'location') fields = ('id', 'start', 'end')
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
+2 -32
View File
@@ -24,16 +24,14 @@ from decimal import Decimal
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import OrderPositionSerializer from pretix.api.serializers.order import OrderPositionSerializer
from pretix.api.serializers.organizer import ( from pretix.api.serializers.organizer import (
CustomerSerializer, GiftCardSerializer, CustomerSerializer, GiftCardSerializer,
) )
from pretix.base.models import ( from pretix.base.models import Order, OrderPosition, ReusableMedium
Device, Order, OrderPosition, ReusableMedium, TeamAPIToken,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -68,9 +66,6 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'): 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) self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True, context=self.context)
if 'linked_giftcard.owner_ticket' in self.context['request'].query_params.getlist('expand'): 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) self.fields['linked_giftcard'].fields['owner_ticket'] = NestedOrderPositionSerializer(read_only=True, context=self.context)
@@ -82,7 +77,6 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
) )
if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'): if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'):
# Permission Check performed in to_representation
self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True) self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True)
else: else:
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField( self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
@@ -92,9 +86,6 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
) )
if 'customer' in self.context['request'].query_params.getlist('expand'): 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) self.fields['customer'] = CustomerSerializer(read_only=True)
else: else:
self.fields['customer'] = serializers.SlugRelatedField( self.fields['customer'] = serializers.SlugRelatedField(
@@ -118,27 +109,6 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
) )
return data return data
def to_representation(self, instance):
r = super().to_representation(instance)
request = self.context.get('request')
# late permission evaluations for checks that depend on the actual linked events
expand_nested = self.context['request'].query_params.getlist('expand')
perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user
if 'linked_orderposition' in expand_nested:
if instance.linked_orderposition is not None:
event = instance.linked_orderposition.order.event
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
r['linked_orderposition'] = {'id': instance.linked_orderposition.id}
if 'linked_giftcard.owner_ticket' in expand_nested:
gc = instance.linked_giftcard
if gc is not None and gc.owner_ticket is not None:
event = gc.owner_ticket.order.event
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
r['linked_giftcard']['owner_ticket'] = {'id': instance.linked_giftcard.owner_ticket.id}
return r
class Meta: class Meta:
model = ReusableMedium model = ReusableMedium
fields = ( fields = (
+9 -86
View File
@@ -19,7 +19,6 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # 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/>. # <https://www.gnu.org/licenses/>.
# #
import json
import logging import logging
import os import os
from collections import Counter, defaultdict from collections import Counter, defaultdict
@@ -53,7 +52,7 @@ from pretix.base.decimal import round_decimal
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.invoicing.transmission import get_transmission_types from pretix.base.invoicing.transmission import get_transmission_types
from pretix.base.models import ( from pretix.base.models import (
CachedFile, Checkin, Customer, Device, GiftCard, Invoice, InvoiceAddress, CachedFile, Checkin, Customer, Device, Invoice, InvoiceAddress,
InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question, InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule, QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule,
Voucher, Voucher,
@@ -62,7 +61,6 @@ from pretix.base.models.orders import (
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund, BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
PrintLog, RevokedTicketSecret, Transaction, PrintLog, RevokedTicketSecret, Transaction,
) )
from pretix.base.payment import GiftCardPayment, PaymentException
from pretix.base.pdf import get_images, get_variables from pretix.base.pdf import get_images, get_variables
from pretix.base.services.cart import error_messages from pretix.base.services.cart import error_messages
from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects
@@ -615,7 +613,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
# /events/…/checkinlists/…/positions/ # /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 # 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. # layer to not set pdf_data=true in the first place.
request and hasattr(request, 'eventpermset') and 'event.orders:read' not in request.eventpermset request and hasattr(request, 'eventpermset') and 'can_view_orders' not in request.eventpermset
) )
if ('pdf_data' in self.context and not self.context['pdf_data']) or pdf_data_forbidden: if ('pdf_data' in self.context and not self.context['pdf_data']) or pdf_data_forbidden:
self.fields.pop('pdf_data', None) self.fields.pop('pdf_data', None)
@@ -638,14 +636,6 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
return entry return entry
class OrganizerOrderPositionSerializer(OrderPositionSerializer):
event = SlugRelatedField(slug_field='slug', read_only=True)
class Meta(OrderPositionSerializer.Meta):
fields = OrderPositionSerializer.Meta.fields + ('event',)
read_only_fields = OrderPositionSerializer.Meta.read_only_fields + ('event',)
class RequireAttentionField(serializers.Field): class RequireAttentionField(serializers.Field):
def to_representation(self, instance: OrderPosition): def to_representation(self, instance: OrderPosition):
return instance.require_checkin_attention return instance.require_checkin_attention
@@ -769,11 +759,7 @@ class PaymentDetailsField(serializers.Field):
pp = value.payment_provider pp = value.payment_provider
if not pp: if not pp:
return {} return {}
try: return pp.api_payment_details(value)
return pp.api_payment_details(value)
except Exception:
logger.exception("Failed to retrieve payment_details")
return {}
class OrderPaymentSerializer(I18nAwareModelSerializer): class OrderPaymentSerializer(I18nAwareModelSerializer):
@@ -1205,7 +1191,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
) )
tax_rounding_mode = serializers.ChoiceField(choices=ROUNDING_MODES, allow_null=True, required=False,) tax_rounding_mode = serializers.ChoiceField(choices=ROUNDING_MODES, allow_null=True, required=False,)
locale = serializers.ChoiceField(choices=[], required=False, allow_null=True) 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): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -1221,7 +1206,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel', fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date', 'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date',
'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at', 'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at',
'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode', 'use_gift_cards') 'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode')
def validate_payment_provider(self, pp): def validate_payment_provider(self, pp):
if pp is None: if pp is None:
@@ -1230,18 +1215,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
raise ValidationError('The given payment provider is not known.') raise ValidationError('The given payment provider is not known.')
return pp return pp
def validate_payment_info(self, info):
if info:
try:
obj = json.loads(info)
except ValueError:
raise ValidationError('payment_info must be valid JSON.')
if not isinstance(obj, dict):
# only objects are allowed
raise ValidationError('payment_info must be a JSON object.')
return info
def validate_expires(self, expires): def validate_expires(self, expires):
if expires < now(): if expires < now():
raise ValidationError('Expiration date must be in the future.') raise ValidationError('Expiration date must be in the future.')
@@ -1316,14 +1289,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
payment_date = validated_data.pop('payment_date', now()) payment_date = validated_data.pop('payment_date', now())
force = validated_data.pop('force', False) force = validated_data.pop('force', False)
simulate = validated_data.pop('simulate', 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"): if not validated_data.get("sales_channel"):
validated_data["sales_channel"] = self.context['event'].organizer.sales_channels.get(identifier="web") validated_data["sales_channel"] = self.context['event'].organizer.sales_channels.get(identifier="web")
@@ -1416,7 +1381,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
qa = QuotaAvailability() qa = QuotaAvailability()
qa.queue(*[q for q, d in quota_diff_for_locking.items() if d > 0]) qa.queue(*[q for q, d in quota_diff_for_locking.items() if d > 0])
qa.compute() qa.compute()
v_avail = {}
# These are not technically correct as diff use due to the time offset applied above, so let's prevent accidental # These are not technically correct as diff use due to the time offset applied above, so let's prevent accidental
# use further down # use further down
@@ -1446,13 +1410,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
voucher_usage[v] += 1 voucher_usage[v] += 1
if voucher_usage[v] > 0: if voucher_usage[v] > 0:
if v not in v_avail: redeemed_in_carts = CartPosition.objects.filter(
v.refresh_from_db(fields=['redeemed']) Q(voucher=pos_data['voucher']) & Q(event=self.context['event']) & Q(expires__gte=now_dt)
redeemed_in_carts = CartPosition.objects.filter( ).exclude(pk__in=[cp.pk for cp in delete_cps])
Q(voucher=v) & Q(event=self.context['event']) & Q(expires__gte=now_dt) v_avail = v.max_usages - v.redeemed - redeemed_in_carts.count()
).exclude(pk__in=[cp.pk for cp in delete_cps]) if v_avail < voucher_usage[v]:
v_avail[v] = v.max_usages - v.redeemed - redeemed_in_carts.count()
if v_avail[v] < voucher_usage[v]:
errs[i]['voucher'] = [ errs[i]['voucher'] = [
'The voucher has already been used the maximum number of times.' 'The voucher has already been used the maximum number of times.'
] ]
@@ -1811,45 +1773,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if order.total != Decimal('0.00') and order.event.currency == "XXX": if order.total != Decimal('0.00') and order.event.currency == "XXX":
raise ValidationError('Paid products not supported without a valid currency.') 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'): 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.status = Order.STATUS_PAID
order.save() order.save()
+6 -137
View File
@@ -45,19 +45,12 @@ from pretix.base.models import (
SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User, SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
) )
from pretix.base.models.seating import SeatingPlanLayoutValidator 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 ( from pretix.base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID, PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_ORGANIZER,
) )
from pretix.base.services.mail import mail from pretix.base.services.mail import mail
from pretix.base.settings import validate_organizer_settings 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.helpers.urls import build_absolute_uri as build_global_uri
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
@@ -286,19 +279,6 @@ class GiftCardSerializer(I18nAwareModelSerializer):
) )
return data return data
def to_representation(self, instance):
r = super().to_representation(instance)
request = self.context.get('request')
# late permission evaluations for checks that depend on the actual linked events
if 'owner_ticket' in self.context['request'].query_params.getlist('expand'):
owner_ticket = instance.owner_ticket
if owner_ticket:
event = owner_ticket.order.event
perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
r['owner_ticket'] = {'id': instance.owner_ticket.id}
return r
class Meta: class Meta:
model = GiftCard model = GiftCard
fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode', 'expires', 'conditions', 'owner_ticket', fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode', 'expires', 'conditions', 'owner_ticket',
@@ -326,128 +306,23 @@ class EventSlugField(serializers.SlugRelatedField):
return self.context['organizer'].events.all() 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): class TeamSerializer(serializers.ModelSerializer):
limit_events = EventSlugField(slug_field='slug', many=True) 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: class Meta:
model = Team model = Team
fields = ( fields = (
'id', 'name', 'require_2fa', 'all_events', 'limit_events', 'all_event_permissions', 'limit_event_permissions', 'id', 'name', 'require_2fa', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
'all_organizer_permissions', 'limit_organizer_permissions', 'can_change_event_settings', 'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings',
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_checkin_orders', 'can_view_vouchers', 'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
'can_change_vouchers', 'can_create_events', 'can_change_organizer_settings', 'can_change_teams', 'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers', 'can_manage_reusable_media'
'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): 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 = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data) 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'): if full_data.get('limit_events') and full_data.get('all_events'):
raise ValidationError('Do not set both limit_events and 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 return data
@@ -464,7 +339,7 @@ class DeviceSerializer(serializers.ModelSerializer):
created = serializers.DateTimeField(read_only=True) created = serializers.DateTimeField(read_only=True)
revoked = serializers.BooleanField(read_only=True) revoked = serializers.BooleanField(read_only=True)
initialized = serializers.DateTimeField(read_only=True) initialized = serializers.DateTimeField(read_only=True)
initialization_token = serializers.CharField(read_only=True) initialization_token = serializers.DateTimeField(read_only=True)
security_profile = serializers.ChoiceField(choices=[], required=False, default="full") security_profile = serializers.ChoiceField(choices=[], required=False, default="full")
class Meta: class Meta:
@@ -478,8 +353,6 @@ class DeviceSerializer(serializers.ModelSerializer):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['security_profile'].choices = [(k, v.verbose_name) for k, v in get_all_security_profiles().items()] 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): class TeamInviteSerializer(serializers.ModelSerializer):
@@ -492,10 +365,9 @@ class TeamInviteSerializer(serializers.ModelSerializer):
def _send_invite(self, instance): def _send_invite(self, instance):
mail( mail(
instance.email, instance.email,
_('Account invitation'), _('pretix account invitation'),
'pretixcontrol/email/invitation.txt', 'pretixcontrol/email/invitation.txt',
{ {
'instance': settings.PRETIX_INSTANCE_NAME,
'user': self, 'user': self,
'organizer': self.context['organizer'].name, 'organizer': self.context['organizer'].name,
'team': instance.team.name, 'team': instance.team.name,
@@ -564,10 +436,7 @@ class TeamMemberSerializer(serializers.ModelSerializer):
class OrganizerSettingsSerializer(SettingsSerializer): class OrganizerSettingsSerializer(SettingsSerializer):
default_write_permission = 'organizer.settings.general:write'
default_fields = [ 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',
'customer_accounts_native', 'customer_accounts_native',
'customer_accounts_link_by_email', 'customer_accounts_link_by_email',
-10
View File
@@ -37,8 +37,6 @@ logger = logging.getLogger(__name__)
class SettingsSerializer(serializers.Serializer): class SettingsSerializer(serializers.Serializer):
default_fields = [] default_fields = []
readonly_fields = [] readonly_fields = []
default_write_permission = 'organizer.settings.general:write'
write_permission_required = {}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.changed_data = [] self.changed_data = []
@@ -60,17 +58,9 @@ class SettingsSerializer(serializers.Serializer):
f._label = str(form_kwargs.get('label', fname)) f._label = str(form_kwargs.get('label', fname))
f._help_text = str(form_kwargs.get('help_text')) f._help_text = str(form_kwargs.get('help_text'))
f.parent = self f.parent = self
self.write_permission_required[fname] = DEFAULTS[fname].get('write_permission', self.default_write_permission)
self.fields[fname] = f self.fields[fname] = f
def validate(self, attrs): 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} return {k: v for k, v in attrs.items() if k not in self.readonly_fields}
def update(self, instance: HierarkeyProxy, validated_data): def update(self, instance: HierarkeyProxy, validated_data):
+1 -2
View File
@@ -67,7 +67,6 @@ orga_router.register(r'invoices', order.InvoiceViewSet)
orga_router.register(r'scheduled_exports', exporters.ScheduledOrganizerExportViewSet) orga_router.register(r'scheduled_exports', exporters.ScheduledOrganizerExportViewSet)
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters') orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
orga_router.register(r'transactions', order.OrganizerTransactionViewSet) orga_router.register(r'transactions', order.OrganizerTransactionViewSet)
orga_router.register(r'orderpositions', order.OrganizerOrderPositionViewSet, basename='orderpositions')
team_router = routers.DefaultRouter() team_router = routers.DefaultRouter()
team_router.register(r'members', organizer.TeamMemberViewSet) team_router.register(r'members', organizer.TeamMemberViewSet)
@@ -84,7 +83,7 @@ event_router.register(r'discounts', discount.DiscountViewSet)
event_router.register(r'quotas', item.QuotaViewSet) event_router.register(r'quotas', item.QuotaViewSet)
event_router.register(r'vouchers', voucher.VoucherViewSet) event_router.register(r'vouchers', voucher.VoucherViewSet)
event_router.register(r'orders', order.EventOrderViewSet) event_router.register(r'orders', order.EventOrderViewSet)
event_router.register(r'orderpositions', order.EventOrderPositionViewSet) event_router.register(r'orderpositions', order.OrderPositionViewSet)
event_router.register(r'transactions', order.TransactionViewSet) event_router.register(r'transactions', order.TransactionViewSet)
event_router.register(r'invoices', order.InvoiceViewSet) event_router.register(r'invoices', order.InvoiceViewSet)
event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets') event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets')
+2 -2
View File
@@ -52,8 +52,8 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
ordering = ('datetime',) ordering = ('datetime',)
ordering_fields = ('datetime', 'cart_id') ordering_fields = ('datetime', 'cart_id')
lookup_field = 'id' lookup_field = 'id'
permission = 'event.orders:read' permission = 'can_view_orders'
write_permission = 'event.orders:write' write_permission = 'can_change_orders'
def get_queryset(self): def get_queryset(self):
return CartPosition.objects.filter( return CartPosition.objects.filter(
+18 -23
View File
@@ -67,7 +67,6 @@ from pretix.base.models import (
Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken, Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken,
) )
from pretix.base.models.orders import PrintLog from pretix.base.models.orders import PrintLog
from pretix.base.permissions import AnyPermissionOf
from pretix.base.services.checkin import ( from pretix.base.services.checkin import (
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin, CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
) )
@@ -119,11 +118,11 @@ class CheckinListViewSet(viewsets.ModelViewSet):
def _get_permission_name(self, request): def _get_permission_name(self, request):
if request.path.endswith('/failed_checkins/'): if request.path.endswith('/failed_checkins/'):
return 'event.orders:checkin', 'event.orders:write' return 'can_checkin_orders', 'can_change_orders'
elif request.method in SAFE_METHODS: elif request.method in SAFE_METHODS:
return 'event.orders:read', 'event.orders:checkin', return 'can_view_orders', 'can_checkin_orders',
else: else:
return 'event.settings.general:write' return 'can_change_event_settings'
def get_queryset(self): def get_queryset(self):
qs = self.request.event.checkin_lists.prefetch_related( qs = self.request.event.checkin_lists.prefetch_related(
@@ -189,15 +188,11 @@ class CheckinListViewSet(viewsets.ModelViewSet):
clist = self.get_object() clist = self.get_object()
if serializer.validated_data.get('nonce'): if serializer.validated_data.get('nonce'):
if kwargs.get('position'): if kwargs.get('position'):
prev = kwargs['position'].all_checkins.filter( prev = kwargs['position'].all_checkins.filter(nonce=serializer.validated_data['nonce']).first()
nonce=serializer.validated_data['nonce'],
successful=False
).first()
else: else:
prev = clist.checkins.filter( prev = clist.checkins.filter(
nonce=serializer.validated_data['nonce'], nonce=serializer.validated_data['nonce'],
raw_barcode=serializer.validated_data['raw_barcode'], raw_barcode=serializer.validated_data['raw_barcode'],
successful=False
).first() ).first()
if prev: if prev:
# Ignore because nonce is already handled # Ignore because nonce is already handled
@@ -475,7 +470,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'event': op.order.event, 'event': op.order.event,
'pdf_data': pdf_data and ( 'pdf_data': pdf_data and (
user if user and user.is_authenticated else auth user if user and user.is_authenticated else auth
).has_event_permission(request.organizer, event, 'event.orders:read', request), ).has_event_permission(request.organizer, event, 'can_view_orders', request),
} }
common_checkin_args = dict( common_checkin_args = dict(
@@ -840,8 +835,8 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
} }
filterset_class = CheckinOrderPositionFilter filterset_class = CheckinOrderPositionFilter
permission = AnyPermissionOf('event.orders:read', 'event.orders:checkin') permission = ('can_view_orders', 'can_checkin_orders')
write_permission = AnyPermissionOf('event.orders:write', 'event.orders:checkin') write_permission = ('can_change_orders', 'can_checkin_orders')
def get_serializer_context(self): def get_serializer_context(self):
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
@@ -872,7 +867,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
expand=self.request.query_params.getlist('expand'), expand=self.request.query_params.getlist('expand'),
) )
if 'pk' not in self.request.resolver_match.kwargs and 'event.orders:read' not in self.request.eventpermset \ if 'pk' not in self.request.resolver_match.kwargs and 'can_view_orders' not in self.request.eventpermset \
and len(self.request.query_params.get('search', '')) < 3: and len(self.request.query_params.get('search', '')) < 3:
qs = qs.none() qs = qs.none()
@@ -921,9 +916,9 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
class CheckinRPCRedeemView(views.APIView): class CheckinRPCRedeemView(views.APIView):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
if isinstance(self.request.auth, (TeamAPIToken, Device)): if isinstance(self.request.auth, (TeamAPIToken, Device)):
events = self.request.auth.get_events_with_permission(('event.orders:write', 'event.orders:checkin')) events = self.request.auth.get_events_with_permission(('can_change_orders', 'can_checkin_orders'))
elif self.request.user.is_authenticated: elif self.request.user.is_authenticated:
events = self.request.user.get_events_with_permission(('event.orders:write', 'event.orders:checkin'), self.request).filter( events = self.request.user.get_events_with_permission(('can_change_orders', 'can_checkin_orders'), self.request).filter(
organizer=self.request.organizer organizer=self.request.organizer
) )
else: else:
@@ -991,9 +986,9 @@ class CheckinRPCSearchView(ListAPIView):
@cached_property @cached_property
def lists(self): def lists(self):
if isinstance(self.request.auth, (TeamAPIToken, Device)): if isinstance(self.request.auth, (TeamAPIToken, Device)):
events = self.request.auth.get_events_with_permission(('event.orders:read', 'event.orders:checkin')) events = self.request.auth.get_events_with_permission(('can_view_orders', 'can_checkin_orders'))
elif self.request.user.is_authenticated: elif self.request.user.is_authenticated:
events = self.request.user.get_events_with_permission(('event.orders:read', 'event.orders:checkin'), self.request).filter( events = self.request.user.get_events_with_permission(('can_view_orders', 'can_checkin_orders'), self.request).filter(
organizer=self.request.organizer organizer=self.request.organizer
) )
else: else:
@@ -1010,9 +1005,9 @@ class CheckinRPCSearchView(ListAPIView):
@cached_property @cached_property
def has_full_access_permission(self): def has_full_access_permission(self):
if isinstance(self.request.auth, (TeamAPIToken, Device)): if isinstance(self.request.auth, (TeamAPIToken, Device)):
events = self.request.auth.get_events_with_permission('event.orders:read') events = self.request.auth.get_events_with_permission('can_view_orders')
elif self.request.user.is_authenticated: elif self.request.user.is_authenticated:
events = self.request.user.get_events_with_permission('event.orders:read', self.request).filter( events = self.request.user.get_events_with_permission('can_view_orders', self.request).filter(
organizer=self.request.organizer organizer=self.request.organizer
) )
else: else:
@@ -1039,9 +1034,9 @@ class CheckinRPCSearchView(ListAPIView):
class CheckinRPCAnnulView(views.APIView): class CheckinRPCAnnulView(views.APIView):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
if isinstance(self.request.auth, (TeamAPIToken, Device)): if isinstance(self.request.auth, (TeamAPIToken, Device)):
events = self.request.auth.get_events_with_permission(('event.orders:write', 'event.orders:checkin')) events = self.request.auth.get_events_with_permission(('can_change_orders', 'can_checkin_orders'))
elif self.request.user.is_authenticated: elif self.request.user.is_authenticated:
events = self.request.user.get_events_with_permission(('event.orders:write', 'event.orders:checkin'), self.request).filter( events = self.request.user.get_events_with_permission(('can_change_orders', 'can_checkin_orders'), self.request).filter(
organizer=self.request.organizer organizer=self.request.organizer
) )
else: else:
@@ -1119,10 +1114,10 @@ class CheckinViewSet(viewsets.ReadOnlyModelViewSet):
filterset_class = CheckinFilter filterset_class = CheckinFilter
ordering = ('created', 'id') ordering = ('created', 'id')
ordering_fields = ('created', 'datetime', 'id',) ordering_fields = ('created', 'datetime', 'id',)
permission = 'event.orders:read' permission = 'can_view_orders'
def get_queryset(self): def get_queryset(self):
qs = Checkin.all.filter(list__event=self.request.event).select_related( qs = Checkin.all.filter().select_related(
"position", "position",
"device", "device",
) )
+1 -1
View File
@@ -57,7 +57,7 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
ordering_fields = ('id', 'position') ordering_fields = ('id', 'position')
ordering = ('position', 'id') ordering = ('position', 'id')
permission = None permission = None
write_permission = 'event.items:write' write_permission = 'can_change_items'
def get_queryset(self): def get_queryset(self):
return self.request.event.discounts.prefetch_related( return self.request.event.discounts.prefetch_related(
+15 -28
View File
@@ -281,11 +281,6 @@ class EventViewSet(viewsets.ModelViewSet):
new_event = serializer.save(organizer=self.request.organizer) new_event = serializer.save(organizer=self.request.organizer)
if copy_from: 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) new_event.copy_data_from(copy_from, skip_meta_data='meta_data' in serializer.validated_data)
if plugins is not None: if plugins is not None:
@@ -346,24 +341,15 @@ class CloneEventViewSet(viewsets.ModelViewSet):
lookup_field = 'slug' lookup_field = 'slug'
lookup_url_kwarg = 'event' lookup_url_kwarg = 'event'
http_method_names = ['post'] http_method_names = ['post']
write_permission = 'event.settings.general:write' write_permission = 'can_create_events'
def get_serializer_context(self): def get_serializer_context(self):
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
ctx['event'] = Event.objects.get(slug=self.kwargs['event'], organizer=self.request.organizer) ctx['event'] = self.kwargs['event']
ctx['organizer'] = self.request.organizer ctx['organizer'] = self.request.organizer
return ctx return ctx
def perform_create(self, serializer): 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.save(organizer=self.request.organizer)
serializer.instance.log_action( serializer.instance.log_action(
@@ -440,7 +426,7 @@ with scopes_disabled():
class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet): class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = SubEventSerializer serializer_class = SubEventSerializer
queryset = SubEvent.objects.none() queryset = SubEvent.objects.none()
write_permission = 'event.subevents:write' write_permission = 'can_change_event_settings'
filter_backends = (DjangoFilterBackend, TotalOrderingFilter) filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
ordering = ('date_from',) ordering = ('date_from',)
ordering_fields = ('id', 'date_from', 'last_modified') ordering_fields = ('id', 'date_from', 'last_modified')
@@ -560,7 +546,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet): class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = TaxRuleSerializer serializer_class = TaxRuleSerializer
queryset = TaxRule.objects.none() queryset = TaxRule.objects.none()
write_permission = 'event.settings.tax:write' write_permission = 'can_change_event_settings'
def get_queryset(self): def get_queryset(self):
return self.request.event.tax_rules.all() return self.request.event.tax_rules.all()
@@ -603,7 +589,7 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
class ItemMetaPropertiesViewSet(viewsets.ModelViewSet): class ItemMetaPropertiesViewSet(viewsets.ModelViewSet):
serializer_class = ItemMetaPropertiesSerializer serializer_class = ItemMetaPropertiesSerializer
queryset = ItemMetaProperty.objects.none() queryset = ItemMetaProperty.objects.none()
write_permission = 'event.settings.general:write' write_permission = 'can_change_event_settings'
def get_queryset(self): def get_queryset(self):
qs = self.request.event.item_meta_properties.all() qs = self.request.event.item_meta_properties.all()
@@ -650,18 +636,19 @@ class ItemMetaPropertiesViewSet(viewsets.ModelViewSet):
class EventSettingsView(views.APIView): class EventSettingsView(views.APIView):
permission = None permission = None
write_permission = 'event.settings.general:write' write_permission = 'can_change_event_settings'
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
if isinstance(request.auth, Device): if isinstance(request.auth, Device):
s = DeviceEventSettingsSerializer(instance=request.event.settings, event=request.event, context={ s = DeviceEventSettingsSerializer(instance=request.event.settings, event=request.event, context={
'request': request, 'permissions': request.eventpermset 'request': request
})
elif 'can_change_event_settings' in request.eventpermset:
s = EventSettingsSerializer(instance=request.event.settings, event=request.event, context={
'request': request
}) })
else: else:
s = EventSettingsSerializer(instance=request.event.settings, event=request.event, context={ raise PermissionDenied()
'request': request, 'permissions': request.eventpermset,
})
if 'explain' in request.GET: if 'explain' in request.GET:
return Response({ return Response({
fname: { fname: {
@@ -675,7 +662,7 @@ class EventSettingsView(views.APIView):
def patch(self, request, *wargs, **kwargs): def patch(self, request, *wargs, **kwargs):
s = EventSettingsSerializer(instance=request.event.settings, data=request.data, partial=True, s = EventSettingsSerializer(instance=request.event.settings, data=request.data, partial=True,
event=request.event, context={'request': request, 'permissions': request.eventpermset}) event=request.event, context={'request': request})
s.is_valid(raise_exception=True) s.is_valid(raise_exception=True)
with transaction.atomic(): with transaction.atomic():
s.save() s.save()
@@ -687,7 +674,7 @@ class EventSettingsView(views.APIView):
) )
s = EventSettingsSerializer( s = EventSettingsSerializer(
instance=request.event.settings, event=request.event, context={ instance=request.event.settings, event=request.event, context={
'request': request, 'permissions': request.eventpermset 'request': request
}) })
return Response(s.data) return Response(s.data)
@@ -714,7 +701,7 @@ class SeatFilter(FilterSet):
class SeatViewSet(ConditionalListView, viewsets.ModelViewSet): class SeatViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = SeatSerializer serializer_class = SeatSerializer
queryset = Seat.objects.none() queryset = Seat.objects.none()
write_permission = 'event.settings.general:write' write_permission = 'can_change_event_settings'
filter_backends = (DjangoFilterBackend, ) filter_backends = (DjangoFilterBackend, )
filterset_class = SeatFilter filterset_class = SeatFilter
+69 -98
View File
@@ -40,12 +40,12 @@ from pretix.api.serializers.exporters import (
) )
from pretix.base.exporter import OrganizerLevelExportMixin from pretix.base.exporter import OrganizerLevelExportMixin
from pretix.base.models import ( from pretix.base.models import (
CachedFile, Device, ScheduledEventExport, ScheduledOrganizerExport, CachedFile, Device, Event, ScheduledEventExport, ScheduledOrganizerExport,
TeamAPIToken, TeamAPIToken,
) )
from pretix.base.models.organizer import TeamQuerySet from pretix.base.services.export import export, multiexport
from pretix.base.services.export import ( from pretix.base.signals import (
export, init_event_exporters, init_organizer_exporters, multiexport, register_data_exporters, register_multievent_data_exporters,
) )
from pretix.helpers.http import ChunkBasedFileResponse from pretix.helpers.http import ChunkBasedFileResponse
@@ -111,7 +111,7 @@ class ExportersMixin:
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def run(self, *args, **kwargs): def run(self, *args, **kwargs):
instance = self.get_object() instance = self.get_object()
serializer = JobRunSerializer(exporter=instance, data=self.request.data) serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs())
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
cf = CachedFile(web_download=True) cf = CachedFile(web_download=True)
@@ -136,34 +136,27 @@ class ExportersMixin:
class EventExportersViewSet(ExportersMixin, viewsets.ViewSet): class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
permission = None permission = 'can_view_orders'
def get_serializer_kwargs(self):
return {}
@cached_property @cached_property
def exporters(self): 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 = [] 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)): for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
ex._serializer = JobRunSerializer(exporter=ex) ex._serializer = JobRunSerializer(exporter=ex)
exporters.append(ex) exporters.append(ex)
return exporters return exporters
def do_export(self, cf, instance, data): def do_export(self, cf, instance, data):
return export.apply_async(args=( return export.apply_async(args=(self.request.event.id, str(cf.id), instance.identifier, data))
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): class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
@@ -171,23 +164,47 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
@cached_property @cached_property
def exporters(self): 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 = [] 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)): for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
ex._serializer = JobRunSerializer(exporter=ex) ex._serializer = JobRunSerializer(exporter=ex, events=events)
exporters.append(ex) exporters.append(ex)
return exporters 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): def do_export(self, cf, instance, data):
return multiexport.apply_async(kwargs={ return multiexport.apply_async(kwargs={
'organizer': self.request.organizer.id, 'organizer': self.request.organizer.id,
'user': self.request.user.id if self.request.user and self.request.user.is_authenticated else None, 'user': self.request.user.id if self.request.user.is_authenticated else None,
'token': self.request.auth.pk if isinstance(self.request.auth, TeamAPIToken) 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, 'device': self.request.auth.pk if isinstance(self.request.auth, Device) else None,
'fileid': str(cf.id), 'fileid': str(cf.id),
@@ -205,11 +222,11 @@ class ScheduledExportersViewSet(viewsets.ModelViewSet):
class ScheduledEventExportViewSet(ScheduledExportersViewSet): class ScheduledEventExportViewSet(ScheduledExportersViewSet):
serializer_class = ScheduledEventExportSerializer serializer_class = ScheduledEventExportSerializer
queryset = ScheduledEventExport.objects.none() queryset = ScheduledEventExport.objects.none()
permission = None permission = 'can_view_orders'
def get_queryset(self): def get_queryset(self):
perm_holder = self.request.auth if isinstance(self.request.auth, (TeamAPIToken, Device)) else self.request.user 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, 'event.settings.general:write', if not perm_holder.has_event_permission(self.request.organizer, self.request.event, 'can_change_event_settings',
request=self.request): request=self.request):
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
qs = self.request.event.scheduled_exports.filter(owner=self.request.user) qs = self.request.event.scheduled_exports.filter(owner=self.request.user)
@@ -241,28 +258,11 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet):
@cached_property @cached_property
def exporters(self): def exporters(self):
exporters = list(init_event_exporters( responses = register_data_exporters.send(self.request.event)
event=self.request.event, exporters = [response(self.request.event, self.request.organizer) for r, response in responses if response]
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} return {e.identifier: e for e in exporters}
def perform_update(self, serializer): 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.save(event=self.request.event)
serializer.instance.compute_next_run() serializer.instance.compute_next_run()
serializer.instance.error_counter = 0 serializer.instance.error_counter = 0
@@ -291,7 +291,7 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet):
def get_queryset(self): def get_queryset(self):
perm_holder = self.request.auth if isinstance(self.request.auth, (TeamAPIToken, Device)) else self.request.user 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, 'organizer.settings.general:write', if not perm_holder.has_organizer_permission(self.request.organizer, 'can_change_organizer_settings',
request=self.request): request=self.request):
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
qs = self.request.organizer.scheduled_exports.filter(owner=self.request.user) qs = self.request.organizer.scheduled_exports.filter(owner=self.request.user)
@@ -321,55 +321,26 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet):
ctx['exporters'] = self.exporters ctx['exporters'] = self.exporters
return ctx 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 @cached_property
def exporters(self): def exporters(self):
exporters = list(init_organizer_exporters( responses = register_multievent_data_exporters.send(self.request.organizer)
organizer=self.request.organizer, exporters = [
user=self.request.user if self.request.user and self.request.user.is_authenticated else None, response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else self.events,
token=self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None, self.request.organizer)
device=self.request.auth if isinstance(self.request.auth, Device) else None, for r, response in responses if response
request=self.request, ]
))
return {e.identifier: e for e in exporters} return {e.identifier: e for e in exporters}
def perform_update(self, serializer): 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.save(organizer=self.request.organizer)
serializer.instance.compute_next_run() serializer.instance.compute_next_run()
serializer.instance.error_counter = 0 serializer.instance.error_counter = 0
+9 -9
View File
@@ -99,7 +99,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
ordering = ('position', 'id') ordering = ('position', 'id')
filterset_class = ItemFilter filterset_class = ItemFilter
permission = None permission = None
write_permission = 'event.items:write' write_permission = 'can_change_items'
def get_queryset(self): def get_queryset(self):
return self.request.event.items.select_related('tax_rule').prefetch_related( return self.request.event.items.select_related('tax_rule').prefetch_related(
@@ -163,7 +163,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
ordering_fields = ('id', 'position') ordering_fields = ('id', 'position')
ordering = ('id',) ordering = ('id',)
permission = None permission = None
write_permission = 'event.items:write' write_permission = 'can_change_items'
@cached_property @cached_property
def item(self): def item(self):
@@ -234,7 +234,7 @@ class ItemBundleViewSet(viewsets.ModelViewSet):
ordering_fields = ('id',) ordering_fields = ('id',)
ordering = ('id',) ordering = ('id',)
permission = None permission = None
write_permission = 'event.items:write' write_permission = 'can_change_items'
@cached_property @cached_property
def item(self): def item(self):
@@ -286,7 +286,7 @@ class ItemProgramTimeViewSet(viewsets.ModelViewSet):
ordering_fields = ('id',) ordering_fields = ('id',)
ordering = ('id',) ordering = ('id',)
permission = None permission = None
write_permission = 'event.items:write' write_permission = 'can_change_items'
@cached_property @cached_property
def item(self): def item(self):
@@ -339,7 +339,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
ordering_fields = ('id', 'position') ordering_fields = ('id', 'position')
ordering = ('id',) ordering = ('id',)
permission = None permission = None
write_permission = 'event.items:write' write_permission = 'can_change_items'
@cached_property @cached_property
def item(self): def item(self):
@@ -398,7 +398,7 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
ordering_fields = ('id', 'position') ordering_fields = ('id', 'position')
ordering = ('position', 'id') ordering = ('position', 'id')
permission = None permission = None
write_permission = 'event.items:write' write_permission = 'can_change_items'
def get_queryset(self): def get_queryset(self):
return self.request.event.categories.all() return self.request.event.categories.all()
@@ -453,7 +453,7 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
ordering_fields = ('id', 'position') ordering_fields = ('id', 'position')
ordering = ('position', 'id') ordering = ('position', 'id')
permission = None permission = None
write_permission = 'event.items:write' write_permission = 'can_change_items'
def get_queryset(self): def get_queryset(self):
return self.request.event.questions.prefetch_related('options').all() return self.request.event.questions.prefetch_related('options').all()
@@ -497,7 +497,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
ordering_fields = ('id', 'position') ordering_fields = ('id', 'position')
ordering = ('position',) ordering = ('position',)
permission = None permission = None
write_permission = 'event.items:write' write_permission = 'can_change_items'
def get_queryset(self): def get_queryset(self):
q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event) q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
@@ -564,7 +564,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
ordering_fields = ('id', 'size') ordering_fields = ('id', 'size')
ordering = ('id',) ordering = ('id',)
permission = None permission = None
write_permission = 'event.items:write' write_permission = 'can_change_items'
def get_queryset(self): def get_queryset(self):
return self.request.event.quotas.select_related('subevent').prefetch_related('items', 'variations').all() return self.request.event.quotas.select_related('subevent').prefetch_related('items', 'variations').all()
+2 -4
View File
@@ -62,8 +62,8 @@ with scopes_disabled():
class ReusableMediaViewSet(viewsets.ModelViewSet): class ReusableMediaViewSet(viewsets.ModelViewSet):
serializer_class = ReusableMediaSerializer serializer_class = ReusableMediaSerializer
queryset = ReusableMedium.objects.none() queryset = ReusableMedium.objects.none()
permission = 'organizer.reusablemedia:read' permission = 'can_manage_reusable_media'
write_permission = 'organizer.reusablemedia:write' write_permission = 'can_manage_reusable_media'
filter_backends = (DjangoFilterBackend, OrderingFilter) filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('-updated', '-id') ordering = ('-updated', '-id')
ordering_fields = ('created', 'updated', 'identifier', 'type', 'id') ordering_fields = ('created', 'updated', 'identifier', 'type', 'id')
@@ -95,8 +95,6 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
def get_serializer_context(self): def get_serializer_context(self):
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer 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 return ctx
@transaction.atomic() @transaction.atomic()
+59 -118
View File
@@ -57,10 +57,9 @@ from pretix.api.serializers.order import (
BlockedTicketSecretSerializer, InvoiceSerializer, OrderCreateSerializer, BlockedTicketSecretSerializer, InvoiceSerializer, OrderCreateSerializer,
OrderPaymentCreateSerializer, OrderPaymentSerializer, OrderPaymentCreateSerializer, OrderPaymentSerializer,
OrderPositionSerializer, OrderRefundCreateSerializer, OrderPositionSerializer, OrderRefundCreateSerializer,
OrderRefundSerializer, OrderSerializer, OrganizerOrderPositionSerializer, OrderRefundSerializer, OrderSerializer, OrganizerTransactionSerializer,
OrganizerTransactionSerializer, PriceCalcSerializer, PrintLogSerializer, PriceCalcSerializer, PrintLogSerializer, RevokedTicketSecretSerializer,
RevokedTicketSecretSerializer, SimulatedOrderSerializer, SimulatedOrderSerializer, TransactionSerializer,
TransactionSerializer,
) )
from pretix.api.serializers.orderchange import ( from pretix.api.serializers.orderchange import (
BlockNameSerializer, OrderChangeOperationSerializer, BlockNameSerializer, OrderChangeOperationSerializer,
@@ -317,7 +316,7 @@ class OrderViewSetMixin:
class OrganizerOrderViewSet(OrderViewSetMixin, viewsets.ReadOnlyModelViewSet): class OrganizerOrderViewSet(OrderViewSetMixin, viewsets.ReadOnlyModelViewSet):
def get_base_queryset(self): def get_base_queryset(self):
perm = "event.orders:read" if self.request.method in SAFE_METHODS else "event.orders:write" perm = "can_view_orders" if self.request.method in SAFE_METHODS else "can_change_orders"
if isinstance(self.request.auth, (TeamAPIToken, Device)): if isinstance(self.request.auth, (TeamAPIToken, Device)):
return Order.objects.filter( return Order.objects.filter(
event__organizer=self.request.organizer, event__organizer=self.request.organizer,
@@ -338,8 +337,8 @@ class OrganizerOrderViewSet(OrderViewSetMixin, viewsets.ReadOnlyModelViewSet):
class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet): class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
permission = 'event.orders:read' permission = 'can_view_orders'
write_permission = 'event.orders:write' write_permission = 'can_change_orders'
def get_serializer_context(self): def get_serializer_context(self):
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
@@ -381,15 +380,12 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list') resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
return resp return resp
else: else:
return FileResponse( resp = FileResponse(ct.file.file, content_type=ct.type)
ct.file.file, resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
filename='{}-{}-{}{}'.format( self.request.event.slug.upper(), order.code,
self.request.event.slug.upper(), order.code, provider.identifier, ct.extension
provider.identifier, ct.extension
),
as_attachment=True,
content_type=ct.type
) )
return resp
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def mark_paid(self, request, **kwargs): def mark_paid(self, request, **kwargs):
@@ -1069,12 +1065,15 @@ with scopes_disabled():
} }
class OrderPositionViewSetMixin: class OrderPositionViewSet(viewsets.ModelViewSet):
serializer_class = OrderPositionSerializer
queryset = OrderPosition.all.none() queryset = OrderPosition.all.none()
filter_backends = (DjangoFilterBackend, RichOrderingFilter) filter_backends = (DjangoFilterBackend, RichOrderingFilter)
ordering = ('order__datetime', 'positionid') ordering = ('order__datetime', 'positionid')
ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',) ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',)
filterset_class = OrderPositionFilter filterset_class = OrderPositionFilter
permission = 'can_view_orders'
write_permission = 'can_change_orders'
ordering_custom = { ordering_custom = {
'attendee_name': { 'attendee_name': {
'_order': F('display_name').asc(nulls_first=True), '_order': F('display_name').asc(nulls_first=True),
@@ -1088,7 +1087,8 @@ class OrderPositionViewSetMixin:
def get_serializer_context(self): def get_serializer_context(self):
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
ctx['pdf_data'] = False ctx['event'] = self.request.event
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
ctx['check_quotas'] = self.request.query_params.get('check_quotas', 'true').lower() == 'true' ctx['check_quotas'] = self.request.query_params.get('check_quotas', 'true').lower() == 'true'
return ctx return ctx
@@ -1097,8 +1097,9 @@ class OrderPositionViewSetMixin:
qs = OrderPosition.all qs = OrderPosition.all
else: else:
qs = OrderPosition.objects qs = OrderPosition.objects
qs = qs.filter(order__event__organizer=self.request.organizer)
if self.request.query_params.get('pdf_data', 'false').lower() == 'true' and getattr(self.request, 'event', None): qs = qs.filter(order__event=self.request.event)
if self.request.query_params.get('pdf_data', 'false').lower() == 'true':
prefetch_related_objects([self.request.organizer], 'meta_properties') prefetch_related_objects([self.request.organizer], 'meta_properties')
prefetch_related_objects( prefetch_related_objects(
[self.request.event], [self.request.event],
@@ -1153,9 +1154,9 @@ class OrderPositionViewSetMixin:
qs = qs.prefetch_related( qs = qs.prefetch_related(
Prefetch('checkins', queryset=Checkin.objects.select_related("device")), Prefetch('checkins', queryset=Checkin.objects.select_related("device")),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')), Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'answers', 'answers__options', 'answers__question', 'order__event', 'order__event__organizer' 'answers', 'answers__options', 'answers__question',
).select_related( ).select_related(
'item', 'order', 'seat' 'item', 'order', 'order__event', 'order__event__organizer', 'seat'
) )
return qs return qs
@@ -1167,49 +1168,6 @@ class OrderPositionViewSetMixin:
return prov return prov
raise NotFound('Unknown output provider.') raise NotFound('Unknown output provider.')
class OrganizerOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrganizerOrderPositionSerializer
permission = None
write_permission = None
def get_queryset(self):
qs = super().get_queryset()
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
elif self.request.user.is_authenticated:
auth_obj = self.request.user
else:
raise PermissionDenied("Unknown authentication scheme")
qs = qs.filter(
order__event__in=auth_obj.get_events_with_permission(perm, request=self.request).filter(
organizer=self.request.organizer
)
)
return qs
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()
ctx['event'] = self.request.event
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
return ctx
def get_queryset(self):
qs = super().get_queryset()
qs = qs.filter(order__event=self.request.event)
return qs
@action(detail=True, methods=['POST'], url_name='price_calc') @action(detail=True, methods=['POST'], url_name='price_calc')
def price_calc(self, request, *args, **kwargs): def price_calc(self, request, *args, **kwargs):
""" """
@@ -1306,17 +1264,14 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
raise NotFound() raise NotFound()
ftype, ignored = mimetypes.guess_type(answer.file.name) ftype, ignored = mimetypes.guess_type(answer.file.name)
return FileResponse( resp = FileResponse(answer.file, content_type=ftype or 'application/binary')
answer.file, resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}"'.format(
filename='{}-{}-{}-{}'.format( self.request.event.slug.upper(),
self.request.event.slug.upper(), pos.order.code,
pos.order.code, pos.positionid,
pos.positionid, os.path.basename(answer.file.name).split('.', 1)[1]
os.path.basename(answer.file.name).split('.', 1)[1]
),
as_attachment=True,
content_type=ftype or 'application/binary'
) )
return resp
@action(detail=True, url_name="printlog", url_path="printlog", methods=["POST"]) @action(detail=True, url_name="printlog", url_path="printlog", methods=["POST"])
def printlog(self, request, **kwargs): def printlog(self, request, **kwargs):
@@ -1371,18 +1326,15 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
if hasattr(image_file, 'seek'): if hasattr(image_file, 'seek'):
image_file.seek(0) image_file.seek(0)
return FileResponse( resp = FileResponse(image_file, content_type=ftype or 'application/binary')
image_file, resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}.{}"'.format(
filename='{}-{}-{}-{}.{}'.format( self.request.event.slug.upper(),
self.request.event.slug.upper(), pos.order.code,
pos.order.code, pos.positionid,
pos.positionid, key,
key, extension,
extension,
),
as_attachment=True,
content_type=ftype or 'application/binary'
) )
return resp
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)') @action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
def download(self, request, output, **kwargs): def download(self, request, output, **kwargs):
@@ -1408,15 +1360,12 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list') resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
return resp return resp
else: else:
return FileResponse( resp = FileResponse(ct.file.file, content_type=ct.type)
ct.file.file, resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
filename='{}-{}-{}-{}{}'.format( self.request.event.slug.upper(), pos.order.code, pos.positionid,
self.request.event.slug.upper(), pos.order.code, pos.positionid, provider.identifier, ct.extension
provider.identifier, ct.extension
),
as_attachment=True,
content_type=ct.type
) )
return resp
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def regenerate_secrets(self, request, **kwargs): def regenerate_secrets(self, request, **kwargs):
@@ -1625,8 +1574,8 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPaymentSerializer serializer_class = OrderPaymentSerializer
queryset = OrderPayment.objects.none() queryset = OrderPayment.objects.none()
permission = 'event.orders:read' permission = 'can_view_orders'
write_permission = 'event.orders:write' write_permission = 'can_change_orders'
lookup_field = 'local_id' lookup_field = 'local_id'
def get_serializer_context(self): def get_serializer_context(self):
@@ -1798,8 +1747,8 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderRefundSerializer serializer_class = OrderRefundSerializer
queryset = OrderRefund.objects.none() queryset = OrderRefund.objects.none()
permission = 'event.orders:read' permission = 'can_view_orders'
write_permission = 'event.orders:write' write_permission = 'can_change_orders'
lookup_field = 'local_id' lookup_field = 'local_id'
def get_queryset(self): def get_queryset(self):
@@ -1956,18 +1905,13 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
ordering = ('nr',) ordering = ('nr',)
ordering_fields = ('nr', 'date') ordering_fields = ('nr', 'date')
filterset_class = InvoiceFilter filterset_class = InvoiceFilter
permission = 'can_view_orders'
lookup_url_kwarg = 'number' lookup_url_kwarg = 'number'
lookup_field = 'nr' 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): def get_queryset(self):
perm = "event.orders:read" if self.request.method in SAFE_METHODS else "event.orders:write" perm = "can_view_orders" if self.request.method in SAFE_METHODS else "can_change_orders"
if getattr(self.request, 'event', None): if getattr(self.request, 'event', None):
qs = self.request.event.invoices qs = self.request.event.invoices
elif isinstance(self.request.auth, (TeamAPIToken, Device)): elif isinstance(self.request.auth, (TeamAPIToken, Device)):
@@ -1998,12 +1942,9 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
if not invoice.file: if not invoice.file:
raise RetryException() raise RetryException()
return FileResponse( resp = FileResponse(invoice.file.file, content_type='application/pdf')
invoice.file.file, resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number)
filename='{}.pdf'.format(invoice.number), return resp
as_attachment=True,
content_type='application/pdf'
)
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def transmit(self, request, **kwargs): def transmit(self, request, **kwargs):
@@ -2111,8 +2052,8 @@ class RevokedSecretViewSet(viewsets.ReadOnlyModelViewSet):
ordering = ('-created',) ordering = ('-created',)
ordering_fields = ('created', 'secret') ordering_fields = ('created', 'secret')
filterset_class = RevokedSecretFilter filterset_class = RevokedSecretFilter
permission = 'event.orders:read' permission = 'can_view_orders'
write_permission = 'event.orders:write' write_permission = 'can_change_orders'
def get_queryset(self): def get_queryset(self):
return RevokedTicketSecret.objects.filter(event=self.request.event) return RevokedTicketSecret.objects.filter(event=self.request.event)
@@ -2133,8 +2074,8 @@ class BlockedSecretViewSet(viewsets.ReadOnlyModelViewSet):
filter_backends = (DjangoFilterBackend, TotalOrderingFilter) filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
ordering = ('-updated', '-pk') ordering = ('-updated', '-pk')
filterset_class = BlockedSecretFilter filterset_class = BlockedSecretFilter
permission = 'event.orders:read' permission = 'can_view_orders'
write_permission = 'event.orders:write' write_permission = 'can_change_orders'
def get_queryset(self): def get_queryset(self):
return BlockedTicketSecret.objects.filter(event=self.request.event) return BlockedTicketSecret.objects.filter(event=self.request.event)
@@ -2169,7 +2110,7 @@ class TransactionViewSet(viewsets.ReadOnlyModelViewSet):
ordering = ('datetime', 'pk') ordering = ('datetime', 'pk')
ordering_fields = ('datetime', 'created', 'id',) ordering_fields = ('datetime', 'created', 'id',)
filterset_class = TransactionFilter filterset_class = TransactionFilter
permission = 'event.orders:read' permission = 'can_view_orders'
def get_queryset(self): def get_queryset(self):
return Transaction.objects.filter(order__event=self.request.event).select_related("order") return Transaction.objects.filter(order__event=self.request.event).select_related("order")
@@ -2186,11 +2127,11 @@ class OrganizerTransactionViewSet(TransactionViewSet):
if isinstance(self.request.auth, (TeamAPIToken, Device)): if isinstance(self.request.auth, (TeamAPIToken, Device)):
qs = qs.filter( qs = qs.filter(
order__event__in=self.request.auth.get_events_with_permission("event.orders:read"), order__event__in=self.request.auth.get_events_with_permission("can_view_orders"),
) )
elif self.request.user.is_authenticated: elif self.request.user.is_authenticated:
qs = qs.filter( qs = qs.filter(
order__event__in=self.request.user.get_events_with_permission("event.orders:read", request=self.request) order__event__in=self.request.user.get_events_with_permission("can_view_orders", request=self.request)
) )
else: else:
raise PermissionDenied("Unknown authentication scheme") raise PermissionDenied("Unknown authentication scheme")
+29 -46
View File
@@ -70,7 +70,7 @@ class OrganizerViewSet(mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
filter_backends = (TotalOrderingFilter,) filter_backends = (TotalOrderingFilter,)
ordering = ('slug',) ordering = ('slug',)
ordering_fields = ('name', 'slug') ordering_fields = ('name', 'slug')
write_permission = "organizer.settings.general:write" write_permission = "can_change_organizer_settings"
def get_queryset(self): def get_queryset(self):
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
@@ -154,8 +154,8 @@ class OrganizerViewSet(mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
class SeatingPlanViewSet(viewsets.ModelViewSet): class SeatingPlanViewSet(viewsets.ModelViewSet):
serializer_class = SeatingPlanSerializer serializer_class = SeatingPlanSerializer
queryset = SeatingPlan.objects.none() queryset = SeatingPlan.objects.none()
permission = None permission = 'can_change_organizer_settings'
write_permission = 'organizer.seatingplans:write' write_permission = 'can_change_organizer_settings'
def get_queryset(self): def get_queryset(self):
return self.request.organizer.seating_plans.order_by('name') return self.request.organizer.seating_plans.order_by('name')
@@ -221,8 +221,8 @@ with scopes_disabled():
class GiftCardViewSet(viewsets.ModelViewSet): class GiftCardViewSet(viewsets.ModelViewSet):
serializer_class = GiftCardSerializer serializer_class = GiftCardSerializer
queryset = GiftCard.objects.none() queryset = GiftCard.objects.none()
permission = 'organizer.giftcards:read' permission = 'can_manage_gift_cards'
write_permission = 'organizer.giftcards:write' write_permission = 'can_manage_gift_cards'
filter_backends = (DjangoFilterBackend,) filter_backends = (DjangoFilterBackend,)
filterset_class = GiftCardFilter filterset_class = GiftCardFilter
@@ -259,14 +259,7 @@ class GiftCardViewSet(viewsets.ModelViewSet):
action='pretix.giftcards.transaction.manual', action='pretix.giftcards.transaction.manual',
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
data=merge_dicts( data=merge_dicts(self.request.data, {'id': inst.pk, 'acceptor_id': self.request.organizer.id})
self.request.data,
{
'id': inst.pk,
'acceptor_id': self.request.organizer.id,
'acceptor_slug': self.request.organizer.slug
}
)
) )
@transaction.atomic() @transaction.atomic()
@@ -297,11 +290,7 @@ class GiftCardViewSet(viewsets.ModelViewSet):
action='pretix.giftcards.transaction.manual', action='pretix.giftcards.transaction.manual',
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
data={ data={'value': diff, 'acceptor_id': self.request.organizer.id}
'value': diff,
'acceptor_id': self.request.organizer.id,
'acceptor_slug': self.request.organizer.slug
}
) )
return inst return inst
@@ -331,8 +320,7 @@ class GiftCardViewSet(viewsets.ModelViewSet):
data={ data={
'value': value, 'value': value,
'text': text, 'text': text,
'acceptor_id': self.request.organizer.id, 'acceptor_id': self.request.organizer.id
'acceptor_slug': self.request.organizer.slug
} }
) )
return Response(GiftCardSerializer(gc, context=self.get_serializer_context()).data, status=status.HTTP_200_OK) return Response(GiftCardSerializer(gc, context=self.get_serializer_context()).data, status=status.HTTP_200_OK)
@@ -344,8 +332,8 @@ class GiftCardViewSet(viewsets.ModelViewSet):
class GiftCardTransactionViewSet(viewsets.ReadOnlyModelViewSet): class GiftCardTransactionViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = GiftCardTransactionSerializer serializer_class = GiftCardTransactionSerializer
queryset = GiftCardTransaction.objects.none() queryset = GiftCardTransaction.objects.none()
permission = 'organizer.giftcards:read' permission = 'can_manage_gift_cards'
write_permission = 'organizer.giftcards:write' write_permission = 'can_manage_gift_cards'
@cached_property @cached_property
def giftcard(self): def giftcard(self):
@@ -362,8 +350,8 @@ class GiftCardTransactionViewSet(viewsets.ReadOnlyModelViewSet):
class TeamViewSet(viewsets.ModelViewSet): class TeamViewSet(viewsets.ModelViewSet):
serializer_class = TeamSerializer serializer_class = TeamSerializer
queryset = Team.objects.none() queryset = Team.objects.none()
permission = 'organizer.teams:write' permission = 'can_change_teams'
write_permission = 'organizer.teams:write' write_permission = 'can_change_teams'
def get_queryset(self): def get_queryset(self):
return self.request.organizer.teams.order_by('pk') return self.request.organizer.teams.order_by('pk')
@@ -402,8 +390,8 @@ class TeamViewSet(viewsets.ModelViewSet):
class TeamMemberViewSet(DestroyModelMixin, viewsets.ReadOnlyModelViewSet): class TeamMemberViewSet(DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = TeamMemberSerializer serializer_class = TeamMemberSerializer
queryset = User.objects.none() queryset = User.objects.none()
permission = 'organizer.teams:write' permission = 'can_change_teams'
write_permission = 'organizer.teams:write' write_permission = 'can_change_teams'
@cached_property @cached_property
def team(self): def team(self):
@@ -431,8 +419,8 @@ class TeamMemberViewSet(DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
class TeamInviteViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet): class TeamInviteViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = TeamInviteSerializer serializer_class = TeamInviteSerializer
queryset = TeamInvite.objects.none() queryset = TeamInvite.objects.none()
permission = 'organizer.teams:write' permission = 'can_change_teams'
write_permission = 'organizer.teams:write' write_permission = 'can_change_teams'
@cached_property @cached_property
def team(self): def team(self):
@@ -468,8 +456,8 @@ class TeamInviteViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyMo
class TeamAPITokenViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet): class TeamAPITokenViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = TeamAPITokenSerializer serializer_class = TeamAPITokenSerializer
queryset = TeamAPIToken.objects.none() queryset = TeamAPIToken.objects.none()
permission = 'organizer.teams:write' permission = 'can_change_teams'
write_permission = 'organizer.teams:write' write_permission = 'can_change_teams'
@cached_property @cached_property
def team(self): def team(self):
@@ -532,8 +520,8 @@ class DeviceViewSet(mixins.CreateModelMixin,
GenericViewSet): GenericViewSet):
serializer_class = DeviceSerializer serializer_class = DeviceSerializer
queryset = Device.objects.none() queryset = Device.objects.none()
permission = 'organizer.devices:read' permission = 'can_change_organizer_settings'
write_permission = 'organizer.devices:write' write_permission = 'can_change_organizer_settings'
lookup_field = 'device_id' lookup_field = 'device_id'
def get_queryset(self): def get_queryset(self):
@@ -542,9 +530,6 @@ class DeviceViewSet(mixins.CreateModelMixin,
def get_serializer_context(self): def get_serializer_context(self):
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer 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 return ctx
@transaction.atomic() @transaction.atomic()
@@ -571,11 +556,11 @@ class DeviceViewSet(mixins.CreateModelMixin,
class OrganizerSettingsView(views.APIView): class OrganizerSettingsView(views.APIView):
permission = None permission = None
write_permission = 'organizer.settings.general:write' write_permission = 'can_change_organizer_settings'
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={ s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={
'request': request, 'permissions': request.orgapermset 'request': request
}) })
if 'explain' in request.GET: if 'explain' in request.GET:
return Response({ return Response({
@@ -592,7 +577,7 @@ class OrganizerSettingsView(views.APIView):
s = OrganizerSettingsSerializer( s = OrganizerSettingsSerializer(
instance=request.organizer.settings, data=request.data, partial=True, instance=request.organizer.settings, data=request.data, partial=True,
organizer=request.organizer, context={ organizer=request.organizer, context={
'request': request, 'permissions': request.orgapermset 'request': request
} }
) )
s.is_valid(raise_exception=True) s.is_valid(raise_exception=True)
@@ -604,7 +589,7 @@ class OrganizerSettingsView(views.APIView):
} }
) )
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={ s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={
'request': request, 'permissions': request.orgapermset 'request': request
}) })
return Response(s.data) return Response(s.data)
@@ -621,8 +606,7 @@ with scopes_disabled():
class CustomerViewSet(viewsets.ModelViewSet): class CustomerViewSet(viewsets.ModelViewSet):
serializer_class = CustomerSerializer serializer_class = CustomerSerializer
queryset = Customer.objects.none() queryset = Customer.objects.none()
permission = 'organizer.customers:read' permission = 'can_manage_customers'
write_permission = 'organizer.customers:write'
lookup_field = 'identifier' lookup_field = 'identifier'
filter_backends = (DjangoFilterBackend,) filter_backends = (DjangoFilterBackend,)
filterset_class = CustomerFilter filterset_class = CustomerFilter
@@ -682,7 +666,7 @@ class CustomerViewSet(viewsets.ModelViewSet):
class MembershipTypeViewSet(viewsets.ModelViewSet): class MembershipTypeViewSet(viewsets.ModelViewSet):
serializer_class = MembershipTypeSerializer serializer_class = MembershipTypeSerializer
queryset = MembershipType.objects.none() queryset = MembershipType.objects.none()
permission = 'organizer.settings.general:write' permission = 'can_change_organizer_settings'
def get_queryset(self): def get_queryset(self):
qs = self.request.organizer.membership_types.all() qs = self.request.organizer.membership_types.all()
@@ -739,8 +723,7 @@ with scopes_disabled():
class MembershipViewSet(viewsets.ModelViewSet): class MembershipViewSet(viewsets.ModelViewSet):
serializer_class = MembershipSerializer serializer_class = MembershipSerializer
queryset = Membership.objects.none() queryset = Membership.objects.none()
permission = 'organizer.customers:read' permission = 'can_manage_customers'
write_permission = 'organizer.customers:write'
filter_backends = (DjangoFilterBackend,) filter_backends = (DjangoFilterBackend,)
filterset_class = MembershipFilter filterset_class = MembershipFilter
@@ -790,8 +773,8 @@ with scopes_disabled():
class SalesChannelViewSet(viewsets.ModelViewSet): class SalesChannelViewSet(viewsets.ModelViewSet):
serializer_class = SalesChannelSerializer serializer_class = SalesChannelSerializer
queryset = SalesChannel.objects.none() queryset = SalesChannel.objects.none()
permission = 'organizer.settings.general:write' permission = 'can_change_organizer_settings'
write_permission = 'organizer.settings.general:write' write_permission = 'can_change_organizer_settings'
filter_backends = (DjangoFilterBackend,) filter_backends = (DjangoFilterBackend,)
filterset_class = SalesChannelFilter filterset_class = SalesChannelFilter
lookup_field = 'identifier' lookup_field = 'identifier'
+1 -1
View File
@@ -204,7 +204,7 @@ class ShreddersMixin:
class EventShreddersViewSet(ShreddersMixin, viewsets.ViewSet): class EventShreddersViewSet(ShreddersMixin, viewsets.ViewSet):
permission = 'event.orders:write' permission = 'can_change_orders'
def get_serializer_kwargs(self): def get_serializer_kwargs(self):
return {} return {}
+2 -2
View File
@@ -62,8 +62,8 @@ class VoucherViewSet(viewsets.ModelViewSet):
ordering = ('id',) ordering = ('id',)
ordering_fields = ('id', 'code', 'max_usages', 'valid_until', 'value') ordering_fields = ('id', 'code', 'max_usages', 'valid_until', 'value')
filterset_class = VoucherFilter filterset_class = VoucherFilter
permission = 'event.vouchers:read' permission = 'can_view_vouchers'
write_permission = 'event.vouchers:write' write_permission = 'can_change_vouchers'
@scopes_disabled() # we have an event check here, and we can save some performance on subqueries @scopes_disabled() # we have an event check here, and we can save some performance on subqueries
def get_queryset(self): def get_queryset(self):
+2 -2
View File
@@ -51,8 +51,8 @@ class WaitingListViewSet(viewsets.ModelViewSet):
ordering = ('created', 'pk',) ordering = ('created', 'pk',)
ordering_fields = ('id', 'created', 'email', 'item') ordering_fields = ('id', 'created', 'email', 'item')
filterset_class = WaitingListFilter filterset_class = WaitingListFilter
permission = 'event.orders:read' permission = 'can_view_orders'
write_permission = 'event.orders:write' write_permission = 'can_change_orders'
def get_queryset(self): def get_queryset(self):
return self.request.event.waitinglistentries.all() return self.request.event.waitinglistentries.all()
+2 -2
View File
@@ -35,8 +35,8 @@ class WebhookFilter(FilterSet):
class WebHookViewSet(viewsets.ModelViewSet): class WebHookViewSet(viewsets.ModelViewSet):
serializer_class = WebHookSerializer serializer_class = WebHookSerializer
queryset = WebHook.objects.none() queryset = WebHook.objects.none()
permission = 'organizer.settings.general:write' permission = 'can_change_organizer_settings'
write_permission = 'organizer.settings.general:write' write_permission = 'can_change_organizer_settings'
filter_backends = (DjangoFilterBackend,) filter_backends = (DjangoFilterBackend,)
filterset_class = WebhookFilter filterset_class = WebhookFilter
+1 -4
View File
@@ -183,7 +183,6 @@ class ParametrizedGiftcardWebhookEvent(ParametrizedWebhookEvent):
return { return {
'notification_id': logentry.pk, 'notification_id': logentry.pk,
'issuer_id': logentry.organizer_id, 'issuer_id': logentry.organizer_id,
'issuer_slug': logentry.organizer.slug,
'giftcard': giftcard.pk, 'giftcard': giftcard.pk,
'action': logentry.action_type, 'action': logentry.action_type,
} }
@@ -198,9 +197,7 @@ class ParametrizedGiftcardTransactionWebhookEvent(ParametrizedWebhookEvent):
return { return {
'notification_id': logentry.pk, 'notification_id': logentry.pk,
'issuer_id': logentry.organizer_id, 'issuer_id': logentry.organizer_id,
'issuer_slug': logentry.organizer.slug,
'acceptor_id': logentry.parsed_data.get('acceptor_id'), 'acceptor_id': logentry.parsed_data.get('acceptor_id'),
'acceptor_slug': logentry.parsed_data.get('acceptor_slug'),
'giftcard': giftcard.pk, 'giftcard': giftcard.pk,
'action': logentry.action_type, 'action': logentry.action_type,
} }
@@ -475,7 +472,7 @@ def register_default_webhook_events(sender, **kwargs):
), ),
ParametrizedGiftcardTransactionWebhookEvent( ParametrizedGiftcardTransactionWebhookEvent(
'pretix.giftcards.transaction.*', 'pretix.giftcards.transaction.*',
_('Gift card used in transaction'), _('Gift card used in transcation'),
) )
) )
+1 -1
View File
@@ -224,7 +224,7 @@ class HistoryPasswordValidator:
).delete() ).delete()
def has_event_access_permission(request, permission='event.settings.general:write'): def has_event_access_permission(request, permission='can_change_event_settings'):
return ( return (
request.user.is_authenticated and request.user.is_authenticated and
request.user.has_event_permission(request.organizer, request.event, permission, request=request) request.user.has_event_permission(request.organizer, request.event, permission, request=request)
+2 -5
View File
@@ -216,10 +216,7 @@ class OutboundSyncProvider:
try: try:
mapped_objects = self.sync_order(sq.order) mapped_objects = self.sync_order(sq.order)
actions_taken = [res and res.sync_info.get("action", "") for res_list in mapped_objects.values() for res in res_list] if not all(all(not res or res.sync_info.get("action", "") == "nothing_to_do" for res in res_list) for res_list in mapped_objects.values()):
should_write_logentry = any(action not in (None, "nothing_to_do") for action in actions_taken)
logger.info('Synced order %s to %s, actions: %r, log: %r', sq.order.code, sq.sync_provider, actions_taken, should_write_logentry)
if should_write_logentry:
sq.order.log_action("pretix.event.order.data_sync.success", { sq.order.log_action("pretix.event.order.data_sync.success", {
"provider": self.identifier, "provider": self.identifier,
"objects": { "objects": {
@@ -240,7 +237,7 @@ class OutboundSyncProvider:
sq.set_sync_error("exceeded", e.messages, e.full_message) sq.set_sync_error("exceeded", e.messages, e.full_message)
else: else:
logger.info( logger.info(
f"Could not sync order {sq.order.code} to {sq.sync_provider} " f"Could not sync order {sq.order.code} to {type(self).__name__} "
f"(transient error, attempt #{sq.failed_attempts}, next {sq.not_before})", f"(transient error, attempt #{sq.failed_attempts}, next {sq.not_before})",
exc_info=True, exc_info=True,
) )
-80
View File
@@ -19,10 +19,7 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # 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/>. # <https://www.gnu.org/licenses/>.
# #
import ipaddress
import logging import logging
import smtplib
import socket
from itertools import groupby from itertools import groupby
from smtplib import SMTPResponseException from smtplib import SMTPResponseException
from typing import TypeVar from typing import TypeVar
@@ -240,80 +237,3 @@ def base_renderers(sender, **kwargs):
def get_email_context(**kwargs): def get_email_context(**kwargs):
return PlaceholderContext(**kwargs).render_all() return PlaceholderContext(**kwargs).render_all()
def create_connection(address, timeout=socket.getdefaulttimeout(),
source_address=None, *, all_errors=False):
# Taken from the python stdlib, extended with a check for local ips
host, port = address
exceptions = []
for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM):
af, socktype, proto, canonname, sa = res
if not getattr(settings, "MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False):
ip_addr = ipaddress.ip_address(sa[0])
if ip_addr.is_multicast:
raise socket.error(f"Request to multicast address {sa[0]} blocked")
if ip_addr.is_loopback or ip_addr.is_link_local:
raise socket.error(f"Request to local address {sa[0]} blocked")
if ip_addr.is_private:
raise socket.error(f"Request to private address {sa[0]} blocked")
sock = None
try:
sock = socket.socket(af, socktype, proto)
if timeout is not socket.getdefaulttimeout():
sock.settimeout(timeout)
if source_address:
sock.bind(source_address)
sock.connect(sa)
# Break explicitly a reference cycle
exceptions.clear()
return sock
except socket.error as exc:
if not all_errors:
exceptions.clear() # raise only the last error
exceptions.append(exc)
if sock is not None:
sock.close()
if len(exceptions):
try:
if not all_errors:
raise exceptions[0]
raise ExceptionGroup("create_connection failed", exceptions)
finally:
# Break explicitly a reference cycle
exceptions.clear()
else:
raise socket.error("getaddrinfo returns an empty list")
class CheckPrivateNetworkMixin:
# _get_socket taken 1:1 from smtplib, just with a call to our own create_connection
def _get_socket(self, host, port, timeout):
# This makes it simpler for SMTP_SSL to use the SMTP connect code
# and just alter the socket connection bit.
if timeout is not None and not timeout:
raise ValueError('Non-blocking socket (timeout=0) is not supported')
if self.debuglevel > 0:
self._print_debug('connect: to', (host, port), self.source_address)
return create_connection((host, port), timeout, self.source_address)
class SMTP(CheckPrivateNetworkMixin, smtplib.SMTP):
pass
# SMTP used here instead of mixin, because smtp.SMTP_SSL._get_socket calls super()._get_socket and then wraps this socket
# super()._get_socket needs to be our version from the mixin
class SMTP_SSL(smtplib.SMTP_SSL, SMTP): # noqa: N801
pass
class CheckPrivateNetworkSmtpBackend(EmailBackend):
@property
def connection_class(self):
return SMTP_SSL if self.use_ssl else SMTP
+6 -34
View File
@@ -47,7 +47,6 @@ from django.utils.formats import localize
from django.utils.translation import gettext, gettext_lazy as _ from django.utils.translation import gettext, gettext_lazy as _
from pretix.base.models import Event from pretix.base.models import Event
from pretix.base.models.auth import PermissionHolder
from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for plugins using excel_safe from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for plugins using excel_safe
SafeWorkbook, remove_invalid_excel_chars as excel_safe, SafeWorkbook, remove_invalid_excel_chars as excel_safe,
) )
@@ -60,20 +59,11 @@ class BaseExporter:
This is the base class for all data exporters This is the base class for all data exporters
""" """
def __init__(self, event, organizer, permission_holder: PermissionHolder=None, progress_callback=lambda v: None): def __init__(self, event, organizer, progress_callback=lambda v: None):
"""
:param event: Event context, can also be a queryset of events for multi-event exports
:param organizer: Organizer context
:param user: The user who triggered the export (or None).
:param token: The API token that triggered the export (or None).
:param device: The device that triggered the export (or None)
:param progress_callback: Callback function with progress
"""
self.event = event self.event = event
self.organizer = organizer self.organizer = organizer
self.progress_callback = progress_callback self.progress_callback = progress_callback
self.is_multievent = isinstance(event, QuerySet) self.is_multievent = isinstance(event, QuerySet)
self.permission_holder = permission_holder
if isinstance(event, QuerySet): if isinstance(event, QuerySet):
self.events = event self.events = event
self.event = None self.event = None
@@ -83,9 +73,6 @@ class BaseExporter:
self.events = Event.objects.filter(pk=event.pk) self.events = Event.objects.filter(pk=event.pk)
self.timezone = event.timezone self.timezone = event.timezone
if hasattr(self, 'organizer_required_permission'):
raise TypeError("Deprecated attribute organizer_required_permission no longer supported.")
def __str__(self): def __str__(self):
return self.identifier return self.identifier
@@ -189,30 +176,15 @@ class BaseExporter:
""" """
return True return True
@classmethod
def get_required_event_permission(cls) -> Optional[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: class OrganizerLevelExportMixin:
@classmethod @property
def get_required_event_permission(cls): def organizer_required_permission(self) -> str:
raise TypeError("required_event_permission may not be called on OrganizerLevelExportMixin")
@classmethod
def get_required_organizer_permission(cls) -> Optional[str]:
""" """
The permission level required to use this exporter. Must be set for organizer-level exports. Set to `None` to The permission level required to use this exporter. Only useful for organizer-level exports,
allow everyone with any access to the organizer. not for event-level exports.
``get_required_event_permission`` will be ignored on this class.
""" """
raise NotImplementedError() return 'can_view_orders'
class ListExporter(BaseExporter): class ListExporter(BaseExporter):
+1 -4
View File
@@ -47,13 +47,10 @@ from ..signals import register_multievent_data_exporters
class CustomerListExporter(OrganizerLevelExportMixin, ListExporter): class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'customerlist' identifier = 'customerlist'
verbose_name = gettext_lazy('Customer accounts') verbose_name = gettext_lazy('Customer accounts')
organizer_required_permission = 'can_manage_customers'
category = pgettext_lazy('export_category', 'Customer accounts') category = pgettext_lazy('export_category', 'Customer accounts')
description = gettext_lazy('Download a spreadsheet of all currently registered 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 @property
def additional_form_fields(self): def additional_form_fields(self):
return OrderedDict( return OrderedDict(
+11 -35
View File
@@ -271,7 +271,7 @@ class OrderListExporter(MultiSheetListExporter):
qs = self._date_filter(qs, form_data, rel='') qs = self._date_filter(qs, form_data, rel='')
if form_data.get('paid_only'): if form_data['paid_only']:
qs = qs.filter(status=Order.STATUS_PAID) qs = qs.filter(status=Order.STATUS_PAID)
return qs return qs
@@ -315,9 +315,8 @@ class OrderListExporter(MultiSheetListExporter):
for id, vn in payment_methods: for id, vn in payment_methods:
headers.append(_('Paid by {method}').format(method=vn)) headers.append(_('Paid by {method}').format(method=vn))
if self.event_object_cache: # get meta_data labels from first cached event
# get meta_data labels from first cached event if any headers += next(iter(self.event_object_cache.values())).meta_data.keys()
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
yield headers yield headers
full_fee_sum_cache = { full_fee_sum_cache = {
@@ -458,7 +457,7 @@ class OrderListExporter(MultiSheetListExporter):
).annotate( ).annotate(
payment_providers=Subquery(p_providers, output_field=CharField()), payment_providers=Subquery(p_providers, output_field=CharField()),
).select_related('order', 'order__invoice_address', 'order__customer', 'tax_rule') ).select_related('order', 'order__invoice_address', 'order__customer', 'tax_rule')
if form_data.get('paid_only'): if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False) qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
if form_data.get('items'): if form_data.get('items'):
@@ -504,9 +503,8 @@ class OrderListExporter(MultiSheetListExporter):
headers.append(_('External customer ID')) headers.append(_('External customer ID'))
headers.append(_('Payment providers')) headers.append(_('Payment providers'))
if self.event_object_cache: # get meta_data labels from first cached event
# get meta_data labels from first cached event if any headers += next(iter(self.event_object_cache.values())).meta_data.keys()
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
yield headers yield headers
yield self.ProgressSetTotal(total=qs.count()) yield self.ProgressSetTotal(total=qs.count())
@@ -562,7 +560,7 @@ class OrderListExporter(MultiSheetListExporter):
qs = OrderPosition.all.filter( qs = OrderPosition.all.filter(
order__event__in=self.events, order__event__in=self.events,
) )
if form_data.get('paid_only'): if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False) qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
if form_data.get('items'): if form_data.get('items'):
@@ -653,7 +651,6 @@ class OrderListExporter(MultiSheetListExporter):
pgettext('address', 'State'), pgettext('address', 'State'),
_('Voucher'), _('Voucher'),
_('Voucher budget usage'), _('Voucher budget usage'),
_('Voucher tag'),
_('Pseudonymization ID'), _('Pseudonymization ID'),
_('Ticket secret'), _('Ticket secret'),
_('Seat ID'), _('Seat ID'),
@@ -709,9 +706,9 @@ class OrderListExporter(MultiSheetListExporter):
_('Position order link') _('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: 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 headers += meta_data_labels
yield headers yield headers
@@ -772,7 +769,6 @@ class OrderListExporter(MultiSheetListExporter):
op.state_for_address or '', op.state_for_address or '',
op.voucher.code if op.voucher else '', op.voucher.code if op.voucher else '',
op.voucher_budget_use if op.voucher_budget_use else '', op.voucher_budget_use if op.voucher_budget_use else '',
op.voucher.tag if op.voucher else '',
op.pseudonymization_id, op.pseudonymization_id,
op.secret, op.secret,
] ]
@@ -1103,25 +1099,13 @@ class PaymentListExporter(ListExporter):
def iterate_list(self, form_data): def iterate_list(self, form_data):
provider_names = dict(get_all_payment_providers()) provider_names = dict(get_all_payment_providers())
i_numbers = Invoice.objects.filter(
order=OuterRef('order_id'),
).values('order').annotate(
m=GroupConcat('full_invoice_no', delimiter=', ')
).values(
'm'
).order_by()
payments = OrderPayment.objects.filter( payments = OrderPayment.objects.filter(
order__event__in=self.events, order__event__in=self.events,
state__in=form_data.get('payment_states', []) state__in=form_data.get('payment_states', [])
).annotate(
order_invoice_numbers=Subquery(i_numbers, output_field=CharField()),
).select_related('order').prefetch_related('order__event').order_by('created') ).select_related('order').prefetch_related('order__event').order_by('created')
refunds = OrderRefund.objects.filter( refunds = OrderRefund.objects.filter(
order__event__in=self.events, order__event__in=self.events,
state__in=form_data.get('refund_states', []) state__in=form_data.get('refund_states', [])
).annotate(
order_invoice_numbers=Subquery(i_numbers, output_field=CharField()),
).select_related('order').prefetch_related('order__event').order_by('created') ).select_related('order').prefetch_related('order__event').order_by('created')
if form_data.get('end_date_range'): if form_data.get('end_date_range'):
@@ -1147,7 +1131,6 @@ class PaymentListExporter(ListExporter):
headers = [ headers = [
_('Event slug'), _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'), _('Event slug'), _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
_('Status code'), _('Amount'), _('Payment method'), _('Comment'), _('Matching ID'), _('Payment details'), _('Status code'), _('Amount'), _('Payment method'), _('Comment'), _('Matching ID'), _('Payment details'),
_('Invoice numbers'),
] ]
yield headers yield headers
@@ -1185,7 +1168,6 @@ class PaymentListExporter(ListExporter):
obj.comment if isinstance(obj, OrderRefund) else "", obj.comment if isinstance(obj, OrderRefund) else "",
matching_id, matching_id,
payment_details, payment_details,
obj.order_invoice_numbers,
] ]
yield row yield row
@@ -1253,14 +1235,11 @@ class QuotaListExporter(ListExporter):
class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter): class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'giftcardtransactionlist' identifier = 'giftcardtransactionlist'
verbose_name = gettext_lazy('Gift card transactions') verbose_name = gettext_lazy('Gift card transactions')
organizer_required_permission = 'can_manage_gift_cards'
category = pgettext_lazy('export_category', 'Gift cards') category = pgettext_lazy('export_category', 'Gift cards')
description = gettext_lazy('Download a spreadsheet of all gift card transactions.') description = gettext_lazy('Download a spreadsheet of all gift card transactions.')
repeatable_read = False repeatable_read = False
@classmethod
def get_required_organizer_permission(cls) -> str:
return 'organizer.giftcards:read'
@property @property
def additional_form_fields(self): def additional_form_fields(self):
d = [ d = [
@@ -1363,13 +1342,10 @@ class GiftcardRedemptionListExporter(ListExporter):
class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter): class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'giftcardlist' identifier = 'giftcardlist'
verbose_name = gettext_lazy('Gift cards') verbose_name = gettext_lazy('Gift cards')
organizer_required_permission = 'can_manage_gift_cards'
category = pgettext_lazy('export_category', 'Gift cards') category = pgettext_lazy('export_category', 'Gift cards')
description = gettext_lazy('Download a spreadsheet of all gift cards including their current value.') 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 @property
def additional_form_fields(self): def additional_form_fields(self):
return OrderedDict( return OrderedDict(
@@ -36,10 +36,6 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
description = _('Download a spread sheet with the data of all reusable medias on your account.') description = _('Download a spread sheet with the data of all reusable medias on your account.')
repeatable_read = False repeatable_read = False
@classmethod
def get_required_organizer_permission(cls) -> str:
return "organizer.reusablemedia:read"
def iterate_list(self, form_data): def iterate_list(self, form_data):
media = ReusableMedium.objects.filter( media = ReusableMedium.objects.filter(
organizer=self.organizer, organizer=self.organizer,
+2 -1
View File
@@ -196,7 +196,8 @@ class RegistrationForm(forms.Form):
def clean_password(self): def clean_password(self):
password1 = self.cleaned_data.get('password', '') password1 = self.cleaned_data.get('password', '')
user = User(email=self.cleaned_data.get('email')) user = User(email=self.cleaned_data.get('email'))
validate_password(password1, user=user) if validate_password(password1, user=user) is not None:
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
return password1 return password1
def clean_email(self): def clean_email(self):
+4 -11
View File
@@ -45,6 +45,7 @@ import pycountry
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.gis.geoip2 import GeoIP2
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.validators import ( from django.core.validators import (
@@ -90,7 +91,7 @@ from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL, COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL,
PERSON_NAME_SALUTATIONS, PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, PERSON_NAME_SALUTATIONS, PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
) )
from pretix.base.templatetags.rich_text import URL_RE, rich_text from pretix.base.templatetags.rich_text import rich_text
from pretix.base.timemachine import time_machine_now from pretix.base.timemachine import time_machine_now
from pretix.control.forms import ( from pretix.control.forms import (
ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField, ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField,
@@ -101,7 +102,6 @@ from pretix.helpers.countries import (
from pretix.helpers.escapejson import escapejson_attr from pretix.helpers.escapejson import escapejson_attr
from pretix.helpers.http import get_client_ip from pretix.helpers.http import get_client_ip
from pretix.helpers.i18n import get_format_without_seconds from pretix.helpers.i18n import get_format_without_seconds
from pretix.helpers.security import get_geoip
from pretix.presale.signals import question_form_fields from pretix.presale.signals import question_form_fields
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -227,15 +227,9 @@ class NamePartsFormField(forms.MultiValueField):
# bots. # bots.
r'^[^$€/%§{}<>~]*$', r'^[^$€/%§{}<>~]*$',
message=_('Please do not use special characters in names.') message=_('Please do not use special characters in names.')
),
RegexValidator(
URL_RE,
inverse_match=True,
message=_('Please do not use special characters in names.')
) )
] ]
} }
self.max_length = defaults['max_length']
self.scheme_name = kwargs.pop('scheme') self.scheme_name = kwargs.pop('scheme')
self.titles = kwargs.pop('titles') self.titles = kwargs.pop('titles')
self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name) self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name)
@@ -293,7 +287,7 @@ class NamePartsFormField(forms.MultiValueField):
if self.require_all_fields and not all(v for v in value): if self.require_all_fields and not all(v for v in value):
raise forms.ValidationError(self.error_messages['incomplete'], code='required') raise forms.ValidationError(self.error_messages['incomplete'], code='required')
if sum(len(v) for v in value.values() if v) > (self.max_length or 250): if sum(len(v) for v in value.values() if v) > 250:
raise forms.ValidationError(_('Please enter a shorter name.'), code='max_length') raise forms.ValidationError(_('Please enter a shorter name.'), code='max_length')
if value.get("salutation") == "empty": if value.get("salutation") == "empty":
@@ -399,7 +393,7 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
def guess_country_from_request(request, event): def guess_country_from_request(request, event):
if settings.HAS_GEOIP: if settings.HAS_GEOIP:
g = get_geoip() g = GeoIP2()
try: try:
res = g.country(get_client_ip(request)) res = g.country(get_client_ip(request))
if res['country_code'] and len(res['country_code']) == 2: if res['country_code'] and len(res['country_code']) == 2:
@@ -1421,7 +1415,6 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if not data.get(r): if not data.get(r):
raise ValidationError({r: _("This field is required for the selected type of invoice transmission.")}) 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_type = transmission_type.identifier
self.instance.transmission_info = transmission_type.form_data_to_transmission_info(data) 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")): elif transmission_type.is_exclusive(self.event, data.get("country"), data.get("is_business")):
+1 -10
View File
@@ -42,8 +42,6 @@ from django.utils.html import escape
from django.utils.timezone import get_current_timezone, now from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.helpers.format import PlainHtmlAlternativeString
def replace_arabic_numbers(inp): def replace_arabic_numbers(inp):
if not isinstance(inp, str): if not isinstance(inp, str):
@@ -63,18 +61,11 @@ def replace_arabic_numbers(inp):
return inp.translate(table) return inp.translate(table)
def format_placeholder_help_text(placeholder_name, sample_value):
if isinstance(sample_value, PlainHtmlAlternativeString):
sample_value = sample_value.plain
title = (_("Sample: %s") % sample_value) if sample_value else ""
return ('<button type="button" class="content-placeholder" title="%s">{%s}</button>' % (escape(title), escape(placeholder_name)))
def format_placeholders_help_text(placeholders, event=None): def format_placeholders_help_text(placeholders, event=None):
placeholders = [(k, v.render_sample(event) if event else v) for k, v in placeholders.items()] placeholders = [(k, v.render_sample(event) if event else v) for k, v in placeholders.items()]
placeholders.sort(key=lambda x: x[0]) placeholders.sort(key=lambda x: x[0])
phs = [ phs = [
format_placeholder_help_text(k, v) '<button type="button" class="content-placeholder" title="%s">{%s}</button>' % (escape(_("Sample: %s") % v) if v else "", escape(k))
for k, v in placeholders for k, v in placeholders
] ]
return _('Available placeholders: {list}').format( return _('Available placeholders: {list}').format(
+21 -9
View File
@@ -33,7 +33,8 @@ from pretix.base.invoicing.transmission import (
transmission_types, transmission_types,
) )
from pretix.base.models import Invoice, InvoiceAddress from pretix.base.models import Invoice, InvoiceAddress
from pretix.base.services.mail import mail from pretix.base.services.mail import mail, render_mail
from pretix.helpers.format import format_map
@transmission_types.new() @transmission_types.new()
@@ -133,7 +134,9 @@ class EmailTransmissionProvider(TransmissionProvider):
subject = invoice.order.event.settings.get('mail_subject_order_invoice', as_type=LazyI18nString) 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 # Do not set to completed because that is done by the email sending task
outgoing_mail = mail( subject = format_map(subject, context)
email_content = render_mail(template, context)
mail(
[recipient], [recipient],
subject, subject,
template, template,
@@ -148,10 +151,19 @@ class EmailTransmissionProvider(TransmissionProvider):
plain_text_only=True, plain_text_only=True,
no_order_links=True, no_order_links=True,
) )
if outgoing_mail: invoice.order.log_action(
invoice.order.log_action( 'pretix.event.order.email.invoice',
'pretix.event.order.email.invoice', user=None,
user=None, auth=None,
auth=None, data={
data=outgoing_mail.log_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': [],
}
)
-4
View File
@@ -148,10 +148,6 @@ class NumberedCanvas(Canvas):
self.restoreState() self.restoreState()
class InvoiceNotReadyException(Exception):
pass
class BaseInvoiceRenderer: class BaseInvoiceRenderer:
""" """
This is the base class for all invoice renderers. This is the base class for all invoice renderers.
-6
View File
@@ -204,12 +204,6 @@ class PeppolTransmissionType(TransmissionType):
} }
return base | {"transmission_peppol_participant_id"} 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: def pdf_watermark(self) -> str:
return pgettext("peppol_invoice", "Visual copy") return pgettext("peppol_invoice", "Visual copy")
+2 -2
View File
@@ -24,7 +24,7 @@ from typing import Optional
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_countries.fields import Country from django_countries.fields import Country
from pretix.base.models import Invoice from pretix.base.models import Invoice, InvoiceAddress
from pretix.base.signals import EventPluginRegistry, Registry 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: def invoice_address_form_fields_visible(self, country: Country, is_business: bool) -> set:
return set(self.invoice_address_form_fields.keys()) return set(self.invoice_address_form_fields.keys())
def validate_invoice_address_data(self, address_data: dict): def validate_address(self, ia: InvoiceAddress):
pass pass
@property @property
@@ -36,9 +36,8 @@ from django.core.management.commands.makemigrations import Command as Parent
from ._migrations import monkeypatch_migrations from ._migrations import monkeypatch_migrations
monkeypatch_migrations()
class Command(Parent): class Command(Parent):
pass
def handle(self, *args, **kwargs):
monkeypatch_migrations()
return super().handle(*args, **kwargs)
@@ -64,7 +64,7 @@ class Command(BaseCommand):
if not periodic_task.receivers or periodic_task.sender_receivers_cache.get(self) is NO_RECEIVERS: if not periodic_task.receivers or periodic_task.sender_receivers_cache.get(self) is NO_RECEIVERS:
return return
for receiver in periodic_task._live_receivers(self)[0]: for receiver in periodic_task._live_receivers(self):
name = f'{receiver.__module__}.{receiver.__name__}' name = f'{receiver.__module__}.{receiver.__name__}'
if options['list_tasks']: if options['list_tasks']:
print(name) print(name)
-13
View File
@@ -24,7 +24,6 @@ from urllib.parse import urlparse, urlsplit
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from django.conf import settings from django.conf import settings
from django.core.exceptions import BadRequest
from django.http import Http404, HttpRequest, HttpResponse from django.http import Http404, HttpRequest, HttpResponse
from django.middleware.common import CommonMiddleware from django.middleware.common import CommonMiddleware
from django.urls import get_script_prefix, resolve from django.urls import get_script_prefix, resolve
@@ -348,18 +347,6 @@ class SecurityMiddleware(MiddlewareMixin):
return resp return resp
class RejectInvalidInputMiddleware(MiddlewareMixin):
def process_request(self, request):
# Nullbytes in GET/POST parameters are mostly harmless, as they will later fail on database insertion, but it
# keeps spamming our error logs whenever someone tries to run a vulnerability scanner.
if "\x00" in request.META['QUERY_STRING'] or "%00" in request.META['QUERY_STRING']:
raise BadRequest("Invalid characters in input.")
if request.method in ('POST', 'PUT', 'PATCH') and request.content_type == "application/x-www-form-urlencoded":
if any("\x00" in value for key, value_list in request.POST.lists() for value in value_list):
raise BadRequest("Invalid characters in input.")
class CustomCommonMiddleware(CommonMiddleware): class CustomCommonMiddleware(CommonMiddleware):
def get_full_path_with_slash(self, request): def get_full_path_with_slash(self, request):
@@ -41,20 +41,16 @@ class Migration(migrations.Migration):
name='datetime', name='datetime',
field=models.DateTimeField(), field=models.DateTimeField(),
), ),
migrations.AddIndex( migrations.AlterIndexTogether(
'logentry', name='logentry',
models.Index(fields=('datetime', 'id'), name="pretixbase__datetim_b1fe5a_idx"), index_together={('datetime', 'id')},
), ),
migrations.AddIndex( migrations.AlterIndexTogether(
'order', name='order',
models.Index(fields=["datetime", "id"], name="pretixbase__datetim_66aff0_idx"), index_together={('datetime', 'id'), ('last_modified', 'id')},
), ),
migrations.AddIndex( migrations.AlterIndexTogether(
'order', name='transaction',
models.Index(fields=["last_modified", "id"], name="pretixbase__last_mo_4ebf8b_idx"), index_together={('datetime', 'id')},
),
migrations.AddIndex(
'transaction',
models.Index(fields=('datetime', 'id'), name="pretixbase__datetim_b20405_idx"),
), ),
] ]
@@ -61,10 +61,7 @@ class Migration(migrations.Migration):
options={ options={
'ordering': ('identifier', 'type', 'organizer'), 'ordering': ('identifier', 'type', 'organizer'),
'unique_together': {('identifier', 'type', 'organizer')}, 'unique_together': {('identifier', 'type', 'organizer')},
'indexes': [ 'index_together': {('identifier', 'type', 'organizer'), ('updated', 'id')},
models.Index(fields=('identifier', 'type', 'organizer'), name='reusable_medium_organizer_index'),
models.Index(fields=('updated', 'id'), name="pretixbase__updated_093277_idx")
],
}, },
bases=(models.Model, pretix.base.models.base.LoggingMixin), bases=(models.Model, pretix.base.models.base.LoggingMixin),
), ),
+25
View File
@@ -9,6 +9,31 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.RenameIndex(
model_name="logentry",
new_name="pretixbase__datetim_b1fe5a_idx",
old_fields=("datetime", "id"),
),
migrations.RenameIndex(
model_name="order",
new_name="pretixbase__datetim_66aff0_idx",
old_fields=("datetime", "id"),
),
migrations.RenameIndex(
model_name="order",
new_name="pretixbase__last_mo_4ebf8b_idx",
old_fields=("last_modified", "id"),
),
migrations.RenameIndex(
model_name="reusablemedium",
new_name="pretixbase__updated_093277_idx",
old_fields=("updated", "id"),
),
migrations.RenameIndex(
model_name="transaction",
new_name="pretixbase__datetim_b20405_idx",
old_fields=("datetime", "id"),
),
migrations.AlterField( migrations.AlterField(
model_name="attendeeprofile", model_name="attendeeprofile",
name="id", name="id",
@@ -1,6 +1,6 @@
# Generated by Django 4.2.10 on 2024-04-02 15:16 # Generated by Django 4.2.10 on 2024-04-02 15:16
from django.db import migrations, models from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
@@ -10,8 +10,8 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.RemoveIndex( migrations.AlterIndexTogether(
"reusablemedium", name="reusablemedium",
'reusable_medium_organizer_index', index_together=set(),
), ),
] ]
@@ -1,137 +0,0 @@
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",
),
]
@@ -1,19 +0,0 @@
# Generated by Django 4.2.27 on 2026-01-21 12:06
import i18nfield.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0298_pluggable_permissions"),
]
operations = [
migrations.AddField(
model_name="itemprogramtime",
name="location",
field=i18nfield.fields.I18nTextField(max_length=200, null=True),
)
]
-4
View File
@@ -70,10 +70,6 @@ def parse_csv(file, length=None, mode="strict", charset=None):
except ImportError: except ImportError:
charset = file.charset charset = file.charset
data = data.decode(charset or "utf-8", mode) data = data.decode(charset or "utf-8", mode)
# remove stray linebreaks from the end of the file
data = data.rstrip("\n")
# If the file was modified on a Mac, it only contains \r as line breaks # If the file was modified on a Mac, it only contains \r as line breaks
if '\r' in data and '\n' not in data: if '\r' in data and '\n' not in data:
data = data.replace('\r', '\n') data = data.replace('\r', '\n')
+3 -3
View File
@@ -132,7 +132,7 @@ class AllowIgnoreQuotaColumn(BooleanColumnMixin, ImportColumn):
class PriceModeColumn(ImportColumn): class PriceModeColumn(ImportColumn):
identifier = 'price_mode' identifier = 'price_mode'
verbose_name = gettext_lazy('Price effect') verbose_name = gettext_lazy('Price mode')
default_value = None default_value = None
initial = 'static:none' initial = 'static:none'
@@ -147,7 +147,7 @@ class PriceModeColumn(ImportColumn):
elif value in reverse: elif value in reverse:
return reverse[value] return reverse[value]
else: else:
raise ValidationError(_("Could not parse {value} as a price effect, use one of {options}.").format( raise ValidationError(_("Could not parse {value} as a price mode, use one of {options}.").format(
value=value, options=', '.join(d.keys()) value=value, options=', '.join(d.keys())
)) ))
@@ -162,7 +162,7 @@ class ValueColumn(DecimalColumnMixin, ImportColumn):
def clean(self, value, previous_values): def clean(self, value, previous_values):
value = super().clean(value, previous_values) value = super().clean(value, previous_values)
if value and previous_values.get("price_mode") == "none": if value and previous_values.get("price_mode") == "none":
raise ValidationError(_("It is pointless to set a value without a price effect.")) raise ValidationError(_("It is pointless to set a value without a price mode."))
return value return value
def assign(self, value, obj: Voucher, **kwargs): def assign(self, value, obj: Voucher, **kwargs):
+3 -9
View File
@@ -29,9 +29,7 @@ import inspect
import logging import logging
import os import os
import threading import threading
from pathlib import Path
import django
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import transaction
@@ -76,14 +74,10 @@ def _transactions_mark_order_dirty(order_id, using=None):
if "PYTEST_CURRENT_TEST" in os.environ: if "PYTEST_CURRENT_TEST" in os.environ:
# We don't care about Order.objects.create() calls in test code so let's try to figure out if this is test code # We don't care about Order.objects.create() calls in test code so let's try to figure out if this is test code
# or not. # or not.
for frame in inspect.stack()[1:]: for frame in inspect.stack():
if ( if 'pretix/base/models/orders' in frame.filename:
'pretix/base/models/orders' in frame.filename
or Path(frame.filename).is_relative_to(Path(django.__file__).parent)
):
# Ignore model- and django-internal code
continue continue
elif 'test_' in frame.filename or 'conftest.py' in frame.filename: elif 'test_' in frame.filename or 'conftest.py in frame.filename':
return return
elif 'pretix/' in frame.filename or 'pretix_' in frame.filename: elif 'pretix/' in frame.filename or 'pretix_' in frame.filename:
# This went through non-test code, let's consider it non-test # This went through non-test code, let's consider it non-test
+14 -66
View File
@@ -38,7 +38,6 @@ import operator
import secrets import secrets
from datetime import timedelta from datetime import timedelta
from functools import reduce from functools import reduce
from typing import Protocol
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import ( from django.contrib.auth.models import (
@@ -50,7 +49,6 @@ from django.core.exceptions import BadRequest, PermissionDenied
from django.db import IntegrityError, models, transaction from django.db import IntegrityError, models, transaction
from django.db.models import Q from django.db.models import Q
from django.utils.crypto import get_random_string, salted_hmac 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.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_otp.models import Device from django_otp.models import Device
@@ -68,14 +66,6 @@ class EmailAddressTakenError(IntegrityError):
pass pass
class PermissionHolder(Protocol):
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
...
def has_organizer_permission(self, organizer, perm_name=None, request=None):
...
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
""" """
This is the user manager for our custom user model. See the User This is the user manager for our custom user model. See the User
@@ -222,28 +212,6 @@ class SuperuserPermissionSet:
return True 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): class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
""" """
This is the user model used by pretix for authentication. This is the user model used by pretix for authentication.
@@ -378,8 +346,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
{ {
'user': self, 'user': self,
'messages': msg, 'messages': msg,
'url': build_absolute_uri('control:user.settings'), 'url': build_absolute_uri('control:user.settings')
'instance': settings.PRETIX_INSTANCE_NAME,
}, },
event=None, event=None,
user=self, user=self,
@@ -424,7 +391,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
'user': self, 'user': self,
'reason': msg, 'reason': msg,
'code': code, 'code': code,
'instance': settings.PRETIX_INSTANCE_NAME,
}, },
event=None, event=None,
user=self, user=self,
@@ -464,7 +430,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
mail( mail(
self.email, _('Password recovery'), 'pretixcontrol/email/forgot.txt', self.email, _('Password recovery'), 'pretixcontrol/email/forgot.txt',
{ {
'instance': settings.PRETIX_INSTANCE_NAME,
'user': self, 'user': self,
'url': (build_absolute_uri('control:auth.forgot.recover') 'url': (build_absolute_uri('control:auth.forgot.recover')
+ '?id=%d&token=%s' % (self.id, default_token_generator.make_token(self))) + '?id=%d&token=%s' % (self.id, default_token_generator.make_token(self)))
@@ -504,7 +469,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
:return: set :return: set
""" """
teams = self._get_teams_for_event(organizer, event) teams = self._get_teams_for_event(organizer, event)
sets = [t.event_permission_set() for t in teams] sets = [t.permission_set() for t in teams]
if sets: if sets:
return set.union(*sets) return set.union(*sets)
else: else:
@@ -518,7 +483,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
:return: set :return: set
""" """
teams = self._get_teams_for_organizer(organizer) teams = self._get_teams_for_organizer(organizer)
sets = [t.organizer_permission_set() for t in teams] sets = [t.permission_set() for t in teams]
if sets: if sets:
return set.union(*sets) return set.union(*sets)
else: else:
@@ -533,7 +498,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
:param organizer: The organizer of the event :param organizer: The organizer of the event
:param event: The event to check :param event: The event to check
:param perm_name: The permission, e.g. ``event.orders:read`` :param perm_name: The permission, e.g. ``can_change_teams``
:param request: The current request (optional) :param request: The current request (optional)
:param session_key: The current session key (optional) :param session_key: The current session key (optional)
:return: bool :return: bool
@@ -545,8 +510,8 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
if teams: if teams:
self._teamcache['e{}'.format(event.pk)] = teams self._teamcache['e{}'.format(event.pk)] = teams
if isinstance(perm_name, (tuple, list)): if isinstance(perm_name, (tuple, list)):
return any([any(team.has_event_permission(p) for team in teams) for p in perm_name]) return any([any(team.has_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]): if not perm_name or any([team.has_permission(perm_name) for team in teams]):
return True return True
return False return False
@@ -556,7 +521,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
to the organizer ``organizer``. to the organizer ``organizer``.
:param organizer: The organizer to check :param organizer: The organizer to check
:param perm_name: The permission, e.g. ``organizer.events:create`` :param perm_name: The permission, e.g. ``can_change_teams``
:param request: The current request (optional). Required to detect staff sessions properly. :param request: The current request (optional). Required to detect staff sessions properly.
:return: bool :return: bool
""" """
@@ -565,8 +530,8 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
teams = self._get_teams_for_organizer(organizer) teams = self._get_teams_for_organizer(organizer)
if teams: if teams:
if isinstance(perm_name, (tuple, list)): if isinstance(perm_name, (tuple, list)):
return any([any(team.has_organizer_permission(p) for team in teams) for p in perm_name]) return any([any(team.has_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]): if not perm_name or any([team.has_permission(perm_name) for team in teams]):
return True return True
return False return False
@@ -597,15 +562,14 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
:return: Iterable of Events :return: Iterable of Events
""" """
from .event import Event from .event import Event
from .organizer import TeamQuerySet
if request and self.has_active_staff_session(request.session.session_key): if request and self.has_active_staff_session(request.session.session_key):
return Event.objects.all() return Event.objects.all()
if isinstance(permission, (tuple, list)): if isinstance(permission, (tuple, list)):
q = reduce(operator.or_, [TeamQuerySet.event_permission_q(p) for p in permission]) q = reduce(operator.or_, [Q(**{p: True}) for p in permission])
else: else:
q = TeamQuerySet.event_permission_q(permission) q = Q(**{permission: True})
return Event.objects.filter( return Event.objects.filter(
Q(organizer_id__in=self.teams.filter(q, all_events=True).values_list('organizer', flat=True)) Q(organizer_id__in=self.teams.filter(q, all_events=True).values_list('organizer', flat=True))
@@ -638,13 +602,14 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
:return: Iterable of Organizers :return: Iterable of Organizers
""" """
from .event import Organizer from .event import Organizer
from .organizer import TeamQuerySet
if request and self.has_active_staff_session(request.session.session_key): if request and self.has_active_staff_session(request.session.session_key):
return Organizer.objects.all() return Organizer.objects.all()
kwargs = {permission: True}
return Organizer.objects.filter( return Organizer.objects.filter(
id__in=self.teams.filter(TeamQuerySet.organizer_permission_q(permission)).values_list('organizer', flat=True) id__in=self.teams.filter(**kwargs).values_list('organizer', flat=True)
) )
def has_active_staff_session(self, session_key=None): def has_active_staff_session(self, session_key=None):
@@ -699,23 +664,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
self.session_token = generate_session_token() self.session_token = generate_session_token()
self.save(update_fields=['session_token']) self.save(update_fields=['session_token'])
@cached_property
@scopes_disabled()
def is_in_any_teams(self):
return self.teams.exists()
class UserWithStaffSession:
# Wrapper around a User object with a staff session, implementing the PermissionHolder Protocol
def __init__(self, user):
self.user = user
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
return True
def has_organizer_permission(self, organizer, perm_name=None, request=None):
return True
class UserKnownLoginSource(models.Model): class UserKnownLoginSource(models.Model):
user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources") user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources")
+1 -1
View File
@@ -86,7 +86,7 @@ class OrderSyncQueue(models.Model):
def set_sync_error(self, failure_mode, messages, full_message): def set_sync_error(self, failure_mode, messages, full_message):
logger.exception( logger.exception(
f"Could not sync order {self.order.code} to {self.sync_provider} ({failure_mode})" f"Could not sync order {self.order.code} to {type(self).__name__} ({failure_mode})"
) )
self.order.log_action(f"pretix.event.order.data_sync.failed.{failure_mode}", { self.order.log_action(f"pretix.event.order.data_sync.failed.{failure_mode}", {
"provider": self.sync_provider, "provider": self.sync_provider,
+17 -28
View File
@@ -29,9 +29,6 @@ from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled from django_scopes import ScopedManager, scopes_disabled
from pretix.base.models import LoggedModel from pretix.base.models import LoggedModel
from pretix.base.permissions import (
AnyPermissionOf, assert_valid_event_permission,
)
@scopes_disabled() @scopes_disabled()
@@ -192,19 +189,13 @@ class Device(LoggedModel):
kwargs['update_fields'] = {'device_id'}.union(kwargs['update_fields']) kwargs['update_fields'] = {'device_id'}.union(kwargs['update_fields'])
super().save(*args, **kwargs) super().save(*args, **kwargs)
def _event_permission_set(self) -> set: def permission_set(self) -> set:
return { return {
'event.orders:read', 'can_view_orders',
'event.orders:write', 'can_change_orders',
'event.vouchers:read', 'can_view_vouchers',
} 'can_manage_gift_cards',
'can_manage_reusable_media',
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: def get_event_permission_set(self, organizer, event) -> set:
@@ -218,7 +209,7 @@ class Device(LoggedModel):
has_event_access = (self.all_events and organizer == self.organizer) or ( has_event_access = (self.all_events and organizer == self.organizer) or (
event in self.limit_events.all() event in self.limit_events.all()
) )
return self._event_permission_set() if has_event_access else set() return self.permission_set() if has_event_access else set()
def get_organizer_permission_set(self, organizer) -> set: def get_organizer_permission_set(self, organizer) -> set:
""" """
@@ -227,26 +218,25 @@ class Device(LoggedModel):
:param organizer: The organizer of the event :param organizer: The organizer of the event
:return: set of permissions :return: set of permissions
""" """
return self._organizer_permission_set() if self.organizer == organizer else set() return self.permission_set() if self.organizer == organizer else set()
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool: def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
""" """
Checks if this token is part of a team that grants access of type ``perm_name`` Checks if this token is part of a team that grants access of type ``perm_name``
to the event ``event``. to the event ``event``.
:param organizer: The organizer of the event :param organizer: The organizer of the event
:param event: The event to check :param event: The event to check
:param perm_name: The permission, e.g. ``event.orders:read`` :param perm_name: The permission, e.g. ``can_change_teams``
:param request: This parameter is ignored and only defined for compatibility reasons. :param request: This parameter is ignored and only defined for compatibility reasons.
:param session_key: This parameter is ignored and only defined for compatibility reasons.
:return: bool :return: bool
""" """
has_event_access = (self.all_events and organizer == self.organizer) or ( has_event_access = (self.all_events and organizer == self.organizer) or (
event in self.limit_events.all() event in self.limit_events.all()
) )
if isinstance(perm_name, (tuple, list)): if isinstance(perm_name, (tuple, list)):
return has_event_access and any(p in self._event_permission_set() for p in perm_name) 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._event_permission_set()) return has_event_access and (not perm_name or perm_name in self.permission_set())
def has_organizer_permission(self, organizer, perm_name=None, request=None): def has_organizer_permission(self, organizer, perm_name=None, request=None):
""" """
@@ -254,13 +244,13 @@ class Device(LoggedModel):
to the organizer ``organizer``. to the organizer ``organizer``.
:param organizer: The organizer to check :param organizer: The organizer to check
:param perm_name: The permission, e.g. ``organizer.events:create`` :param perm_name: The permission, e.g. ``can_change_teams``
:param request: This parameter is ignored and only defined for compatibility reasons. :param request: This parameter is ignored and only defined for compatibility reasons.
:return: bool :return: bool
""" """
if isinstance(perm_name, (tuple, list)): if isinstance(perm_name, (tuple, list)):
return organizer == self.organizer and any(p in self._organizer_permission_set() for p in perm_name) 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._organizer_permission_set()) return organizer == self.organizer and (not perm_name or perm_name in self.permission_set())
def get_events_with_any_permission(self): def get_events_with_any_permission(self):
""" """
@@ -280,10 +270,9 @@ class Device(LoggedModel):
:param request: Ignored, for compatibility with User model :param request: Ignored, for compatibility with User model
:return: Iterable of Events :return: Iterable of Events
""" """
assert_valid_event_permission(permission)
if ( if (
isinstance(permission, (AnyPermissionOf, list, tuple)) and any(p in self._event_permission_set() for p in permission) isinstance(permission, (list, tuple)) and any(p in self.permission_set() for p in permission)
) or (isinstance(permission, str) and permission in self._event_permission_set()): ) or (isinstance(permission, str) and permission in self.permission_set()):
return self.get_events_with_any_permission() return self.get_events_with_any_permission()
else: else:
return self.organizer.events.none() return self.organizer.events.none()
+4 -36
View File
@@ -715,12 +715,6 @@ class Event(EventMixin, LoggedModel):
self.settings.name_scheme = 'given_family' self.settings.name_scheme = 'given_family'
self.settings.payment_banktransfer_invoice_immediately = True self.settings.payment_banktransfer_invoice_immediately = True
self.settings.low_availability_percentage = 10 self.settings.low_availability_percentage = 10
self.settings.mail_send_order_free_attendee = True
self.settings.mail_send_order_placed_attendee = True
self.settings.mail_send_order_paid_attendee = True
self.settings.mail_send_order_approved_attendee = True
self.settings.mail_send_order_approved_free_attendee = True
self.settings.mail_text_download_reminder_attendee = True
@property @property
def social_image(self): def social_image(self):
@@ -849,33 +843,6 @@ class Event(EventMixin, LoggedModel):
time(hour=23, minute=59, second=59) time(hour=23, minute=59, second=59)
), tz) ), 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): def copy_data_from(self, other, skip_meta_data=False):
from ..signals import event_copy_data from ..signals import event_copy_data
from . import ( from . import (
@@ -1419,13 +1386,14 @@ class Event(EventMixin, LoggedModel):
from .auth import User from .auth import User
if permission: if permission:
qs = Team.objects.with_event_permission(permission) kwargs = {permission: True}
else: else:
qs = Team.objects.all() kwargs = {}
team_with_perm = qs.filter( team_with_perm = Team.objects.filter(
members__pk=OuterRef('pk'), members__pk=OuterRef('pk'),
organizer=self.organizer, organizer=self.organizer,
**kwargs
).filter( ).filter(
Q(all_events=True) | Q(limit_events__pk=self.pk) Q(all_events=True) | Q(limit_events__pk=self.pk)
) )
-7
View File
@@ -2306,17 +2306,10 @@ class ItemProgramTime(models.Model):
:type start: datetime :type start: datetime
:param end: The date and time this program time ends :param end: The date and time this program time ends
:type end: datetime :type end: datetime
:param location: venue
:type location: str
""" """
item = models.ForeignKey('Item', related_name='program_times', on_delete=models.CASCADE) item = models.ForeignKey('Item', related_name='program_times', on_delete=models.CASCADE)
start = models.DateTimeField(verbose_name=_("Start")) start = models.DateTimeField(verbose_name=_("Start"))
end = models.DateTimeField(verbose_name=_("End")) end = models.DateTimeField(verbose_name=_("End"))
location = I18nTextField(
null=True, blank=True,
max_length=200,
verbose_name=_("Location"),
)
def clean(self): def clean(self):
if hasattr(self, 'item') and self.item and self.item.event.has_subevents: if hasattr(self, 'item') and self.item and self.item.event.has_subevents:
+1 -1
View File
@@ -88,7 +88,7 @@ class LogEntry(models.Model):
class Meta: class Meta:
ordering = ('-datetime', '-id') ordering = ('-datetime', '-id')
indexes = [models.Index(fields=["datetime", "id"], name="pretixbase__datetim_b1fe5a_idx")] indexes = [models.Index(fields=["datetime", "id"])]
def display(self): def display(self):
from pretix.base.logentrytype_registry import log_entry_types from pretix.base.logentrytype_registry import log_entry_types
-17
View File
@@ -220,20 +220,3 @@ class OutgoingMail(models.Model):
error_log_action_type = 'pretix.email.error' error_log_action_type = 'pretix.email.error'
log_target = None log_target = None
return log_target, error_log_action_type 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,
}
+1 -1
View File
@@ -122,7 +122,7 @@ class ReusableMedium(LoggedModel):
class Meta: class Meta:
unique_together = (("identifier", "type", "organizer"),) unique_together = (("identifier", "type", "organizer"),)
indexes = [ indexes = [
models.Index(fields=("updated", "id"), name="pretixbase__updated_093277_idx"), models.Index(fields=("updated", "id")),
] ]
ordering = "identifier", "type", "organizer" ordering = "identifier", "type", "organizer"
+47 -23
View File
@@ -87,6 +87,7 @@ from pretix.base.timemachine import time_machine_now
from ...helpers import OF_SELF from ...helpers import OF_SELF
from ...helpers.countries import CachedCountries, FastCountryField from ...helpers.countries import CachedCountries, FastCountryField
from ...helpers.format import FormattedString, format_map
from ...helpers.names import build_name from ...helpers.names import build_name
from ...testutils.middleware import debugflags_var from ...testutils.middleware import debugflags_var
from ._transactions import ( from ._transactions import (
@@ -336,8 +337,8 @@ class Order(LockModel, LoggedModel):
verbose_name_plural = _("Orders") verbose_name_plural = _("Orders")
ordering = ("-datetime", "-pk") ordering = ("-datetime", "-pk")
indexes = [ indexes = [
models.Index(fields=["datetime", "id"], name="pretixbase__datetim_66aff0_idx"), models.Index(fields=["datetime", "id"]),
models.Index(fields=["last_modified", "id"], name="pretixbase__last_mo_4ebf8b_idx"), models.Index(fields=["last_modified", "id"]),
] ]
constraints = [ constraints = [
models.UniqueConstraint(fields=["organizer", "code"], name="order_organizer_code_uniq"), models.UniqueConstraint(fields=["organizer", "code"], name="order_organizer_code_uniq"),
@@ -590,7 +591,7 @@ class Order(LockModel, LoggedModel):
not kwargs.get('force_save_with_deferred_fields', None) and not kwargs.get('force_save_with_deferred_fields', None) and
(not update_fields or ('require_approval' not in update_fields and 'status' not in update_fields)) (not update_fields or ('require_approval' not in update_fields and 'status' not in update_fields))
): ):
_fail("It is unsafe to call save() on an Order with deferred fields since we can't check if you missed " _fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed "
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do " "creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
"this.") "this.")
@@ -1166,7 +1167,7 @@ class Order(LockModel, LoggedModel):
only be attached for this position and child positions, the link will only point to the 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. position and the attendee email will be used if available.
""" """
from pretix.base.services.mail import mail from pretix.base.services.mail import mail, render_mail
if not self.email and not (position and position.attendee_email): if not self.email and not (position and position.attendee_email):
return return
@@ -1176,20 +1177,32 @@ class Order(LockModel, LoggedModel):
if position and position.attendee_email: if position and position.attendee_email:
recipient = position.attendee_email recipient = position.attendee_email
outgoing_mail = mail( email_content = render_mail(template, context)
if not isinstance(subject, FormattedString):
subject = format_map(subject, context)
mail(
recipient, subject, template, context, recipient, subject, template, context,
self.event, self.locale, self, headers=headers, sender=sender, self.event, self.locale, self, headers=headers, sender=sender,
invoices=invoices, attach_tickets=attach_tickets, invoices=invoices, attach_tickets=attach_tickets,
position=position, auto_email=auto_email, attach_ical=attach_ical, position=position, auto_email=auto_email, attach_ical=attach_ical,
attach_other_files=attach_other_files, attach_cached_files=attach_cached_files, attach_other_files=attach_other_files, attach_cached_files=attach_cached_files,
) )
if outgoing_mail: self.log_action(
self.log_action( log_entry_type,
log_entry_type, user=user,
user=user, auth=auth,
auth=auth, data={
data=outgoing_mail.log_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 [],
}
)
def resend_link(self, user=None, auth=None): def resend_link(self, user=None, auth=None):
with language(self.locale, self.event.settings.region): with language(self.locale, self.event.settings.region):
@@ -2841,7 +2854,7 @@ class OrderPosition(AbstractPosition):
if Transaction.key(self) != self.__initial_transaction_key or self.canceled != self.__initial_canceled or not self.pk: if Transaction.key(self) != self.__initial_transaction_key or self.canceled != self.__initial_canceled or not self.pk:
_transactions_mark_order_dirty(self.order_id, using=kwargs.get('using', None)) _transactions_mark_order_dirty(self.order_id, using=kwargs.get('using', None))
elif not kwargs.get('force_save_with_deferred_fields', None): elif not kwargs.get('force_save_with_deferred_fields', None):
_fail("It is unsafe to call save() on an OrderPosition with deferred fields since we can't check if you missed " _fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed "
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do " "creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
"this.") "this.")
@@ -2887,14 +2900,17 @@ class OrderPosition(AbstractPosition):
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download :param attach_tickets: Attach tickets of this order, if they are existing and ready to download
:param attach_ical: Attach relevant ICS files :param attach_ical: Attach relevant ICS files
""" """
from pretix.base.services.mail import mail from pretix.base.services.mail import mail, render_mail
if not self.attendee_email: if not self.attendee_email:
return return
with language(self.order.locale, self.order.event.settings.region): with language(self.order.locale, self.order.event.settings.region):
recipient = self.attendee_email recipient = self.attendee_email
outgoing_mail = mail( email_content = render_mail(template, context)
if not isinstance(subject, FormattedString):
subject = format_map(subject, context)
mail(
recipient, subject, template, context, recipient, subject, template, context,
self.event, self.order.locale, order=self.order, headers=headers, sender=sender, self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
position=self, position=self,
@@ -2903,13 +2919,21 @@ class OrderPosition(AbstractPosition):
attach_ical=attach_ical, attach_ical=attach_ical,
attach_other_files=attach_other_files, attach_other_files=attach_other_files,
) )
if outgoing_mail: self.order.log_action(
self.order.log_action( log_entry_type,
log_entry_type, user=user,
user=user, auth=auth,
auth=auth, data={
data=outgoing_mail.log_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': [],
}
)
def resend_link(self, user=None, auth=None): def resend_link(self, user=None, auth=None):
@@ -3080,7 +3104,7 @@ class Transaction(models.Model):
class Meta: class Meta:
ordering = 'datetime', 'pk' ordering = 'datetime', 'pk'
indexes = [ indexes = [
models.Index(fields=['datetime', 'id'], name="pretixbase__datetim_b20405_idx") models.Index(fields=['datetime', 'id'])
] ]
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
+104 -149
View File
@@ -31,10 +31,9 @@
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is # 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 # 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. # License for the specific language governing permissions and limitations under the License.
import operator
import string import string
from datetime import date, datetime, time from datetime import date, datetime, time
from functools import reduce
import pytz_deprecation_shim import pytz_deprecation_shim
from django.conf import settings from django.conf import settings
@@ -54,10 +53,6 @@ from i18nfield.strings import LazyI18nString
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
from pretix.base.validators import OrganizerSlugBanlistValidator 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 ..settings import settings_hierarkey
from .auth import User from .auth import User
@@ -314,44 +309,6 @@ def generate_api_token():
return get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits) 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 is None:
return Q()
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 is None:
return Q()
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): class Team(LoggedModel):
""" """
A team is a collection of people given certain access rights to one or more events of an organizer. A team is a collection of people given certain access rights to one or more events of an organizer.
@@ -364,10 +321,36 @@ class Team(LoggedModel):
:param all_events: Whether this team has access to all events of this organizer :param all_events: Whether this team has access to all events of this organizer
:type all_events: bool :type all_events: bool
:param limit_events: A set of events this team has access to. Irrelevant if ``all_events`` is ``True``. :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) organizer = models.ForeignKey(Organizer, related_name="teams", on_delete=models.CASCADE)
name = models.CharField(max_length=190, verbose_name=_("Team name")) name = models.CharField(max_length=190, verbose_name=_("Team name"))
members = models.ManyToManyField(User, related_name="teams", verbose_name=_("Team members")) 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( require_2fa = models.BooleanField(
default=False, verbose_name=_("Require all members of this team to use two-factor authentication"), 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 " help_text=_("If you turn this on, all members of the team will be required to either set up two-factor "
@@ -375,33 +358,62 @@ class Team(LoggedModel):
"all users.") "all users.")
) )
# Scope can_create_events = models.BooleanField(
all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)")) default=False,
limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True) verbose_name=_("Can create events"),
)
# Permissions can_change_teams = models.BooleanField(
# We store them as {key: True} instead of [key] because otherwise not all lookups we need are supported on SQLite default=False,
all_event_permissions = models.BooleanField(default=False, verbose_name=_("All event permissions")) verbose_name=_("Can change teams and permissions"),
limit_event_permissions = models.JSONField(default=dict, verbose_name=_("Event permissions")) )
all_organizer_permissions = models.BooleanField(default=False, verbose_name=_("All organizer permissions")) can_change_organizer_settings = models.BooleanField(
limit_organizer_permissions = models.JSONField(default=dict, verbose_name=_("Organizer permissions")) default=False,
verbose_name=_("Can change organizer settings"),
# Legacy lookups for plugin compatibility help_text=_('Someone with this setting can get access to most data of all of your events, i.e. via privacy '
can_change_event_settings = LegacyPermissionProperty() 'reports, so be careful who you add to this team!')
can_change_items = LegacyPermissionProperty() )
can_view_orders = LegacyPermissionProperty() can_manage_customers = models.BooleanField(
can_change_orders = LegacyPermissionProperty() default=False,
can_checkin_orders = LegacyPermissionProperty() verbose_name=_("Can manage customer accounts")
can_view_vouchers = LegacyPermissionProperty() )
can_change_vouchers = LegacyPermissionProperty() can_manage_reusable_media = models.BooleanField(
can_create_events = LegacyPermissionProperty() default=False,
can_change_organizer_settings = LegacyPermissionProperty() verbose_name=_("Can manage reusable media")
can_change_teams = LegacyPermissionProperty() )
can_manage_gift_cards = LegacyPermissionProperty() can_manage_gift_cards = models.BooleanField(
can_manage_customers = LegacyPermissionProperty() default=False,
can_manage_reusable_media = LegacyPermissionProperty() verbose_name=_("Can manage gift cards")
)
objects = TeamQuerySet.as_manager() 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")
)
def __str__(self) -> str: def __str__(self) -> str:
return _("%(name)s on %(object)s") % { return _("%(name)s on %(object)s") % {
@@ -409,62 +421,21 @@ class Team(LoggedModel):
'object': str(self.organizer), 'object': str(self.organizer),
} }
def event_permission_set(self, include_legacy=True) -> set: def permission_set(self) -> set:
from ..permissions import get_all_event_permission_groups attribs = dir(self)
return {
result = set() a for a in attribs if a.startswith('can_') and self.has_permission(a)
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 @property
def can_change_settings(self): # Legacy compatibility def can_change_settings(self): # Legacy compatiblilty
return self.can_change_event_settings return self.can_change_event_settings
def has_event_permission(self, perm_name): def has_permission(self, perm_name):
from ..permissions import assert_valid_event_permission try:
if perm_name.startswith('can_') and hasattr(self, perm_name): # legacy
return getattr(self, perm_name) return getattr(self, perm_name)
assert_valid_event_permission(perm_name, allow_legacy=False) except AttributeError:
return self.all_event_permissions or self.limit_event_permissions.get(perm_name, False) raise ValueError('Invalid required permission: %s' % perm_name)
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): def permission_for_event(self, event):
if self.all_events: if self.all_events:
@@ -476,19 +447,6 @@ class Team(LoggedModel):
def active_tokens(self): def active_tokens(self):
return self.tokens.filter(active=True) 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: class Meta:
verbose_name = _("Team") verbose_name = _("Team")
verbose_name_plural = _("Teams") verbose_name_plural = _("Teams")
@@ -545,7 +503,7 @@ class TeamAPIToken(models.Model):
has_event_access = (self.team.all_events and organizer == self.team.organizer) or ( has_event_access = (self.team.all_events and organizer == self.team.organizer) or (
event in self.team.limit_events.all() event in self.team.limit_events.all()
) )
return self.team.event_permission_set() if has_event_access else set() return self.team.permission_set() if has_event_access else set()
def get_organizer_permission_set(self, organizer) -> set: def get_organizer_permission_set(self, organizer) -> set:
""" """
@@ -554,26 +512,25 @@ class TeamAPIToken(models.Model):
:param organizer: The organizer of the event :param organizer: The organizer of the event
:return: set of permissions :return: set of permissions
""" """
return self.team.organizer_permission_set() if self.team.organizer == organizer else set() return self.team.permission_set() if self.team.organizer == organizer else set()
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool: def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
""" """
Checks if this token is part of a team that grants access of type ``perm_name`` Checks if this token is part of a team that grants access of type ``perm_name``
to the event ``event``. to the event ``event``.
:param organizer: The organizer of the event :param organizer: The organizer of the event
:param event: The event to check :param event: The event to check
:param perm_name: The permission, e.g. ``event.orders:read`` :param perm_name: The permission, e.g. ``can_change_teams``
:param request: This parameter is ignored and only defined for compatibility reasons. :param request: This parameter is ignored and only defined for compatibility reasons.
:param session_key: This parameter is ignored and only defined for compatibility reasons.
:return: bool :return: bool
""" """
has_event_access = (self.team.all_events and organizer == self.team.organizer) or ( has_event_access = (self.team.all_events and organizer == self.team.organizer) or (
event in self.team.limit_events.all() event in self.team.limit_events.all()
) )
if isinstance(perm_name, (tuple, list)): if isinstance(perm_name, (tuple, list)):
return has_event_access and any(self.team.has_event_permission(p) for p in perm_name) 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_event_permission(perm_name)) return has_event_access and (not perm_name or self.team.has_permission(perm_name))
def has_organizer_permission(self, organizer, perm_name=None, request=None): def has_organizer_permission(self, organizer, perm_name=None, request=None):
""" """
@@ -581,13 +538,13 @@ class TeamAPIToken(models.Model):
to the organizer ``organizer``. to the organizer ``organizer``.
:param organizer: The organizer to check :param organizer: The organizer to check
:param perm_name: The permission, e.g. ``organizer.events:create`` :param perm_name: The permission, e.g. ``can_change_teams``
:param request: This parameter is ignored and only defined for compatibility reasons. :param request: This parameter is ignored and only defined for compatibility reasons.
:return: bool :return: bool
""" """
if isinstance(perm_name, (tuple, list)): if isinstance(perm_name, (tuple, list)):
return organizer == self.team.organizer and any(self.team.has_organizer_permission(p) for p in perm_name) 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_organizer_permission(perm_name)) return organizer == self.team.organizer and (not perm_name or self.team.has_permission(perm_name))
def get_events_with_any_permission(self): def get_events_with_any_permission(self):
""" """
@@ -607,11 +564,9 @@ class TeamAPIToken(models.Model):
:param request: Ignored, for compatibility with User model :param request: Ignored, for compatibility with User model
:return: Iterable of Events :return: Iterable of Events
""" """
from pretix.base.permissions import AnyPermissionOf
if ( if (
isinstance(permission, (AnyPermissionOf, list, tuple)) and any(self.team.has_event_permission(p) for p in permission) isinstance(permission, (list, tuple)) and any(getattr(self.team, p, False) for p in permission)
) or (isinstance(permission, str) and self.team.has_event_permission(permission)): ) or (isinstance(permission, str) and getattr(self.team, permission, False)):
return self.get_events_with_any_permission() return self.get_events_with_any_permission()
else: else:
return self.team.organizer.events.none() return self.team.organizer.events.none()
+1 -1
View File
@@ -239,7 +239,7 @@ class Voucher(LoggedModel):
) )
) )
price_mode = models.CharField( price_mode = models.CharField(
verbose_name=_("Price effect"), verbose_name=_("Price mode"),
max_length=100, max_length=100,
choices=PRICE_MODES, choices=PRICE_MODES,
default='none' default='none'
+19 -12
View File
@@ -34,9 +34,10 @@ from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.email import get_email_context from pretix.base.email import get_email_context
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import User, Voucher from pretix.base.models import User, Voucher
from pretix.base.services.mail import mail from pretix.base.services.mail import mail, render_mail
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
from ...helpers.format import format_map
from ...helpers.names import build_name from ...helpers.names import build_name
from .base import LoggedModel from .base import LoggedModel
from .event import Event, SubEvent from .event import Event, SubEvent
@@ -180,11 +181,10 @@ class WaitingListEntry(LoggedModel):
block_quota=True, block_quota=True,
item_id=self.item_id, item_id=self.item_id,
subevent_id=self.subevent_id, subevent_id=self.subevent_id,
waitinglistentries__isnull=False, waitinglistentries__isnull=False
seat__isnull=True
).aggregate(free=Sum(F('max_usages') - F('redeemed')))['free'] or 0 ).aggregate(free=Sum(F('max_usages') - F('redeemed')))['free'] or 0
free_seats = num_free_seats_for_product - num_valid_vouchers_for_product free_seats = num_free_seats_for_product - num_valid_vouchers_for_product
if free_seats < 1: if not free_seats:
raise WaitingListException(_('No seat with this product is currently available.')) raise WaitingListException(_('No seat with this product is currently available.'))
if '@' not in self.email: if '@' not in self.email:
@@ -272,7 +272,9 @@ class WaitingListEntry(LoggedModel):
with language(self.locale, self.event.settings.region): with language(self.locale, self.event.settings.region):
recipient = self.email recipient = self.email
outgoing_mail = mail( email_content = render_mail(template, context)
subject = format_map(subject, context)
mail(
recipient, subject, template, context, recipient, subject, template, context,
self.event, self.event,
self.locale, self.locale,
@@ -282,13 +284,18 @@ class WaitingListEntry(LoggedModel):
attach_other_files=attach_other_files, attach_other_files=attach_other_files,
attach_cached_files=attach_cached_files, attach_cached_files=attach_cached_files,
) )
if outgoing_mail: self.log_action(
self.log_action( log_entry_type,
log_entry_type, user=user,
user=user, auth=auth,
auth=auth, data={
data=outgoing_mail.log_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 [],
}
)
@staticmethod @staticmethod
def clean_itemvar(event, item, variation): def clean_itemvar(event, item, variation):
+1 -1
View File
@@ -151,7 +151,7 @@ def get_all_notification_types(event=None):
class ParametrizedOrderNotificationType(NotificationType): class ParametrizedOrderNotificationType(NotificationType):
required_permission = "event.orders:read" required_permission = "can_view_orders"
def __init__(self, event, action_type, verbose_name, title): def __init__(self, event, action_type, verbose_name, title):
self._action_type = action_type self._action_type = action_type
+9 -28
View File
@@ -1295,7 +1295,6 @@ class ManualPayment(BasePaymentProvider):
def format_map(self, order, payment): def format_map(self, order, payment):
return { return {
# Possible placeholder injection, we should make sure to never include user-controlled variables here
'order': order.code, 'order': order.code,
'amount': payment.amount, 'amount': payment.amount,
'currency': self.event.currency, 'currency': self.event.currency,
@@ -1526,26 +1525,16 @@ class GiftCardPayment(BasePaymentProvider):
def payment_control_render(self, request, payment) -> str: def payment_control_render(self, request, payment) -> str:
from .models import GiftCard from .models import GiftCard
if any(key in payment.info_data for key in ('gift_card', 'error')): if 'gift_card' in payment.info_data:
gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card'))
template = get_template('pretixcontrol/giftcards/payment.html') template = get_template('pretixcontrol/giftcards/payment.html')
ctx = { ctx = {
'request': request, 'request': request,
'event': self.event, 'event': self.event,
**({'error': payment.info_data[ 'gc': gc,
'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: def payment_control_render_short(self, payment: OrderPayment) -> str:
d = payment.info_data d = payment.info_data
@@ -1560,16 +1549,12 @@ class GiftCardPayment(BasePaymentProvider):
try: try:
gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card')) gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card'))
except GiftCard.DoesNotExist: except GiftCard.DoesNotExist:
return { return {}
**({'error': payment.info_data[
'error']} if 'error' in payment.info_data else {})
}
return { return {
'gift_card': { 'gift_card': {
'id': gc.pk, 'id': gc.pk,
'secret': gc.secret, 'secret': gc.secret,
'organizer': gc.issuer.slug, 'organizer': gc.issuer.slug
** ({'error': payment.info_data['error']} if 'error' in payment.info_data else {})
} }
} }
@@ -1641,8 +1626,6 @@ class GiftCardPayment(BasePaymentProvider):
raise PaymentException(_("This gift card does not support this currency.")) raise PaymentException(_("This gift card does not support this currency."))
if not gc.accepted_by(self.event.organizer): if not gc.accepted_by(self.event.organizer):
raise PaymentException(_("This gift card is not accepted by this 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: if payment.amount > gc.value:
raise PaymentException(_("This gift card was used in the meantime. Please try again.")) raise PaymentException(_("This gift card was used in the meantime. Please try again."))
if gc.testmode and not payment.order.testmode: if gc.testmode and not payment.order.testmode:
@@ -1667,12 +1650,11 @@ class GiftCardPayment(BasePaymentProvider):
action='pretix.giftcards.transaction.payment', action='pretix.giftcards.transaction.payment',
data={ data={
'value': trans.value, 'value': trans.value,
'acceptor_id': self.event.organizer.id, 'acceptor_id': self.event.organizer.id
'acceptor_slug': self.event.organizer.slug
} }
) )
except PaymentException as e: except PaymentException as e:
payment.fail(info={**payment.info_data, 'error': str(e)}, send_mail=not is_early_special_case) payment.fail(info={'error': str(e)})
raise e raise e
def payment_is_valid_session(self, request: HttpRequest) -> bool: def payment_is_valid_session(self, request: HttpRequest) -> bool:
@@ -1700,7 +1682,6 @@ class GiftCardPayment(BasePaymentProvider):
data={ data={
'value': refund.amount, 'value': refund.amount,
'acceptor_id': self.event.organizer.id, 'acceptor_id': self.event.organizer.id,
'acceptor_slug': self.event.organizer.slug,
'text': refund.comment, 'text': refund.comment,
} }
) )
+15 -32
View File
@@ -54,7 +54,7 @@ from bidi import get_display
from django.conf import settings from django.conf import settings
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Exists, Max, Min, OuterRef from django.db.models import Max, Min
from django.db.models.fields.files import FieldFile from django.db.models.fields.files import FieldFile
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.deconstruct import deconstructible from django.utils.deconstruct import deconstructible
@@ -76,7 +76,7 @@ from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Paragraph from reportlab.platypus import Paragraph
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import Checkin, Event, Order, OrderPosition, Question from pretix.base.models import Event, Order, OrderPosition, Question
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import layout_image_variables, layout_text_variables from pretix.base.signals import layout_image_variables, layout_text_variables
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
@@ -379,13 +379,6 @@ DEFAULT_VARIABLES = OrderedDict((
str(p) for p in generate_compressed_addon_list(op, order, ev) str(p) for p in generate_compressed_addon_list(op, order, ev)
]) ])
}), }),
("checked_in_addons", {
"label": _("List of Checked-In Add-Ons"),
"editor_sample": _("Add-on 1\n2x Add-on 2"),
"evaluate": lambda op, order, ev: "\n".join([
str(p) for p in generate_compressed_addon_list(op, order, ev, only_checked_in=True)
])
}),
("organizer", { ("organizer", {
"label": _("Organizer name"), "label": _("Organizer name"),
"editor_sample": _("Event organizer company"), "editor_sample": _("Event organizer company"),
@@ -498,9 +491,9 @@ DEFAULT_VARIABLES = OrderedDict((
) if op.valid_until else "" ) if op.valid_until else ""
}), }),
("program_times", { ("program_times", {
"label": _("Program times"), "label": _("Program times: date and time"),
"editor_sample": _( "editor_sample": _(
"2017-05-31 10:00 12:00, Room 1\n2017-05-31 14:00 16:00, Room 2\n2017-05-31 14:00 2017-06-01 14:00, Building A"), "2017-05-31 10:00 12:00\n2017-05-31 14:00 16:00\n2017-05-31 14:00 2017-06-01 14:00"),
"evaluate": lambda op, order, ev: get_program_times(op, ev) "evaluate": lambda op, order, ev: get_program_times(op, ev)
}), }),
("medium_identifier", { ("medium_identifier", {
@@ -748,31 +741,21 @@ def get_seat(op: OrderPosition):
def get_program_times(op: OrderPosition, ev: Event): def get_program_times(op: OrderPosition, ev: Event):
ptstr = [] return '\n'.join([
for pt in op.item.program_times.all(): datetimerange(
ptstr.append([ pt.start.astimezone(ev.timezone),
datetimerange( pt.end.astimezone(ev.timezone),
pt.start.astimezone(ev.timezone), as_html=False
pt.end.astimezone(ev.timezone), ) for pt in op.item.program_times.all()
as_html=False ])
),
(', ' + ', '.join(
l.strip() for l in str(pt.location).splitlines() if l.strip())
) if str(pt.location).strip() else ''
])
return '\n'.join(''.join(l) for l in ptstr)
def generate_compressed_addon_list(op, order, event, only_checked_in=False): def generate_compressed_addon_list(op, order, event):
itemcount = defaultdict(int) itemcount = defaultdict(int)
addon_qs = ( addons = [p for p in (
op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {}) op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {})
else op.addons.select_related('item', 'variation') else op.addons.select_related('item', 'variation')
) ) if not p.canceled]
if only_checked_in:
addon_qs = addon_qs.filter(Exists(Checkin.objects.filter(position=OuterRef('pk'))), canceled=False)
addons = [p for p in addon_qs if not p.canceled]
for pos in addons: for pos in addons:
itemcount[pos.item, pos.variation] += 1 itemcount[pos.item, pos.variation] += 1
@@ -929,7 +912,7 @@ class Renderer:
# We do not use str.format like in emails so we (a) can evaluate lazily and (b) can re-implement this # We do not use str.format like in emails so we (a) can evaluate lazily and (b) can re-implement this
# 1:1 on other platforms that render PDFs through our API (libpretixprint) # 1:1 on other platforms that render PDFs through our API (libpretixprint)
return re.sub(r'\{([-a-zA-Z0-9:_]+)\}', replace, text) return re.sub(r'\{([a-zA-Z0-9:_]+)\}', replace, text)
elif o['content'].startswith('itemmeta:'): elif o['content'].startswith('itemmeta:'):
if op.variation_id: if op.variation_id:
-334
View File
@@ -1,334 +0,0 @@
#
# 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")),
],
),
]
+6 -3
View File
@@ -45,6 +45,7 @@ from pretix.base.services.tax import split_fee_for_taxes
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
from pretix.helpers.format import format_map
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -54,7 +55,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) email_context = get_email_context(event_or_subevent=subevent or wle.event, event=wle.event)
mail( mail(
wle.email, wle.email,
str(subject), format_map(subject, email_context),
message, message,
email_context, email_context,
wle.event, wle.event,
@@ -72,8 +73,9 @@ 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, 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) order=order, position_or_address=ia, event=order.event)
real_subject = format_map(subject, email_context)
order.send_mail( order.send_mail(
subject, message, email_context, real_subject, message, email_context,
'pretix.event.order.email.event_canceled', 'pretix.event.order.email.event_canceled',
user, user,
) )
@@ -83,13 +85,14 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
continue continue
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email: 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, email_context = get_email_context(event_or_subevent=p.subevent or order.event,
event=order.event, event=order.event,
refund_amount=refund_amount, refund_amount=refund_amount,
position_or_address=p, position_or_address=p,
order=order, position=p) order=order, position=p)
order.send_mail( order.send_mail(
subject, message, email_context, real_subject, message, email_context,
'pretix.event.order.email.event_canceled', 'pretix.event.order.email.event_canceled',
position=p, position=p,
user=user user=user
+1 -2
View File
@@ -334,8 +334,7 @@ def _check_position_constraints(
raise CartPositionError(error_messages['voucher_invalid_subevent']) raise CartPositionError(error_messages['voucher_invalid_subevent'])
# Voucher expired # Voucher expired
# (checked using real_now_dt as vouchers influence quota calculations) if voucher and voucher.valid_until and voucher.valid_until < time_machine_now_dt:
if voucher and voucher.valid_until and voucher.valid_until < real_now_dt:
raise CartPositionError(error_messages['voucher_expired']) raise CartPositionError(error_messages['voucher_expired'])
# Subevent has been disabled # Subevent has been disabled
-28
View File
@@ -38,7 +38,6 @@ SOURCE_NAMES = {
None: _('European Central Bank'), # backwards-compatibility None: _('European Central Bank'), # backwards-compatibility
'eu:ecb:eurofxref-daily': _('European Central Bank'), 'eu:ecb:eurofxref-daily': _('European Central Bank'),
'cz:cnb:rate-fixing-daily': _('Czech National Bank'), 'cz:cnb:rate-fixing-daily': _('Czech National Bank'),
'pl:nbp:table-a': _('National Bank of Poland'),
} }
@@ -50,7 +49,6 @@ def fetch_rates(sender, **kwargs):
source_tasks = { source_tasks = {
'eu:ecb:eurofxref-daily': fetch_ecb_rates, 'eu:ecb:eurofxref-daily': fetch_ecb_rates,
'cz:cnb:rate-fixing-daily': fetch_cnb_cz_rates, 'cz:cnb:rate-fixing-daily': fetch_cnb_cz_rates,
'pl:nbp:table-a': fetch_nbp_pl_rates,
} }
for source_name, task in source_tasks.items(): for source_name, task in source_tasks.items():
@@ -146,29 +144,3 @@ def fetch_cnb_cz_rates():
rate=rate, rate=rate,
) )
) )
@app.task()
def fetch_nbp_pl_rates():
"""
Fetches currency rates from the Polish National Bank.
"""
r = requests.get("https://api.nbp.pl/api/exchangerates/tables/A/", headers={
"Accept": "application/json",
})
r.raise_for_status()
data = r.json()[0]
source_date = datetime.strptime(data["effectiveDate"], "%Y-%m-%d").date()
for r in data["rates"]:
rate = Decimal(r["mid"]).quantize(Decimal('0.000001'))
ExchangeRate.objects.update_or_create(
source='pl:nbp:table-a',
source_currency=r["code"],
other_currency='PLN',
defaults=dict(
source_date=source_date,
rate=rate,
)
)
+89 -218
View File
@@ -34,13 +34,12 @@ from django_scopes import scopes_disabled
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from pretix.base.email import get_email_context from pretix.base.email import get_email_context
from pretix.base.exporter import BaseExporter, OrganizerLevelExportMixin from pretix.base.exporter import OrganizerLevelExportMixin
from pretix.base.i18n import LazyLocaleException, language from pretix.base.i18n import LazyLocaleException, language
from pretix.base.models import ( from pretix.base.models import (
CachedFile, Device, Event, Organizer, ScheduledEventExport, TeamAPIToken, CachedFile, Device, Event, Organizer, ScheduledEventExport, TeamAPIToken,
User, cachedfile_name, User, cachedfile_name,
) )
from pretix.base.models.auth import UserWithStaffSession
from pretix.base.models.exports import ScheduledOrganizerExport from pretix.base.models.exports import ScheduledOrganizerExport
from pretix.base.services.mail import mail from pretix.base.services.mail import mail
from pretix.base.services.tasks import ( from pretix.base.services.tasks import (
@@ -65,15 +64,7 @@ class ExportEmptyError(ExportError):
@app.task(base=ProfiledEventTask, throws=(ExportError, ExportEmptyError), bind=True) @app.task(base=ProfiledEventTask, throws=(ExportError, ExportEmptyError), bind=True)
def export(self, event: Event, user: User, device: int, token: int, fileid: str, provider: str, def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
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): def set_progress(val):
if not self.request.called_directly: if not self.request.called_directly:
self.update_state( self.update_state(
@@ -81,38 +72,30 @@ def export(self, event: Event, user: User, device: int, token: int, fileid: str,
meta={'value': val} 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) file = CachedFile.objects.get(id=fileid)
with language(event.settings.locale, event.settings.region), override(event.settings.timezone): with language(event.settings.locale, event.settings.region), override(event.settings.timezone):
if ex.repeatable_read: responses = register_data_exporters.send(event)
with repeatable_reads_transaction(): for recv, response in responses:
d = ex.render(form_data) if not response:
else: continue
d = ex.render(form_data) 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 d is None: if d is None:
raise ExportError( raise ExportError(
gettext('Your export did not contain any data.') gettext('Your export did not contain any data.')
) )
file.filename, file.type, data = d 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) f = ContentFile(data)
file.file.save(cachedfile_name(file, file.filename), f) file.file.save(cachedfile_name(file, file.filename), f)
return str(file.pk) return str(file.pk)
@@ -122,7 +105,10 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int,
if device: if device:
device = Device.objects.get(pk=device) device = Device.objects.get(pk=device)
if token: if token:
token = TeamAPIToken.objects.get(pk=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()
def set_progress(val): def set_progress(val):
if not self.request.called_directly: if not self.request.called_directly:
@@ -132,35 +118,12 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int,
) )
file = CachedFile.objects.get(id=fileid) 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: if user:
locale = user.locale locale = user.locale
timezone = user.timezone timezone = user.timezone
region = None # todo: add to user? region = None # todo: add to user?
else: else:
e = ex.events.first() e = allowed_events.first()
if e: if e:
locale = e.settings.locale locale = e.settings.locale
timezone = e.settings.timezone timezone = e.settings.timezone
@@ -170,153 +133,45 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int,
timezone = organizer.settings.timezone or settings.TIME_ZONE timezone = organizer.settings.timezone or settings.TIME_ZONE
region = organizer.settings.region region = organizer.settings.region
with language(locale, region), override(timezone): with language(locale, region), override(timezone):
if ex.repeatable_read: if form_data.get('events') is not None and not form_data.get('all_events'):
with repeatable_reads_transaction(): if isinstance(form_data['events'][0], str):
d = ex.render(form_data) 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)
else: else:
d = ex.render(form_data) events = allowed_events.filter(organizer=organizer)
if d is None: responses = register_multievent_data_exporters.send(organizer)
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 for recv, response in responses:
if not response:
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,
permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user),
**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,
permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user),
**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 continue
ex = response(events, organizer, set_progress)
else: if ex.identifier == provider:
permission_name = response.get_required_event_permission() if (
isinstance(ex, OrganizerLevelExportMixin) and
if permission_name not in _event_list_cache: not staff_session and
if staff_session: not (device or token or user).has_organizer_permission(organizer, ex.organizer_required_permission)
events = event_qs.all() if event_qs else organizer.events.all() ):
elif event_qs is not None: raise ExportError(
events = event_qs.filter( gettext('You do not have sufficient permission to perform this export.')
pk__in=perm_holder.get_events_with_permission(
permission_name, request=request
).filter(
organizer=organizer
).values("id")
) )
if ex.repeatable_read:
with repeatable_reads_transaction():
d = ex.render(form_data)
else: else:
events = perm_holder.get_events_with_permission( d = ex.render(form_data)
permission_name, request=request if d is None:
).filter( raise ExportError(
organizer=organizer gettext('Your export did not contain any data.')
) )
file.filename, file.type, data = d
_event_list_cache[permission_name] = events close_old_connections() # This task can run very long, we might need a new DB connection
if permission_name not in _has_permission_on_any_team_cache: f = ContentFile(data)
# Check if the user has this event permission on any teams they are part of to decide whether to show file.file.save(cachedfile_name(file, file.filename), f)
# the export at all. return str(file.pk)
# 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,
permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user),
**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): def _run_scheduled_export(schedule, context: Union[Event, Organizer], exporter, config_url, retry_func, has_permission):
@@ -362,7 +217,7 @@ def _run_scheduled_export(schedule, context: Union[Event, Organizer], exporter,
try: try:
if not exporter: if not exporter:
raise ExportError("Export type not found or permission denied.") raise ExportError("Export type not found.")
if exporter.repeatable_read: if exporter.repeatable_read:
with repeatable_reads_transaction(): with repeatable_reads_transaction():
d = exporter.render(schedule.export_form_data) d = exporter.render(schedule.export_form_data)
@@ -436,20 +291,31 @@ def _run_scheduled_export(schedule, context: Union[Event, Organizer], exporter,
def scheduled_organizer_export(self, organizer: Organizer, schedule: int) -> None: def scheduled_organizer_export(self, organizer: Organizer, schedule: int) -> None:
schedule = organizer.scheduled_exports.get(pk=schedule) schedule = organizer.scheduled_exports.get(pk=schedule)
event_qs = organizer.events.all() allowed_events = schedule.owner.get_events_with_permission('can_view_orders')
if schedule.export_form_data.get('events') is not None and not schedule.export_form_data.get('all_events'): 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): if isinstance(schedule.export_form_data['events'][0], str):
event_qs = event_qs.filter(slug__in=schedule.export_form_data.get('events')) events = allowed_events.filter(slug__in=schedule.export_form_data.get('events'), organizer=organizer)
else: else:
event_qs = event_qs.filter(pk__in=schedule.export_form_data.get('events')) 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
exporter = init_organizer_exporter(
identifier=schedule.export_identifier,
organizer=organizer,
user=schedule.owner,
event_qs=event_qs,
)
has_permission = schedule.owner.is_active 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( _run_scheduled_export(
schedule, schedule,
@@ -470,12 +336,17 @@ def scheduled_organizer_export(self, organizer: Organizer, schedule: int) -> Non
def scheduled_event_export(self, event: Event, schedule: int) -> None: def scheduled_event_export(self, event: Event, schedule: int) -> None:
schedule = event.scheduled_exports.get(pk=schedule) schedule = event.scheduled_exports.get(pk=schedule)
exporter = init_event_exporter( responses = register_data_exporters.send(event)
identifier=schedule.export_identifier, exporter = None
event=event, for recv, response in responses:
user=schedule.owner, if not response:
) continue
has_permission = schedule.owner.is_active 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')
_run_scheduled_export( _run_scheduled_export(
schedule, schedule,
+2 -17
View File
@@ -51,14 +51,12 @@ from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.invoicing.pdf import InvoiceNotReadyException
from pretix.base.invoicing.transmission import ( from pretix.base.invoicing.transmission import (
get_transmission_types, transmission_providers, get_transmission_types, transmission_providers,
) )
from pretix.base.models import ( from pretix.base.models import (
ExchangeRate, Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee, ExchangeRate, Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee,
) )
from pretix.base.models.orders import OrderPayment
from pretix.base.models.tax import EU_CURRENCIES from pretix.base.models.tax import EU_CURRENCIES
from pretix.base.services.tasks import ( from pretix.base.services.tasks import (
TransactionAwareProfiledEventTask, TransactionAwareTask, TransactionAwareProfiledEventTask, TransactionAwareTask,
@@ -103,7 +101,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
introductory = invoice.event.settings.get('invoice_introductory_text', as_type=LazyI18nString) introductory = invoice.event.settings.get('invoice_introductory_text', as_type=LazyI18nString)
additional = invoice.event.settings.get('invoice_additional_text', as_type=LazyI18nString) additional = invoice.event.settings.get('invoice_additional_text', as_type=LazyI18nString)
footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString) footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString)
if lp and lp.payment_provider and lp.state not in (OrderPayment.PAYMENT_STATE_FAILED, OrderPayment.PAYMENT_STATE_CANCELED): if lp and lp.payment_provider:
if 'payment' in inspect.signature(lp.payment_provider.render_invoice_text).parameters: if 'payment' in inspect.signature(lp.payment_provider.render_invoice_text).parameters:
payment = str(lp.payment_provider.render_invoice_text(invoice.order, lp)) payment = str(lp.payment_provider.render_invoice_text(invoice.order, lp))
else: else:
@@ -205,19 +203,6 @@ def build_invoice(invoice: Invoice) -> Invoice:
invoice.foreign_currency_rate = rate.rate.quantize(Decimal('0.0001'), ROUND_HALF_UP) invoice.foreign_currency_rate = rate.rate.quantize(Decimal('0.0001'), ROUND_HALF_UP)
invoice.foreign_currency_rate_date = rate.source_date invoice.foreign_currency_rate_date = rate.source_date
invoice.foreign_currency_source = 'cz:cnb:rate-fixing-daily' invoice.foreign_currency_source = 'cz:cnb:rate-fixing-daily'
elif invoice.event.settings.invoice_eu_currencies == 'PLN' and invoice.event.currency != 'PLN':
invoice.foreign_currency_display = 'PLN'
if settings.FETCH_ECB_RATES:
rate = ExchangeRate.objects.filter(
source='pl:nbp:table-a',
source_currency=invoice.event.currency,
other_currency=invoice.foreign_currency_display,
source_date__gt=now().date() - timedelta(days=7)
).first()
if rate:
invoice.foreign_currency_rate = rate.rate.quantize(Decimal('0.0001'), ROUND_HALF_UP)
invoice.foreign_currency_rate_date = rate.source_date
invoice.foreign_currency_source = 'pl:nbp:table-a'
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
ia = None ia = None
@@ -519,7 +504,7 @@ def generate_invoice(order: Order, trigger_pdf=True):
return invoice return invoice
@app.task(base=TransactionAwareTask, throws=(InvoiceNotReadyException,)) @app.task(base=TransactionAwareTask)
def invoice_pdf_task(invoice: int): def invoice_pdf_task(invoice: int):
with scopes_disabled(): with scopes_disabled():
i = Invoice.objects.get(pk=invoice) i = Invoice.objects.get(pk=invoice)
+20 -56
View File
@@ -149,13 +149,13 @@ def prefix_subject(settings_holder, subject, highlight=False):
return subject return subject
def mail(email: Union[str, Sequence[str]], subject: Union[str, FormattedString], template: Union[str, LazyI18nString], def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None, 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, 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, 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, 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, plain_text_only=False, no_order_links=False, cc: Sequence[str]=None, bcc: Sequence[str]=None,
sensitive: bool=False) -> Optional[OutgoingMail]: sensitive: bool=False):
""" """
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation. Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
@@ -335,26 +335,14 @@ def mail(email: Union[str, Sequence[str]], subject: Union[str, FormattedString],
should_attach_other_files=attach_other_files or [], should_attach_other_files=attach_other_files or [],
sensitive=sensitive, sensitive=sensitive,
) )
m._prefetched_objects_cache = {}
if invoices and not position: if invoices and not position:
m.should_attach_invoices.add(*invoices) 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: if attach_cached_files:
cf_list = []
for cf in attach_cached_files: for cf in attach_cached_files:
if not isinstance(cf, CachedFile): if not isinstance(cf, CachedFile):
cf = CachedFile.objects.get(pk=cf) m.should_attach_cached_files.add(CachedFile.objects.get(pk=cf))
m.should_attach_cached_files.add(cf) else:
cf_list.append(cf) m.should_attach_cached_files.add(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( send_task = mail_send_task.si(
outgoing_mail=m.id outgoing_mail=m.id
@@ -376,8 +364,6 @@ def mail(email: Union[str, Sequence[str]], subject: Union[str, FormattedString],
lambda: chain(*task_chain).apply_async() lambda: chain(*task_chain).apply_async()
) )
return m
class CustomEmail(EmailMultiAlternatives): class CustomEmail(EmailMultiAlternatives):
def _create_mime_attachment(self, content, mimetype): def _create_mime_attachment(self, content, mimetype):
@@ -403,7 +389,7 @@ def mail_send_task(self, **kwargs) -> bool:
# mail_send_task(self, *, outgoing_mail) # mail_send_task(self, *, outgoing_mail)
with scopes_disabled(): with scopes_disabled():
mail_send(**kwargs) mail_send(**kwargs)
return False return
else: else:
raise ValueError("Unknown arguments") raise ValueError("Unknown arguments")
@@ -411,7 +397,7 @@ def mail_send_task(self, **kwargs) -> bool:
try: try:
outgoing_mail = OutgoingMail.objects.select_for_update(of=OF_SELF).get(pk=outgoing_mail) outgoing_mail = OutgoingMail.objects.select_for_update(of=OF_SELF).get(pk=outgoing_mail)
except OutgoingMail.DoesNotExist: except OutgoingMail.DoesNotExist:
logger.info(f"Ignoring job for non existing email {outgoing_mail}") logger.info(f"Ignoring job for non existing email {outgoing_mail.guid}")
return False return False
if outgoing_mail.status == OutgoingMail.STATUS_INFLIGHT: if outgoing_mail.status == OutgoingMail.STATUS_INFLIGHT:
logger.info(f"Ignoring job for inflight email {outgoing_mail.guid}") logger.info(f"Ignoring job for inflight email {outgoing_mail.guid}")
@@ -423,18 +409,6 @@ def mail_send_task(self, **kwargs) -> bool:
outgoing_mail.inflight_since = now() outgoing_mail.inflight_since = now()
outgoing_mail.save(update_fields=["status", "inflight_since"]) outgoing_mail.save(update_fields=["status", "inflight_since"])
# Performance optimization, saves database queries later on if we resolve the known relationships
if outgoing_mail.event_id:
assert outgoing_mail.event.organizer_id == outgoing_mail.organizer.pk
outgoing_mail.event.organizer = outgoing_mail.organizer
if outgoing_mail.order_id:
assert outgoing_mail.order.event_id == outgoing_mail.event_id
outgoing_mail.order.event = outgoing_mail.event
outgoing_mail.order.organizer = outgoing_mail.organizer
if outgoing_mail.orderposition_id:
assert outgoing_mail.orderposition.order_id == outgoing_mail.order_id
outgoing_mail.orderposition.order = outgoing_mail.order
headers = dict(outgoing_mail.headers) headers = dict(outgoing_mail.headers)
headers.setdefault('X-PX-Correlation', str(outgoing_mail.guid)) headers.setdefault('X-PX-Correlation', str(outgoing_mail.guid))
email = CustomEmail( email = CustomEmail(
@@ -469,24 +443,15 @@ def mail_send_task(self, **kwargs) -> bool:
content = ct.file.read() content = ct.file.read()
args.append((name, content, ct.type)) args.append((name, content, ct.type))
attach_size += len(content) attach_size += len(content)
except Exception as e: except Exception:
# This sometimes fails e.g. with FileNotFoundError. We haven't been able to figure out # This sometimes fails e.g. with FileNotFoundError. We haven't been able to figure out
# why (probably some race condition with ticket cache invalidation?), so retry later. # why (probably some race condition with ticket cache invalidation?), so retry later.
try: try:
logger.exception(f'Could not attach tickets to email {outgoing_mail.guid}, will retry') self.retry(max_retries=5, countdown=60)
retry_after = 60
outgoing_mail.error = "Tickets not ready"
outgoing_mail.error_detail = str(e)
outgoing_mail.sent = now()
outgoing_mail.status = OutgoingMail.STATUS_AWAITING_RETRY
outgoing_mail.retry_after = now() + timedelta(seconds=retry_after)
outgoing_mail.save(update_fields=["status", "error", "error_detail", "sent", "retry_after",
"actual_attachments"])
self.retry(max_retries=5, countdown=retry_after)
except MaxRetriesExceededError: except MaxRetriesExceededError:
# Well then, something is really wrong, let's send it without attachment before we # Well then, something is really wrong, let's send it without attachment before we
# don't send at all # don't send at all
logger.exception(f'Too many retries attaching tickets to email {outgoing_mail.guid}, skip attachment') logger.exception(f'Could not attach tickets to email {outgoing_mail.guid}')
pass pass
if attach_size * 1.37 < settings.FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT - 1024 * 1024: if attach_size * 1.37 < settings.FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT - 1024 * 1024:
@@ -512,17 +477,16 @@ def mail_send_task(self, **kwargs) -> bool:
# Attach calendar files # Attach calendar files
if outgoing_mail.should_attach_ical and outgoing_mail.order: if outgoing_mail.should_attach_ical and outgoing_mail.order:
with language(outgoing_mail.order.locale, outgoing_mail.event.settings.region): fname = re.sub('[^a-zA-Z0-9 ]', '-', unidecode(pgettext('attachment_filename', 'Calendar invite')))
fname = re.sub('[^a-zA-Z0-9 ]', '-', unidecode(pgettext('attachment_filename', 'Calendar invite'))) icals = get_private_icals(
icals = get_private_icals( outgoing_mail.event,
outgoing_mail.event, [outgoing_mail.orderposition] if outgoing_mail.orderposition else outgoing_mail.order.positions.all()
[outgoing_mail.orderposition] if outgoing_mail.orderposition else outgoing_mail.order.positions.all() )
) for i, cal in enumerate(icals):
for i, cal in enumerate(icals): name = '{}{}.ics'.format(fname, f'-{i + 1}' if i > 0 else '')
name = '{}{}.ics'.format(fname, f'-{i + 1}' if i > 0 else '') content = cal.serialize()
content = cal.serialize() mimetype = 'text/calendar'
mimetype = 'text/calendar' email.attach(name, content, mimetype)
email.attach(name, content, mimetype)
invoices_to_mark_transmitted = [] invoices_to_mark_transmitted = []
for inv in outgoing_mail.should_attach_invoices.all(): for inv in outgoing_mail.should_attach_invoices.all():
+56 -81
View File
@@ -67,9 +67,9 @@ from pretix.base.email import get_email_context
from pretix.base.i18n import get_language_without_region, language from pretix.base.i18n import get_language_without_region, language
from pretix.base.media import MEDIA_TYPES from pretix.base.media import MEDIA_TYPES
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Device, Event, GiftCard, Item, ItemVariation, LogEntry, CartPosition, Device, Event, GiftCard, Item, ItemVariation, Membership,
Membership, Order, OrderPayment, OrderPosition, Quota, Seat, Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
SeatCategoryMapping, User, Voucher, Voucher,
) )
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.models.orders import ( from pretix.base.models.orders import (
@@ -253,8 +253,7 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
auth=auth, auth=auth,
data={ data={
'value': position.price, 'value': position.price,
'acceptor_id': order.event.organizer.id, 'acceptor_id': order.event.organizer.id
'acceptor_slug': order.event.organizer.slug
} }
) )
break break
@@ -564,7 +563,6 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
data={ data={
'value': -position.price, 'value': -position.price,
'acceptor_id': order.event.organizer.id, 'acceptor_id': order.event.organizer.id,
'acceptor_slug': order.event.organizer.slug
} }
) )
@@ -727,6 +725,8 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
_check_date(event, time_machine_now_dt) _check_date(event, time_machine_now_dt)
products_seen = Counter() products_seen = Counter()
q_avail = Counter()
v_avail = Counter()
v_usages = Counter() v_usages = Counter()
v_budget = {} v_budget = {}
deleted_positions = set() deleted_positions = set()
@@ -791,9 +791,6 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
shared_lock_objects=[event] shared_lock_objects=[event]
) )
q_avail = Counter()
v_avail = Counter()
# Check maximum order size # Check maximum order size
limit = min(int(event.settings.max_items_per_order), settings.PRETIX_MAX_ORDER_SIZE) limit = min(int(event.settings.max_items_per_order), settings.PRETIX_MAX_ORDER_SIZE)
if sum(1 for cp in sorted_positions if not cp.addon_to) > limit: if sum(1 for cp in sorted_positions if not cp.addon_to) > limit:
@@ -1619,7 +1616,7 @@ class OrderChangeManager:
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership')) MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff')) CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff'))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership', AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership',
'valid_from', 'valid_until', 'is_bundled', 'result', 'count')) 'valid_from', 'valid_until', 'is_bundled', 'result'))
SplitOperation = namedtuple('SplitOperation', ('position',)) SplitOperation = namedtuple('SplitOperation', ('position',))
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff')) FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff'))
AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff')) AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff'))
@@ -1633,24 +1630,16 @@ class OrderChangeManager:
ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple()) ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple())
class AddPositionResult: class AddPositionResult:
_positions: Optional[List[OrderPosition]] _position: Optional[OrderPosition]
def __init__(self): def __init__(self):
self._positions = None self._position = None
@property @property
def position(self) -> OrderPosition: def position(self) -> OrderPosition:
if self._positions is None: if self._position is None:
raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.") raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.")
if len(self._positions) != 1: return self._position
raise RuntimeError("More than one position created.")
return self._positions[0]
@property
def positions(self) -> List[OrderPosition]:
if self._positions is None:
raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.")
return self._positions
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False): def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False):
self.order = order self.order = order
@@ -1808,6 +1797,8 @@ class OrderChangeManager:
tax_rule = tax_rules.get(pos.pk, pos.tax_rule) tax_rule = tax_rules.get(pos.pk, pos.tax_rule)
if not tax_rule: if not tax_rule:
continue continue
if not pos.price:
continue
try: try:
new_rate = tax_rule.tax_rate_for(ia) new_rate = tax_rule.tax_rate_for(ia)
@@ -1824,9 +1815,7 @@ class OrderChangeManager:
override_tax_rate=new_rate, override_tax_code=new_code) override_tax_rate=new_rate, override_tax_code=new_code)
self._totaldiff_guesstimate += new_tax.gross - pos.price self._totaldiff_guesstimate += new_tax.gross - pos.price
self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price)) self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price))
if pos.price: self._invoice_dirty = True
# We do not consider the invoice dirty if only 0€-valued taxes are changed
self._invoice_dirty = True
def cancel_fee(self, fee: OrderFee): def cancel_fee(self, fee: OrderFee):
self._totaldiff_guesstimate -= fee.value self._totaldiff_guesstimate -= fee.value
@@ -1857,12 +1846,8 @@ class OrderChangeManager:
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None, def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None,
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None, subevent: SubEvent = None, seat: Seat = None, membership: Membership = None,
valid_from: datetime = None, valid_until: datetime = None, count: int = 1) -> 'OrderChangeManager.AddPositionResult': valid_from: datetime = None, valid_until: datetime = None) -> 'OrderChangeManager.AddPositionResult':
if count < 1:
raise ValueError("Count must be positive")
if isinstance(seat, str): if isinstance(seat, str):
if count > 1:
raise ValueError("Cannot combine count > 1 with seat")
if not seat: if not seat:
seat = None seat = None
else: else:
@@ -1916,14 +1901,14 @@ class OrderChangeManager:
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'): if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'):
self._invoice_dirty = True self._invoice_dirty = True
self._totaldiff_guesstimate += price.gross * count self._totaldiff_guesstimate += price.gross
self._quotadiff.update({q: count for q in new_quotas}) self._quotadiff.update(new_quotas)
if seat: if seat:
self._seatdiff.update([seat]) self._seatdiff.update([seat])
result = self.AddPositionResult() result = self.AddPositionResult()
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership, self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership,
valid_from, valid_until, is_bundled, result, count)) valid_from, valid_until, is_bundled, result))
return result return result
def split(self, position: OrderPosition): def split(self, position: OrderPosition):
@@ -2472,8 +2457,7 @@ class OrderChangeManager:
auth=self.auth, auth=self.auth,
data={ data={
'value': -position.price, 'value': -position.price,
'acceptor_id': self.order.event.organizer.id, 'acceptor_id': self.order.event.organizer.id
'acceptor_slug': self.order.event.organizer.slug
} }
) )
@@ -2499,8 +2483,7 @@ class OrderChangeManager:
auth=self.auth, auth=self.auth,
data={ data={
'value': -opa.position.price, 'value': -opa.position.price,
'acceptor_id': self.order.event.organizer.id, 'acceptor_id': self.order.event.organizer.id
'acceptor_slug': self.order.event.organizer.slug
} }
) )
@@ -2543,35 +2526,29 @@ class OrderChangeManager:
secret_dirty.remove(position) secret_dirty.remove(position)
position.save(update_fields=['canceled', 'secret']) position.save(update_fields=['canceled', 'secret'])
elif isinstance(op, self.AddOperation): elif isinstance(op, self.AddOperation):
new_pos = [] pos = OrderPosition.objects.create(
new_logs = [] item=op.item, variation=op.variation, addon_to=op.addon_to,
for i in range(op.count): price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code,
pos = OrderPosition.objects.create( tax_value=op.price.tax, tax_rule=op.item.tax_rule,
item=op.item, variation=op.variation, addon_to=op.addon_to, positionid=nextposid, subevent=op.subevent, seat=op.seat,
price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code, used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until,
tax_value=op.price.tax, tax_rule=op.item.tax_rule, is_bundled=op.is_bundled,
positionid=nextposid, subevent=op.subevent, seat=op.seat, )
used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until, nextposid += 1
is_bundled=op.is_bundled, self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
) 'position': pos.pk,
nextposid += 1 'item': op.item.pk,
new_pos.append(pos) 'variation': op.variation.pk if op.variation else None,
new_logs.append(self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={ 'addon_to': op.addon_to.pk if op.addon_to else None,
'position': pos.pk, 'price': op.price.gross,
'item': op.item.pk, 'positionid': pos.positionid,
'variation': op.variation.pk if op.variation else None, 'membership': pos.used_membership_id,
'addon_to': op.addon_to.pk if op.addon_to else None, 'subevent': op.subevent.pk if op.subevent else None,
'price': op.price.gross, 'seat': op.seat.pk if op.seat else None,
'positionid': pos.positionid, 'valid_from': op.valid_from.isoformat() if op.valid_from else None,
'membership': pos.used_membership_id, 'valid_until': op.valid_until.isoformat() if op.valid_until else None,
'subevent': op.subevent.pk if op.subevent else None, })
'seat': op.seat.pk if op.seat else None, op.result._position = pos
'valid_from': op.valid_from.isoformat() if op.valid_from else None,
'valid_until': op.valid_until.isoformat() if op.valid_until else None,
}, save=False))
op.result._positions = new_pos
LogEntry.bulk_create_and_postprocess(new_logs)
elif isinstance(op, self.SplitOperation): elif isinstance(op, self.SplitOperation):
position = position_cache.setdefault(op.position.pk, op.position) position = position_cache.setdefault(op.position.pk, op.position)
split_positions.append(position) split_positions.append(position)
@@ -2896,7 +2873,7 @@ class OrderChangeManager:
return total return total
def _check_order_size(self): def _check_order_size(self):
if (len(self.order.positions.all()) + sum([op.count for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE: if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
raise OrderError( raise OrderError(
self.error_messages['max_order_size'] % { self.error_messages['max_order_size'] % {
'max': settings.PRETIX_MAX_ORDER_SIZE, 'max': settings.PRETIX_MAX_ORDER_SIZE,
@@ -2957,7 +2934,7 @@ class OrderChangeManager:
]) + len([ ]) + len([
o for o in self._operations if isinstance(o, self.SplitOperation) o for o in self._operations if isinstance(o, self.SplitOperation)
]) ])
adds = sum([o.count for o in self._operations if isinstance(o, self.AddOperation)]) adds = len([o for o in self._operations if isinstance(o, self.AddOperation)])
if current > 0 and current - cancels + adds < 1: if current > 0 and current - cancels + adds < 1:
raise OrderError(self.error_messages['complete_cancel']) raise OrderError(self.error_messages['complete_cancel'])
@@ -3004,18 +2981,17 @@ class OrderChangeManager:
elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart: elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart:
fake_cart.remove(positions_to_fake_cart[op.position]) fake_cart.remove(positions_to_fake_cart[op.position])
elif isinstance(op, self.AddOperation): elif isinstance(op, self.AddOperation):
for i in range(op.count): cp = CartPosition(
cp = CartPosition( event=self.event,
event=self.event, item=op.item,
item=op.item, variation=op.variation,
variation=op.variation, used_membership=op.membership,
used_membership=op.membership, subevent=op.subevent,
subevent=op.subevent, seat=op.seat,
seat=op.seat, )
) cp.override_valid_from = op.valid_from
cp.override_valid_from = op.valid_from cp.override_valid_until = op.valid_until
cp.override_valid_until = op.valid_until fake_cart.append(cp)
fake_cart.append(cp)
try: try:
validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode) validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode)
except ValidationError as e: except ValidationError as e:
@@ -3477,7 +3453,6 @@ def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs):
data={ data={
'value': trans.value, 'value': trans.value,
'acceptor_id': order.event.organizer.id, 'acceptor_id': order.event.organizer.id,
'acceptor_slug': order.event.organizer.slug
} }
) )
any_giftcards = True any_giftcards = True
-7
View File
@@ -24,7 +24,6 @@ import logging
from datetime import timedelta from datetime import timedelta
from decimal import Decimal from decimal import Decimal
from django.db.models import Prefetch, prefetch_related_objects
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.html import escape, mark_safe from django.utils.html import escape, mark_safe
@@ -36,7 +35,6 @@ from pretix.base.forms.widgets import format_placeholders_help_text
from pretix.base.i18n import ( from pretix.base.i18n import (
LazyCurrencyNumber, LazyDate, LazyExpiresDate, LazyNumber, LazyCurrencyNumber, LazyDate, LazyExpiresDate, LazyNumber,
) )
from pretix.base.models import EventMetaValue
from pretix.base.reldate import RelativeDateWrapper from pretix.base.reldate import RelativeDateWrapper
from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized
from pretix.base.signals import ( from pretix.base.signals import (
@@ -754,11 +752,6 @@ def base_placeholders(sender, **kwargs):
name_scheme['sample'][f] name_scheme['sample'][f]
)) ))
prefetch_related_objects(
[sender],
Prefetch('meta_values', queryset=EventMetaValue.objects.select_related("property"), to_attr="meta_values_cached")
)
prefetch_related_objects([sender.organizer], Prefetch('meta_properties'))
for k, v in sender.meta_data.items(): for k, v in sender.meta_data.items():
ph.append(MarkdownTextPlaceholder( ph.append(MarkdownTextPlaceholder(
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k], 'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],

Some files were not shown because too many files have changed in this diff Show More