Compare commits

..
Author SHA1 Message Date
Raphael Michel 8fa715ac4b API: Allow to add debug_data to failed check-ins 2023-12-01 11:09:18 +01:00
313 changed files with 167815 additions and 156224 deletions
-2
View File
@@ -10,9 +10,7 @@ updates:
schedule: schedule:
interval: "daily" interval: "daily"
versioning-strategy: increase versioning-strategy: increase
open-pull-requests-limit: 10
- package-ecosystem: "npm" - package-ecosystem: "npm"
directory: "/src/pretix/static/npm_dir" directory: "/src/pretix/static/npm_dir"
schedule: schedule:
interval: "monthly" interval: "monthly"
open-pull-requests-limit: 5
+4
View File
@@ -42,6 +42,7 @@ Example::
currency=EUR currency=EUR
datadir=/data datadir=/data
plugins_default=pretix.plugins.sendmail,pretix.plugins.statistics plugins_default=pretix.plugins.sendmail,pretix.plugins.statistics
cookie_domain=.pretix.de
``instance_name`` ``instance_name``
The name of this installation. Default: ``pretix.de`` The name of this installation. Default: ``pretix.de``
@@ -70,6 +71,9 @@ Example::
``auth_backends`` ``auth_backends``
A comma-separated list of available auth backends. Defaults to ``pretix.base.auth.NativeAuthBackend``. A comma-separated list of available auth backends. Defaults to ``pretix.base.auth.NativeAuthBackend``.
``cookie_domain``
The cookie domain to be set. Defaults to ``None``.
``registration`` ``registration``
Enables or disables the registration of new admin users. Defaults to ``off``. Enables or disables the registration of new admin users. Defaults to ``off``.
+2 -4
View File
@@ -36,8 +36,6 @@ geo_lon float Longitude of th
has_subevents boolean ``true`` if the event series feature is active for this has_subevents boolean ``true`` if the event series feature is active for this
event. Cannot change after event is created. event. Cannot change after event is created.
meta_data object Values set for organizer-specific meta data parameters. meta_data object Values set for organizer-specific meta data parameters.
The allowed keys need to be set up as meta properties
in the organizer configuration.
plugins list A list of package names of the enabled plugins for this plugins list A list of package names of the enabled plugins for this
event. event.
seating_plan integer If reserved seating is in use, the ID of a seating seating_plan integer If reserved seating is in use, the ID of a seating
@@ -345,8 +343,8 @@ Endpoints
Creates a new event with properties as set in the request body. The properties that are copied are: ``is_public``, Creates a new event with properties as set in the request body. The properties that are copied are: ``is_public``,
``testmode``, ``has_subevents``, settings, plugin settings, items, variations, add-ons, quotas, categories, tax rules, questions. ``testmode``, ``has_subevents``, settings, plugin settings, items, variations, add-ons, quotas, categories, tax rules, questions.
If the ``plugins``, ``has_subevents``, ``meta_data`` and/or ``is_public`` fields are present in the post body this will If the ``plugins``, ``has_subevents`` and/or ``is_public`` fields are present in the post body this will determine their
determine their value. Otherwise their value will be copied from the existing event. value. Otherwise their value will be copied from the existing event.
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.
-20
View File
@@ -45,16 +45,8 @@ sales_channels list of strings Sales channels
available. available.
available_from datetime The first date time at which this variation can be bought available_from datetime The first date time at which this variation can be bought
(or ``null``). (or ``null``).
available_from_mode string If ``hide`` (the default), this variation is hidden in the shop
if unavailable due to the available_from setting.
If ``info``, the variation is visible, but can't be purchased,
and a note explaining the unavailability is displayed.
available_until datetime The last date time at which this variation can be bought available_until datetime The last date time at which this variation can be bought
(or ``null``). (or ``null``).
available_until_mode string If ``hide`` (the default), this variation is hidden in the shop
if unavailable due to the available_until setting.
If ``info``, the variation is visible, but can't be purchased,
and a note explaining the unavailability is displayed.
hide_without_voucher boolean If ``true``, this variation is only shown during the voucher hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
redemption process, but not in the normal shop redemption process, but not in the normal shop
frontend. frontend.
@@ -113,9 +105,7 @@ Endpoints
"require_membership_types": [], "require_membership_types": [],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hide_without_voucher": false, "hide_without_voucher": false,
"description": { "description": {
"en": "Test2" "en": "Test2"
@@ -141,9 +131,7 @@ Endpoints
"require_membership_types": [], "require_membership_types": [],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hide_without_voucher": false, "hide_without_voucher": false,
"description": {}, "description": {},
"position": 1, "position": 1,
@@ -204,9 +192,7 @@ Endpoints
"require_membership_types": [], "require_membership_types": [],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hide_without_voucher": false, "hide_without_voucher": false,
"description": null, "description": null,
"position": 0, "position": 0,
@@ -246,9 +232,7 @@ Endpoints
"require_membership_types": [], "require_membership_types": [],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hide_without_voucher": false, "hide_without_voucher": false,
"description": null, "description": null,
"position": 0, "position": 0,
@@ -279,9 +263,7 @@ Endpoints
"require_membership_types": [], "require_membership_types": [],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hide_without_voucher": false, "hide_without_voucher": false,
"description": null, "description": null,
"position": 0, "position": 0,
@@ -343,9 +325,7 @@ Endpoints
"require_membership_types": [], "require_membership_types": [],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hide_without_voucher": false, "hide_without_voucher": false,
"description": null, "description": null,
"position": 1, "position": 1,
-46
View File
@@ -50,16 +50,8 @@ sales_channels list of strings Sales channel
``"web"`` or ``"resellers"``. Defaults to ``["web"]``. ``"web"`` or ``"resellers"``. Defaults to ``["web"]``.
available_from datetime The first date time at which this item can be bought available_from datetime The first date time at which this item can be bought
(or ``null``). (or ``null``).
available_from_mode string If ``hide`` (the default), this item is hidden in the shop
if unavailable due to the ``available_from`` setting.
If ``info``, the item is visible, but can't be purchased,
and a note explaining the unavailability is displayed.
available_until datetime The last date time at which this item can be bought available_until datetime The last date time at which this item can be bought
(or ``null``). (or ``null``).
available_until_mode string If ``hide`` (the default), this item is hidden in the shop
if unavailable due to the ``available_until`` setting.
If ``info``, the item is visible, but can't be purchased,
and a note explaining the unavailability is displayed.
hidden_if_available integer **DEPRECATED** The internal ID of a quota object, or ``null``. If hidden_if_available integer **DEPRECATED** The internal ID of a quota object, or ``null``. If
set, this item won't be shown publicly as long as this set, this item won't be shown publicly as long as this
quota is available. quota is available.
@@ -164,16 +156,8 @@ variations list of objects A list with o
available. available.
├ available_from datetime The first date time at which this variation can be bought ├ available_from datetime The first date time at which this variation can be bought
(or ``null``). (or ``null``).
├ available_from_mode string If ``hide`` (the default), this variation is hidden in the shop
if unavailable due to the ``available_from`` setting.
If ``info``, the variation is visible, but can't be purchased,
and a note explaining the unavailability is displayed.
├ available_until datetime The last date time at which this variation can be bought ├ available_until datetime The last date time at which this variation can be bought
(or ``null``). (or ``null``).
├ available_until_mode string If ``hide`` (the default), this variation is hidden in the shop
if unavailable due to the ``available_until`` setting.
If ``info``, the variation is visible, but can't be purchased,
and a note explaining the unavailability is displayed.
├ hide_without_voucher boolean If ``true``, this variation is only shown during the voucher ├ hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
redemption process, but not in the normal shop redemption process, but not in the normal shop
frontend. frontend.
@@ -295,9 +279,7 @@ Endpoints
"position": 0, "position": 0,
"picture": null, "picture": null,
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hidden_if_available": null, "hidden_if_available": null,
"hidden_if_item_available": null, "hidden_if_item_available": null,
"require_voucher": false, "require_voucher": false,
@@ -342,9 +324,7 @@ Endpoints
"require_membership_types": [], "require_membership_types": [],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hide_without_voucher": false, "hide_without_voucher": false,
"description": null, "description": null,
"meta_data": {}, "meta_data": {},
@@ -364,9 +344,7 @@ Endpoints
"require_membership_types": [], "require_membership_types": [],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hide_without_voucher": false, "hide_without_voucher": false,
"description": null, "description": null,
"meta_data": {}, "meta_data": {},
@@ -439,9 +417,7 @@ Endpoints
"position": 0, "position": 0,
"picture": null, "picture": null,
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hidden_if_available": null, "hidden_if_available": null,
"hidden_if_item_available": null, "hidden_if_item_available": null,
"require_voucher": false, "require_voucher": false,
@@ -487,9 +463,7 @@ Endpoints
"description": null, "description": null,
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hide_without_voucher": false, "hide_without_voucher": false,
"meta_data": {}, "meta_data": {},
"position": 0 "position": 0
@@ -508,9 +482,7 @@ Endpoints
"require_membership_types": [], "require_membership_types": [],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hide_without_voucher": false, "hide_without_voucher": false,
"description": null, "description": null,
"meta_data": {}, "meta_data": {},
@@ -564,9 +536,7 @@ Endpoints
"position": 0, "position": 0,
"picture": null, "picture": null,
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hidden_if_available": null, "hidden_if_available": null,
"hidden_if_item_available": null, "hidden_if_item_available": null,
"require_voucher": false, "require_voucher": false,
@@ -610,9 +580,7 @@ Endpoints
"require_membership_types": [], "require_membership_types": [],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hide_without_voucher": false, "hide_without_voucher": false,
"description": null, "description": null,
"meta_data": {}, "meta_data": {},
@@ -632,9 +600,7 @@ Endpoints
"require_membership_types": [], "require_membership_types": [],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hide_without_voucher": false, "hide_without_voucher": false,
"description": null, "description": null,
"meta_data": {}, "meta_data": {},
@@ -676,9 +642,7 @@ Endpoints
"position": 0, "position": 0,
"picture": null, "picture": null,
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hidden_if_available": null, "hidden_if_available": null,
"hidden_if_item_available": null, "hidden_if_item_available": null,
"require_voucher": false, "require_voucher": false,
@@ -723,9 +687,7 @@ Endpoints
"require_membership_types": [], "require_membership_types": [],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hide_without_voucher": false, "hide_without_voucher": false,
"description": null, "description": null,
"meta_data": {}, "meta_data": {},
@@ -745,9 +707,7 @@ Endpoints
"require_membership_types": [], "require_membership_types": [],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hide_without_voucher": false, "hide_without_voucher": false,
"description": null, "description": null,
"meta_data": {}, "meta_data": {},
@@ -820,9 +780,7 @@ Endpoints
"position": 0, "position": 0,
"picture": null, "picture": null,
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hidden_if_available": null, "hidden_if_available": null,
"hidden_if_item_available": null, "hidden_if_item_available": null,
"require_voucher": false, "require_voucher": false,
@@ -867,9 +825,7 @@ Endpoints
"require_membership_types": [], "require_membership_types": [],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hide_without_voucher": false, "hide_without_voucher": false,
"description": null, "description": null,
"meta_data": {}, "meta_data": {},
@@ -889,9 +845,7 @@ Endpoints
"require_membership_types": [], "require_membership_types": [],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide",
"available_until": null, "available_until": null,
"available_until_mode": "hide",
"hide_without_voucher": false, "hide_without_voucher": false,
"description": null, "description": null,
"meta_data": {}, "meta_data": {},
+2 -17
View File
@@ -137,17 +137,13 @@ last_modified datetime Last modificati
The ``event`` attribute has been added. The organizer-level endpoint has been added. The ``event`` attribute has been added. The organizer-level endpoint has been added.
.. versionchanged:: 2023.9
The ``customer`` query parameter has been added.
.. versionchanged:: 2023.10 .. versionchanged:: 2023.10
The ``checkin_text`` attribute has been added. The ``checkin_text`` attribute has been added.
.. versionchanged:: 2024.1 .. versionchanged:: 2023.9
The ``expires`` attribute can now be passed during order creation. The ``customer`` query parameter has been added.
.. _order-position-resource: .. _order-position-resource:
@@ -179,11 +175,6 @@ country string Attendee countr
state string Attendee state (ISO 3166-2 code). Only supported in state string Attendee state (ISO 3166-2 code). Only supported in
AU, BR, CA, CN, MY, MX, and US, otherwise ``null``. AU, BR, CA, CN, MY, MX, and US, otherwise ``null``.
voucher integer Internal ID of the voucher used for this position (or ``null``) voucher integer Internal ID of the voucher used for this position (or ``null``)
voucher_budget_use money (string) Amount of money discounted by the voucher, corresponding
to how much of the ``budget`` of the voucher is consumed.
**Important:** Do not rely on this amount to be a useful
value if the position's price, product or voucher
are changed *after* the order was created. Can be ``null``.
tax_rate decimal (string) VAT rate applied for this position tax_rate decimal (string) VAT rate applied for this position
tax_value money (string) VAT included in this position tax_value money (string) VAT included in this position
tax_rule integer The ID of the used tax rule (or ``null``) tax_rule integer The ID of the used tax rule (or ``null``)
@@ -372,7 +363,6 @@ List of all orders
"country": "DE", "country": "DE",
"state": null, "state": null,
"voucher": null, "voucher": null,
"voucher_budget_use": null,
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_value": "0.00", "tax_value": "0.00",
"tax_rule": null, "tax_rule": null,
@@ -595,7 +585,6 @@ Fetching individual orders
"country": "DE", "country": "DE",
"state": null, "state": null,
"voucher": null, "voucher": null,
"voucher_budget_use": null,
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": null, "tax_rule": null,
"tax_value": "0.00", "tax_value": "0.00",
@@ -740,8 +729,6 @@ Updating order fields
* ``valid_if_pending`` * ``valid_if_pending``
* ``expires``
**Example request**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
@@ -1548,7 +1535,6 @@ List of all order positions
}, },
"attendee_email": null, "attendee_email": null,
"voucher": null, "voucher": null,
"voucher_budget_use": null,
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": null, "tax_rule": null,
"tax_value": "0.00", "tax_value": "0.00",
@@ -1662,7 +1648,6 @@ Fetching individual positions
}, },
"attendee_email": null, "attendee_email": null,
"voucher": null, "voucher": null,
"voucher_budget_use": null,
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": null, "tax_rule": null,
"tax_value": "0.00", "tax_value": "0.00",
+1 -2
View File
@@ -13,8 +13,7 @@ Core
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
: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_channels, register_global_settings, quota_availability, global_email_filter, item_copy_data, register_sales_channels, 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
Order events Order events
"""""""""""" """"""""""""
+15 -24
View File
@@ -1,11 +1,10 @@
.. highlight:: python .. highlight:: python
:linenothreshold: 5 :linenothreshold: 5
Writing a template placeholder plugin Writing an e-mail placeholder plugin
===================================== ====================================
A template placeholder is a dynamic value that pretix users can use in their email templates and in other An email placeholder is a dynamic value that pretix users can use in their email templates.
configurable texts.
Please read :ref:`Creating a plugin <pluginsetup>` first, if you haven't already. Please read :ref:`Creating a plugin <pluginsetup>` first, if you haven't already.
@@ -13,31 +12,31 @@ Placeholder registration
------------------------ ------------------------
The placeholder API does not make a lot of usage from signals, however, it The placeholder API does not make a lot of usage from signals, however, it
does use a signal to get a list of all available placeholders. Your plugin does use a signal to get a list of all available email placeholders. Your plugin
should listen for this signal and return an instance of a subclass of ``pretix.base.services.placeholders.BaseTextPlaceholder``: should listen for this signal and return an instance of a subclass of ``pretix.base.email.BaseMailTextPlaceholder``:
.. code-block:: python .. code-block:: python
from django.dispatch import receiver from django.dispatch import receiver
from pretix.base.signals import register_text_placeholders from pretix.base.signals import register_mail_placeholders
@receiver(register_text_placeholders, dispatch_uid="placeholder_custom") @receiver(register_mail_placeholders, dispatch_uid="placeholder_custom")
def register_placeholder_renderers(sender, **kwargs): def register_mail_renderers(sender, **kwargs):
from .placeholders import MyPlaceholderClass from .email import MyPlaceholderClass
return MyPlaceholder() return MyPlaceholder()
Context mechanism Context mechanism
----------------- -----------------
Templates are used in different "contexts" within pretix. For example, many emails are rendered from Emails are sent in different "contexts" within pretix. For example, many emails are sent in the
templates in the context of an order, but some are not, such as the notification of a waiting list voucher. the context of an order, but some are not, such as the notification of a waiting list voucher.
Not all placeholders make sense everywhere, and placeholders usually depend on some parameters Not all placeholders make sense in every email, and placeholders usually depend some parameters
themselves, such as the ``Order`` object. Therefore, placeholders are expected to explicitly declare themselves, such as the ``Order`` object. Therefore, placeholders are expected to explicitly declare
what values they depend on and they will only be available in a context where all those dependencies are what values they depend on and they will only be available in an email if all those dependencies are
met. Currently, placeholders can depend on the following context parameters: met. Currently, placeholders can depend on the following context parameters:
* ``event`` * ``event``
@@ -52,7 +51,7 @@ There are a few more that are only to be used internally but not by plugins.
The placeholder class The placeholder class
--------------------- ---------------------
.. class:: pretix.base.services.placeholders.BaseTextPlaceholder .. class:: pretix.base.email.BaseMailTextPlaceholder
.. autoattribute:: identifier .. autoattribute:: identifier
@@ -78,15 +77,7 @@ functions:
.. code-block:: python .. code-block:: python
placeholder = SimpleFunctionalTextPlaceholder( placeholder = SimpleFunctionalMailTextPlaceholder(
'code', ['order'], lambda order: order.code, sample='F8VVL' 'code', ['order'], lambda order: order.code, sample='F8VVL'
) )
Signals
-------
.. automodule:: pretix.base.signals
:members: register_text_placeholders
.. automodule:: pretix.base.signals
:members: register_mail_placeholders
-3
View File
@@ -32,7 +32,6 @@ transactions list of objects Transactions in
├ checksum string Checksum computed from payer, reference, amount and ├ checksum string Checksum computed from payer, reference, amount and
date date
├ payer string Payment source ├ payer string Payment source
├ external_id string Unique ID of the payment from an external source
├ reference string Payment reference ├ reference string Payment reference
├ amount string Payment amount ├ amount string Payment amount
├ iban string Payment IBAN ├ iban string Payment IBAN
@@ -86,7 +85,6 @@ Endpoints
"date": "26.06.2017", "date": "26.06.2017",
"payer": "John Doe", "payer": "John Doe",
"order": null, "order": null,
"external_id": null,
"iban": "", "iban": "",
"bic": "", "bic": "",
"checksum": "5de03a601644dfa63420dacfd285565f8375a8f2", "checksum": "5de03a601644dfa63420dacfd285565f8375a8f2",
@@ -141,7 +139,6 @@ Endpoints
"iban": "", "iban": "",
"bic": "", "bic": "",
"order": null, "order": null,
"external_id": null,
"checksum": "5de03a601644dfa63420dacfd285565f8375a8f2", "checksum": "5de03a601644dfa63420dacfd285565f8375a8f2",
"reference": "GUTSCHRIFT\r\nSAMPLECONF-NAB12 EREF: SAMPLECONF-NAB12\r\nIBAN: DE1234556…", "reference": "GUTSCHRIFT\r\nSAMPLECONF-NAB12 EREF: SAMPLECONF-NAB12\r\nIBAN: DE1234556…",
"state": "nomatch", "state": "nomatch",
+7 -27
View File
@@ -37,12 +37,9 @@ contact_email string Contact person
booth string Booth number (or ``null``). Maximum 100 characters. booth string Booth number (or ``null``). Maximum 100 characters.
locale string Locale for communication with the exhibitor. locale string Locale for communication with the exhibitor.
access_code string Access code for the exhibitor to access their data or use the lead scanning app (read-only). access_code string Access code for the exhibitor to access their data or use the lead scanning app (read-only).
lead_scanning_access_code string Access code for the exhibitor to use the lead scanning app but not access data (read-only).
allow_lead_scanning boolean Enables lead scanning app allow_lead_scanning boolean Enables lead scanning app
allow_lead_access boolean Enables access to data gathered by the lead scanning app allow_lead_access boolean Enables access to data gathered by the lead scanning app
allow_voucher_access boolean Enables access to data gathered by exhibitor vouchers allow_voucher_access boolean Enables access to data gathered by exhibitor vouchers
lead_scanning_scope_by_device string Enables lead scanning to be handled as one lead per attendee
per scanning device, instead of only per exhibitor.
comment string Internal comment, not shown to exhibitor comment string Internal comment, not shown to exhibitor
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
@@ -65,7 +62,6 @@ data list of objects Attendee data s
except in a few cases where it contains an additional list of objects except in a few cases where it contains an additional list of objects
with ``value`` and ``label`` keys (e.g. splitting of names). with ``value`` and ``label`` keys (e.g. splitting of names).
device_name string User-defined name for the device used for scanning (or ``null``). device_name string User-defined name for the device used for scanning (or ``null``).
device_uuid string UUID of device used for scanning (or ``null``).
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
Endpoints Endpoints
@@ -111,9 +107,7 @@ Endpoints
"contact_email": "johnson@as.example.org", "contact_email": "johnson@as.example.org",
"booth": "A2", "booth": "A2",
"locale": "de", "locale": "de",
"access_code": "VKHZ2FU84", "access_code": "VKHZ2FU8",
"lead_scanning_access_code": "WVK2B8PZ",
"lead_scanning_scope_by_device": false,
"allow_lead_scanning": true, "allow_lead_scanning": true,
"allow_lead_access": true, "allow_lead_access": true,
"allow_voucher_access": true, "allow_voucher_access": true,
@@ -164,9 +158,7 @@ Endpoints
"contact_email": "johnson@as.example.org", "contact_email": "johnson@as.example.org",
"booth": "A2", "booth": "A2",
"locale": "de", "locale": "de",
"access_code": "VKHZ2FU84", "access_code": "VKHZ2FU8",
"lead_scanning_access_code": "WVK2B8PZ",
"lead_scanning_scope_by_device": false,
"allow_lead_scanning": true, "allow_lead_scanning": true,
"allow_lead_access": true, "allow_lead_access": true,
"allow_voucher_access": true, "allow_voucher_access": true,
@@ -396,9 +388,7 @@ Endpoints
"contact_email": "johnson@as.example.org", "contact_email": "johnson@as.example.org",
"booth": "A2", "booth": "A2",
"locale": "de", "locale": "de",
"access_code": "VKHZ2FU84", "access_code": "VKHZ2FU8",
"lead_scanning_access_code": "WVK2B8PZ",
"lead_scanning_scope_by_device": false,
"allow_lead_scanning": true, "allow_lead_scanning": true,
"allow_lead_access": true, "allow_lead_access": true,
"allow_voucher_access": true, "allow_voucher_access": true,
@@ -456,9 +446,7 @@ Endpoints
"contact_email": "johnson@as.example.org", "contact_email": "johnson@as.example.org",
"booth": "A2", "booth": "A2",
"locale": "de", "locale": "de",
"access_code": "VKHZ2FU84", "access_code": "VKHZ2FU8",
"lead_scanning_access_code": "WVK2B8PZ",
"lead_scanning_scope_by_device": false,
"allow_lead_scanning": true, "allow_lead_scanning": true,
"allow_lead_access": true, "allow_lead_access": true,
"allow_voucher_access": true, "allow_voucher_access": true,
@@ -573,7 +561,6 @@ name string Exhibitor name
booth string Booth number (or ``null``) booth string Booth number (or ``null``)
event object Object describing the event event object Object describing the event
├ name multi-lingual string Event name ├ name multi-lingual string Event name
├ end_date datetime End date of the event. After this time, the app could show a warning that the event is over.
├ imprint_url string URL to legal notice page. If not ``null``, a button in the app should link to this page. ├ imprint_url string URL to legal notice page. If not ``null``, a button in the app should link to this page.
├ privacy_url string URL to privacy notice page. If not ``null``, a button in the app should link to this page. ├ privacy_url string URL to privacy notice page. If not ``null``, a button in the app should link to this page.
├ help_url string URL to help page. If not ``null``, a button in the app should link to this page. ├ help_url string URL to help page. If not ``null``, a button in the app should link to this page.
@@ -609,7 +596,6 @@ scan_types list of objects Only used for a
"booth": "A2", "booth": "A2",
"event": { "event": {
"name": {"en": "Sample conference", "de": "Beispielkonferenz"}, "name": {"en": "Sample conference", "de": "Beispielkonferenz"},
"end_date": "2017-12-28T10:00:00+00:00",
"slug": "bigevents", "slug": "bigevents",
"imprint_url": null, "imprint_url": null,
"privacy_url": null, "privacy_url": null,
@@ -648,7 +634,6 @@ On the request, you should set the following properties:
* ``tags`` with the list of selected tags * ``tags`` with the list of selected tags
* ``rating`` with the rating assigned by the exhibitor * ``rating`` with the rating assigned by the exhibitor
* ``device_name`` with a user-specified name of the device used for scanning (max. 190 characters), or ``null`` * ``device_name`` with a user-specified name of the device used for scanning (max. 190 characters), or ``null``
* ``device_uuid`` with a auto-generated UUID of the device used for scanning, or ``null``
If you submit ``tags`` and ``rating`` to be ``null`` and ``notes`` to be ``""``, the server If you submit ``tags`` and ``rating`` to be ``null`` and ``notes`` to be ``""``, the server
responds with the previously saved information and will not delete that information. If you responds with the previously saved information and will not delete that information. If you
@@ -683,8 +668,7 @@ The request for this looks like this:
"scan_type": "lead", "scan_type": "lead",
"tags": ["foo"], "tags": ["foo"],
"rating": 4, "rating": 4,
"device_name": "DEV1", "device_name": "DEV1"
"device_uuid": "d8c2ec53-d602-4a08-882d-db4cf54344a2"
} }
**Example response:** **Example response:**
@@ -717,9 +701,7 @@ The request for this looks like this:
}, },
"rating": 4, "rating": 4,
"tags": ["foo"], "tags": ["foo"],
"notes": "Great customer, wants our newsletter", "notes": "Great customer, wants our newsletter"
"device_name": "DEV1",
"device_uuid": "d8c2ec53-d602-4a08-882d-db4cf54344a2"
} }
:statuscode 200: No error, leads was not scanned for the first time :statuscode 200: No error, leads was not scanned for the first time
@@ -774,9 +756,7 @@ You can also fetch existing leads (if you are authorized to do so):
}, },
"rating": 4, "rating": 4,
"tags": ["foo"], "tags": ["foo"],
"notes": "Great customer, wants our newsletter", "notes": "Great customer, wants our newsletter"
"device_name": "DEV1",
"device_uuid": "d8c2ec53-d602-4a08-882d-db4cf54344a2"
} }
] ]
} }
+1 -1
View File
@@ -1,4 +1,4 @@
sphinx==7.2.* sphinx==7.0.*
jinja2==3.1.* jinja2==3.1.*
sphinx-rtd-theme sphinx-rtd-theme
sphinxcontrib-httpdomain sphinxcontrib-httpdomain
+1 -1
View File
@@ -1,5 +1,5 @@
-e ../ -e ../
sphinx==7.2.* sphinx==7.0.*
jinja2==3.1.* jinja2==3.1.*
sphinx-rtd-theme sphinx-rtd-theme
sphinxcontrib-httpdomain sphinxcontrib-httpdomain
+3 -9
View File
@@ -194,23 +194,17 @@ A complete record could look like this::
v=spf1 a mx include:_spf.pretix.eu ~all v=spf1 a mx include:_spf.pretix.eu ~all
Make sure to read up on the `SPF specification`_. Make sure to read up on the `SPF specification`_. If you want to authenticate your emails with DKIM, set up a DNS TXT
record for the subdomain ``pretix._domainkey`` with the following contents::
If you want to authenticate your emails with `DKIM`_, set up a ``CNAME`` record for the subdomain ``pretix._domainkey`` v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDXrDk6lwOWX00e2MbiiJac6huI+gnzLf9N4G1FnBv3PXq8fz3i2q1szH72OF5mAlKm3zXO4cl/uxx+lfidS1ERbX6Bn9BRstBTQUKWC4JFj8Yk9+fwT7LWehDURazLdTzfsIjJFudLLvxtOKSaOCtMhbPX05DIhziaqVCBqgz/NQIDAQAB
pointing to ``dkim.pretix.eu``::
pretix._domainkey.mydomain.com. CNAME dkim.pretix.eu.
Then, please contact support@pretix.eu and we will enable DKIM for your domain on our mail servers. Then, please contact support@pretix.eu and we will enable DKIM for your domain on our mail servers.
For senders with larger volumes, Google Mail also requires you to have a `DMARC`_ policy (that may however be ``p=none``).
.. note:: Many SMTP servers impose rate limits on the sent emails, such as a maximum number of emails sent per hour. .. note:: Many SMTP servers impose rate limits on the sent emails, such as a maximum number of emails sent per hour.
These SMTP servers are often not suitable for use with pretix, in case you want to send an email to many These SMTP servers are often not suitable for use with pretix, in case you want to send an email to many
hundreds or thousands of ticket buyers. Depending on how the rate limit is implemented, emails might be lost hundreds or thousands of ticket buyers. Depending on how the rate limit is implemented, emails might be lost
in this case, as pretix only retries email delivery for a certain time period. in this case, as pretix only retries email delivery for a certain time period.
.. _DKIM: https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail
.. _Sender Policy Framework: https://en.wikipedia.org/wiki/Sender_Policy_Framework .. _Sender Policy Framework: https://en.wikipedia.org/wiki/Sender_Policy_Framework
.. _SPF specification: http://www.open-spf.org/SPF_Record_Syntax .. _SPF specification: http://www.open-spf.org/SPF_Record_Syntax
.. _DMARC: https://en.wikipedia.org/wiki/DMARC
@@ -19,3 +19,4 @@ Then, head to the **Bundled products** tab of the "conference ticket" and add th
Once a customer tries to buy the € 450 conference ticket, a sub-product will be added and the price will automatically be split into the two components, leading to a correct computation of taxes. Once a customer tries to buy the € 450 conference ticket, a sub-product will be added and the price will automatically be split into the two components, leading to a correct computation of taxes.
You can find more use cases in these specialized guides:
+2 -23
View File
@@ -138,7 +138,7 @@ the button-style of that checkbox with the one in the pretix shop, you can use t
.. note:: .. note::
Due to compatibility with existing widget installations, the default value for ``single-item-select`` Due to compatibilty with existing widget installations, the default value for ``single-item-select``
is ``checkbox``. This might change in the future, so make sure, to set the attribute to is ``checkbox``. This might change in the future, so make sure, to set the attribute to
``single-item-select="checkbox"`` if you need it. ``single-item-select="checkbox"`` if you need it.
@@ -196,7 +196,7 @@ settings. For example, if you set up a meta data property called "Promoted" that
<pretix-widget event="https://pretix.eu/demo/series/" list-type="list" filter="attr[Promoted]=Yes"></pretix-widget> <pretix-widget event="https://pretix.eu/demo/series/" list-type="list" filter="attr[Promoted]=Yes"></pretix-widget>
If you have enabled public filters in your meta data attribute configuration, a filter-form shows up. To disable, use:: If you have enabled public filters in your meta data attribute configuration, a filter formshows up. To disable, use::
<pretix-widget event="https://pretix.eu/demo/democon/" disable-filters></pretix-widget> <pretix-widget event="https://pretix.eu/demo/democon/" disable-filters></pretix-widget>
@@ -429,25 +429,4 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
}); });
</script> </script>
Offering wallet payments (Apple Pay, Google Pay) within the widget
------------------------------------------------------------------
Some payment providers (such as Stripe) also offer Apple or Google Pay. But in order to use them, the domain of the
payment needs to be approved first. As of right now, pretix will take care of the domain verification process for you
automatically, when using Stripe. However, pretix can only validate the domain that is being used for your default,
"stand-alone" shop (such as https://pretix.eu/demo/democon/ ).
When embedding the widget on your website, the domain of the embedding page will also need to be validated in order to
be able to use it for wallet payments.
The details might vary from payment provider to payment provider, but generally speaking, it will either involve just
telling your payment provider the domain name and (for Apple Pay) placing an
``apple-developer-merchantid-domain-association``-file into the ``.well-known``-directory of your domain.
Further reading:
* `Stripe Payment Method Domain registration`_
.. _Let's Encrypt: https://letsencrypt.org/ .. _Let's Encrypt: https://letsencrypt.org/
.. _Stripe Payment Method Domain registration: https://stripe.com/docs/payments/payment-methods/pmd-registration
+1 -1
View File
@@ -145,7 +145,7 @@ to get a better plain text representation of your text. Note however, that for
security reasons you can only use the following HTML elements:: security reasons you can only use the following HTML elements::
a, abbr, acronym, b, br, code, div, em, h1, h2, a, abbr, acronym, b, br, code, div, em, h1, h2,
h3, h4, h5, h6, hr, i, li, ol, p, pre, s, span, strong, h3, h4, h5, h6, hr, i, li, ol, p, pre, span, strong,
table, tbody, td, thead, tr, ul table, tbody, td, thead, tr, ul
Additionally, only the following attributes are allowed on them:: Additionally, only the following attributes are allowed on them::
+36 -33
View File
@@ -31,41 +31,41 @@ dependencies = [
"BeautifulSoup4==4.12.*", "BeautifulSoup4==4.12.*",
"bleach==5.0.*", "bleach==5.0.*",
"celery==5.3.*", "celery==5.3.*",
"chardet==5.2.*", "chardet==5.1.*",
"cryptography>=3.4.2", "cryptography>=3.4.2",
"css-inline==0.13.*", "css-inline==0.8.*",
"defusedcsv>=1.1.0", "defusedcsv>=1.1.0",
"dj-static", "dj-static",
"Django==4.2.*", "Django==4.2.*",
"django-bootstrap3==23.6.*", "django-bootstrap3==23.1.*",
"django-compressor==4.4", "django-compressor==4.3.*",
"django-countries==7.5.*", "django-countries==7.5.*",
"django-filter==23.5", "django-filter==23.2",
"django-formset-js-improved==0.5.0.3", "django-formset-js-improved==0.5.0.3",
"django-formtools==2.5.1", "django-formtools==2.4.1",
"django-hierarkey==1.1.*", "django-hierarkey==1.1.*",
"django-hijack==3.4.*", "django-hijack==3.3.*",
"django-i18nfield==1.9.*,>=1.9.4", "django-i18nfield==1.9.*,>=1.9.4",
"django-libsass==0.9", "django-libsass==0.9",
"django-localflavor==4.0", "django-localflavor==4.0",
"django-markup", "django-markup",
"django-oauth-toolkit==2.3.*", "django-oauth-toolkit==2.2.*",
"django-otp==1.3.*", "django-otp==1.2.*",
"django-phonenumber-field==7.3.*", "django-phonenumber-field==7.1.*",
"django-redis==5.4.*", "django-redis==5.2.*",
"django-scopes==2.0.*", "django-scopes==2.0.*",
"django-statici18n==2.4.*", "django-statici18n==2.3.*",
"djangorestframework==3.14.*", "djangorestframework==3.14.*",
"dnspython==2.5.*", "dnspython==2.3.*",
"drf_ujson2==1.7.*", "drf_ujson2==1.7.*",
"geoip2==4.*", "geoip2==4.*",
"importlib_metadata==7.*", # Polyfill, we can probably drop this once we require Python 3.10+ "importlib_metadata==6.*", # Polyfill, we can probably drop this once we require Python 3.10+
"isoweek", "isoweek",
"jsonschema", "jsonschema",
"kombu==5.3.*", "kombu==5.3.*",
"libsass==0.23.*", "libsass==0.22.*",
"lxml", "lxml",
"markdown==3.5.2", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3. "markdown==3.4.3", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7 # We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
"mt-940==4.30.*", "mt-940==4.30.*",
"oauthlib==3.2.*", "oauthlib==3.2.*",
@@ -73,60 +73,63 @@ dependencies = [
"packaging", "packaging",
"paypalrestsdk==1.13.*", "paypalrestsdk==1.13.*",
"paypal-checkout-serversdk==1.0.*", "paypal-checkout-serversdk==1.0.*",
"PyJWT==2.8.*", "PyJWT==2.7.*",
"phonenumberslite==8.13.*", "phonenumberslite==8.13.*",
"Pillow==10.2.*", "Pillow==9.5.*",
"pretix-plugin-build", "pretix-plugin-build",
"protobuf==4.25.*", "protobuf==4.23.*",
"psycopg2-binary", "psycopg2-binary",
"pycountry", "pycountry",
"pycparser==2.21", "pycparser==2.21",
"pycryptodome==3.20.*", "pycryptodome==3.18.*",
"pypdf==3.9.*", "pypdf==3.9.*",
"python-bidi==0.4.*", # Support for Arabic in reportlab "python-bidi==0.4.*", # Support for Arabic in reportlab
"python-dateutil==2.8.*", "python-dateutil==2.8.*",
"python-u2flib-server==4.*",
"pytz", "pytz",
"pytz-deprecation-shim==0.1.*", "pytz-deprecation-shim==0.1.*",
"pyuca", "pyuca",
"qrcode==7.4.*", "qrcode==7.4.*",
"redis==5.0.*", "redis==4.6.*",
"reportlab==4.1.*", "reportlab==4.0.*",
"requests==2.31.*", "requests==2.31.*",
"sentry-sdk==1.40.*", "sentry-sdk==1.15.*",
"sepaxml==2.6.*", "sepaxml==2.6.*",
"slimit", "slimit",
"static3==0.7.*", "static3==0.7.*",
"stripe==7.9.*", "stripe==5.4.*",
"text-unidecode==1.*", "text-unidecode==1.*",
"tlds>=2020041600", "tlds>=2020041600",
"tqdm==4.*", "tqdm==4.*",
"vat_moss_forked==2020.3.20.0.11.0", "vat_moss_forked==2020.3.20.0.11.0",
"vobject==0.9.*", "vobject==0.9.*",
"webauthn==2.0.*", "webauthn==0.4.*",
"zeep==4.2.*" "zeep==4.2.*"
] ]
[project.optional-dependencies] [project.optional-dependencies]
memcached = ["pylibmc"] memcached = ["pylibmc"]
dev = [ dev = [
"aiohttp==3.9.*", "aiohttp==3.8.*",
"coverage", "coverage",
"coveralls", "coveralls",
"fakeredis==2.21.*", "fakeredis==2.18.*",
"flake8==7.0.*", "flake8==6.0.*",
"freezegun", "freezegun",
"isort==5.13.*", "isort==5.12.*",
"pep8-naming==0.13.*", "pep8-naming==0.13.*",
"potypo", "potypo",
"pycodestyle==2.10.*",
"pyflakes==3.0.*",
"pytest-asyncio", "pytest-asyncio",
"pytest-cache", "pytest-cache",
"pytest-cov", "pytest-cov",
"pytest-django==4.*", "pytest-django==4.*",
"pytest-mock==3.12.*", "pytest-mock==3.10.*",
"pytest-rerunfailures==13.*", "pytest-rerunfailures==11.*",
"pytest-sugar", "pytest-sugar",
"pytest-xdist==3.5.*", "pytest-xdist==3.3.*",
"pytest==8.0.*", "pytest==7.3.*",
"responses", "responses",
] ]
+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__ = "2024.2.0" __version__ = "2023.11.0.dev0"
+2 -5
View File
@@ -267,10 +267,9 @@ CACHE_LARGE_VALUES_ALIAS = 'default'
FILE_UPLOAD_EXTENSIONS_IMAGE = (".png", ".jpg", ".gif", ".jpeg") FILE_UPLOAD_EXTENSIONS_IMAGE = (".png", ".jpg", ".gif", ".jpeg")
PILLOW_FORMATS_IMAGE = ('PNG', 'GIF', 'JPEG') PILLOW_FORMATS_IMAGE = ('PNG', 'GIF', 'JPEG')
FILE_UPLOAD_EXTENSIONS_FAVICON = (".ico", ".png", ".jpg", ".gif", ".jpeg") FILE_UPLOAD_EXTENSIONS_FAVICON = (".ico", ".png", "jpg", ".gif", ".jpeg")
PILLOW_FORMATS_QUESTIONS_FAVICON = ('PNG', 'GIF', 'JPEG', 'ICO')
FILE_UPLOAD_EXTENSIONS_QUESTION_IMAGE = (".png", ".jpg", ".gif", ".jpeg", ".bmp", ".tif", ".tiff", ".jfif") FILE_UPLOAD_EXTENSIONS_QUESTION_IMAGE = (".png", "jpg", ".gif", ".jpeg", ".bmp", ".tif", ".tiff", ".jfif")
PILLOW_FORMATS_QUESTIONS_IMAGE = ('PNG', 'GIF', 'JPEG', 'BMP', 'TIFF') PILLOW_FORMATS_QUESTIONS_IMAGE = ('PNG', 'GIF', 'JPEG', 'BMP', 'TIFF')
FILE_UPLOAD_EXTENSIONS_EMAIL_ATTACHMENT = ( FILE_UPLOAD_EXTENSIONS_EMAIL_ATTACHMENT = (
@@ -279,5 +278,3 @@ FILE_UPLOAD_EXTENSIONS_EMAIL_ATTACHMENT = (
".bmp", ".tif", ".tiff" ".bmp", ".tif", ".tiff"
) )
FILE_UPLOAD_EXTENSIONS_OTHER = FILE_UPLOAD_EXTENSIONS_EMAIL_ATTACHMENT FILE_UPLOAD_EXTENSIONS_OTHER = FILE_UPLOAD_EXTENSIONS_EMAIL_ATTACHMENT
PRETIX_MAX_ORDER_SIZE = 500
-1
View File
@@ -38,7 +38,6 @@ MAIL_FROM_ORGANIZERS = 'invalid@invalid'
FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT = 10 FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT = 10
FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT = 10 FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT = 10
FILE_UPLOAD_MAX_SIZE_IMAGE = 10 FILE_UPLOAD_MAX_SIZE_IMAGE = 10
FILE_UPLOAD_MAX_SIZE_FAVICON = 10
DEFAULT_CURRENCY = 'EUR' DEFAULT_CURRENCY = 'EUR'
SECRET_KEY = "build-time-secret-key" SECRET_KEY = "build-time-secret-key"
HAS_REDIS = False HAS_REDIS = False
-5
View File
@@ -19,8 +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 logging
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from rest_framework import exceptions from rest_framework import exceptions
@@ -31,8 +29,6 @@ from pretix.api.auth.devicesecurity import (
) )
from pretix.base.models import Device from pretix.base.models import Device
logger = logging.getLogger(__name__)
class DeviceTokenAuthentication(TokenAuthentication): class DeviceTokenAuthentication(TokenAuthentication):
model = Device model = Device
@@ -50,7 +46,6 @@ class DeviceTokenAuthentication(TokenAuthentication):
raise exceptions.AuthenticationFailed('Device has not been initialized.') raise exceptions.AuthenticationFailed('Device has not been initialized.')
if device.revoked: if device.revoked:
logging.warning(f'Connection attempt of revoked device {device.pk}.')
raise exceptions.AuthenticationFailed('Device access has been revoked.') raise exceptions.AuthenticationFailed('Device access has been revoked.')
return AnonymousUser(), device return AnonymousUser(), device
-2
View File
@@ -185,7 +185,6 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:order-detail'), ('GET', 'api-v1:order-detail'),
('DELETE', 'api-v1:orderposition-detail'), ('DELETE', 'api-v1:orderposition-detail'),
('PATCH', 'api-v1:orderposition-detail'), ('PATCH', 'api-v1:orderposition-detail'),
('GET', 'api-v1:orderposition-list'),
('GET', 'api-v1:orderposition-answer'), ('GET', 'api-v1:orderposition-answer'),
('GET', 'api-v1:orderposition-pdf_image'), ('GET', 'api-v1:orderposition-pdf_image'),
('POST', 'api-v1:order-mark-canceled'), ('POST', 'api-v1:order-mark-canceled'),
@@ -224,7 +223,6 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('POST', 'api-v1:checkinrpc.redeem'), ('POST', 'api-v1:checkinrpc.redeem'),
('GET', 'api-v1:checkinrpc.search'), ('GET', 'api-v1:checkinrpc.search'),
('POST', 'api-v1:reusablemedium-lookup'), ('POST', 'api-v1:reusablemedium-lookup'),
('GET', 'api-v1:reusablemedium-list'),
('POST', 'api-v1:reusablemedium-list'), ('POST', 'api-v1:reusablemedium-list'),
) )
-49
View File
@@ -1,49 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from rest_framework import exceptions
from rest_framework.authentication import (
SessionAuthentication as BaseSessionAuthentication,
)
from pretix.multidomain.middlewares import CsrfViewMiddleware
class CustomCSRFCheck(CsrfViewMiddleware):
def _reject(self, request, reason):
# Return the failure reason instead of an HttpResponse
return reason
class SessionAuthentication(BaseSessionAuthentication):
# Override from DRF to user our custom CSRF middleware
def enforce_csrf(self, request):
def dummy_get_response(request): # pragma: no cover
return None
check = CustomCSRFCheck(dummy_get_response)
# populates request.META['CSRF_COOKIE'], which is used in process_view()
check.process_request(request)
reason = check.process_view(request, None, (), {})
if reason:
# CSRF failed, bail with explicit error message
raise exceptions.PermissionDenied('CSRF Failed: %s' % reason)
+1 -1
View File
@@ -54,7 +54,7 @@ class IdempotencyMiddleware:
auth_hash_parts = '{}:{}'.format( auth_hash_parts = '{}:{}'.format(
request.headers.get('Authorization', ''), request.headers.get('Authorization', ''),
request.COOKIES.get('__Host-' + settings.SESSION_COOKIE_NAME, request.COOKIES.get(settings.SESSION_COOKIE_NAME, '')) request.COOKIES.get(settings.SESSION_COOKIE_NAME, '')
) )
auth_hash = sha1(auth_hash_parts.encode()).hexdigest() auth_hash = sha1(auth_hash_parts.encode()).hexdigest()
idempotency_key = request.headers.get('X-Idempotency-Key', '') idempotency_key = request.headers.get('X-Idempotency-Key', '')
@@ -1,18 +0,0 @@
# Generated by Django 4.2.10 on 2024-02-12 11:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixapi", "0011_bigint"),
]
operations = [
migrations.AddField(
model_name="oauthapplication",
name="post_logout_redirect_uris",
field=models.TextField(default=""),
),
]
-5
View File
@@ -42,11 +42,6 @@ class OAuthApplication(AbstractApplication):
verbose_name=_("Redirection URIs"), verbose_name=_("Redirection URIs"),
help_text=_("Allowed URIs list, space separated") help_text=_("Allowed URIs list, space separated")
) )
post_logout_redirect_uris = models.TextField(
blank=True, validators=[URIValidator],
help_text=_("Allowed Post Logout URIs list, space separated"),
default="",
)
client_id = models.CharField( client_id = models.CharField(
verbose_name=_("Client ID"), verbose_name=_("Client ID"),
max_length=100, unique=True, default=generate_client_id, db_index=True max_length=100, unique=True, default=generate_client_id, db_index=True
+1 -1
View File
@@ -424,7 +424,7 @@ class CloneEventSerializer(EventSerializer):
new_event = super().create({**validated_data, 'plugins': None}) new_event = super().create({**validated_data, 'plugins': None})
event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first() 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)
if plugins is not None: if plugins is not None:
new_event.set_active_plugins(plugins) new_event.set_active_plugins(plugins)
+3 -6
View File
@@ -61,8 +61,7 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
fields = ('id', 'value', 'active', 'description', fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price', 'original_price', 'free_price_suggestion', 'require_approval', 'position', 'default_price', 'price', 'original_price', 'free_price_suggestion', 'require_approval',
'require_membership', 'require_membership_types', 'require_membership_hidden', 'require_membership', 'require_membership_types', 'require_membership_hidden',
'checkin_attention', 'checkin_text', 'checkin_attention', 'checkin_text', 'available_from', 'available_until',
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
'sales_channels', 'hide_without_voucher', 'meta_data') 'sales_channels', 'hide_without_voucher', 'meta_data')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -86,8 +85,7 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
fields = ('id', 'value', 'active', 'description', fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price', 'original_price', 'free_price_suggestion', 'require_approval', 'position', 'default_price', 'price', 'original_price', 'free_price_suggestion', 'require_approval',
'require_membership', 'require_membership_types', 'require_membership_hidden', 'require_membership', 'require_membership_types', 'require_membership_hidden',
'checkin_attention', 'checkin_text', 'checkin_attention', 'checkin_text', 'available_from', 'available_until',
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
'sales_channels', 'hide_without_voucher', 'meta_data') 'sales_channels', 'hide_without_voucher', 'meta_data')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -237,8 +235,7 @@ class ItemSerializer(I18nAwareModelSerializer):
model = Item model = Item
fields = ('id', 'category', 'name', 'internal_name', 'active', 'sales_channels', 'description', fields = ('id', 'category', 'name', 'internal_name', 'active', 'sales_channels', 'description',
'default_price', 'free_price', 'free_price_suggestion', 'tax_rate', 'tax_rule', 'admission', 'default_price', 'free_price', 'free_price_suggestion', 'tax_rate', 'tax_rule', 'admission',
'personalized', 'position', 'picture', 'personalized', 'position', 'picture', 'available_from', 'available_until',
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling', 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
'min_per_order', 'max_per_order', 'checkin_attention', 'checkin_text', 'has_variations', 'variations', 'min_per_order', 'max_per_order', 'checkin_attention', 'checkin_text', 'has_variations', 'variations',
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets', 'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
+4 -15
View File
@@ -486,11 +486,11 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
'company', 'street', 'zipcode', 'city', 'country', 'state', 'discount', 'company', 'street', 'zipcode', 'city', 'country', 'state', 'discount',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled',
'valid_from', 'valid_until', 'blocked', 'voucher_budget_use') 'valid_from', 'valid_until', 'blocked')
read_only_fields = ( read_only_fields = (
'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret', 'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret',
'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data',
'seat', 'canceled', 'discount', 'valid_from', 'valid_until', 'blocked', 'voucher_budget_use' 'seat', 'canceled', 'discount', 'valid_from', 'valid_until', 'blocked'
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -1035,14 +1035,13 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['positions'].child.fields['voucher'].queryset = self.context['event'].vouchers.all() self.fields['positions'].child.fields['voucher'].queryset = self.context['event'].vouchers.all()
self.fields['customer'].queryset = self.context['event'].organizer.customers.all() self.fields['customer'].queryset = self.context['event'].organizer.customers.all()
self.fields['expires'].required = False
class Meta: class Meta:
model = Order model = Order
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') 'require_approval', 'valid_if_pending')
def validate_payment_provider(self, pp): def validate_payment_provider(self, pp):
if pp is None: if pp is None:
@@ -1051,11 +1050,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_expires(self, expires):
if expires < now():
raise ValidationError('Expiration date must be in the future.')
return expires
def validate_sales_channel(self, channel): def validate_sales_channel(self, channel):
if channel not in get_all_sales_channels(): if channel not in get_all_sales_channels():
raise ValidationError('Unknown sales channel.') raise ValidationError('Unknown sales channel.')
@@ -1077,10 +1071,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
raise ValidationError( raise ValidationError(
'An order cannot be empty.' 'An order cannot be empty.'
) )
if len(data) > settings.PRETIX_MAX_ORDER_SIZE:
raise ValidationError(
'Orders cannot have more than %(max)s positions.' % {'max': settings.PRETIX_MAX_ORDER_SIZE}
)
errs = [{} for p in data] errs = [{} for p in data]
if any([p.get('positionid') for p in data]): if any([p.get('positionid') for p in data]):
if not all([p.get('positionid') for p in data]): if not all([p.get('positionid') for p in data]):
@@ -1366,8 +1356,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if validated_data.get('locale', None) is None: if validated_data.get('locale', None) is None:
validated_data['locale'] = self.context['event'].settings.locale validated_data['locale'] = self.context['event'].settings.locale
order = Order(event=self.context['event'], **validated_data) order = Order(event=self.context['event'], **validated_data)
if not validated_data.get('expires'): order.set_expires(subevents=[p.get('subevent') for p in positions_data])
order.set_expires(subevents=[p.get('subevent') for p in positions_data])
order.meta_info = "{}" order.meta_info = "{}"
order.total = Decimal('0.00') order.total = Decimal('0.00')
if validated_data.get('require_approval') is not None: if validated_data.get('require_approval') is not None:
+9 -31
View File
@@ -35,7 +35,6 @@ from django.http import Http404
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property 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
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from packaging.version import parse from packaging.version import parse
@@ -153,6 +152,11 @@ class CheckinListViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['POST'], url_name='failed_checkins') @action(detail=True, methods=['POST'], url_name='failed_checkins')
@transaction.atomic() @transaction.atomic()
def failed_checkins(self, *args, **kwargs): def failed_checkins(self, *args, **kwargs):
additional_log_data = {}
if 'debug_data' in self.request.data:
# Intentionally undocumented, might be removed again
additional_log_data['debug_data'] = self.request.data.pop('debug_data')
serializer = FailedCheckinSerializer( serializer = FailedCheckinSerializer(
data=self.request.data, data=self.request.data,
context={'event': self.request.event} context={'event': self.request.event}
@@ -195,14 +199,16 @@ class CheckinListViewSet(viewsets.ModelViewSet):
'reason_explanation': c.error_explanation, 'reason_explanation': c.error_explanation,
'datetime': c.datetime, 'datetime': c.datetime,
'type': c.type, 'type': c.type,
'list': c.list.pk 'list': c.list.pk,
**additional_log_data,
}, user=self.request.user, auth=self.request.auth) }, user=self.request.user, auth=self.request.auth)
else: else:
self.request.event.log_action('pretix.event.checkin.unknown', data={ self.request.event.log_action('pretix.event.checkin.unknown', data={
'datetime': c.datetime, 'datetime': c.datetime,
'type': c.type, 'type': c.type,
'list': c.list.pk, 'list': c.list.pk,
'barcode': c.raw_barcode 'barcode': c.raw_barcode,
**additional_log_data,
}, user=self.request.user, auth=self.request.auth) }, user=self.request.user, auth=self.request.auth)
return Response(serializer.data, status=201) return Response(serializer.data, status=201)
@@ -286,8 +292,6 @@ with scopes_disabled():
return queryset.filter(last_checked_in__isnull=not value) return queryset.filter(last_checked_in__isnull=not value)
def check_rules_qs(self, queryset, name, value): def check_rules_qs(self, queryset, name, value):
if not value:
return queryset
if not self.checkinlist.rules: if not self.checkinlist.rules:
return queryset return queryset
return queryset.filter( return queryset.filter(
@@ -587,32 +591,6 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data, 'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
}, status=400) }, status=400)
else: else:
if media.linked_orderposition.order.event_id not in list_by_event:
# Medium exists but connected ticket is for the wrong event
if not simulate:
checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
'datetime': datetime,
'type': checkin_type,
'list': checkinlists[0].pk,
'barcode': raw_barcode,
'searched_lists': [cl.pk for cl in checkinlists]
}, user=user, auth=auth)
Checkin.objects.create(
position=None,
successful=False,
error_reason=Checkin.REASON_INVALID,
error_explanation=gettext('Medium connected to other event'),
**common_checkin_args,
)
return Response({
'detail': 'Not found.', # for backwards compatibility
'status': 'error',
'reason': Checkin.REASON_INVALID,
'reason_explanation': gettext('Medium connected to other event'),
'require_attention': False,
'checkin_texts': [],
'list': MiniCheckinListSerializer(checkinlists[0]).data,
}, status=404)
op_candidates = [media.linked_orderposition] op_candidates = [media.linked_orderposition]
if list_by_event[media.linked_orderposition.order.event_id].addon_match: if list_by_event[media.linked_orderposition.order.event_id].addon_match:
op_candidates += list(media.linked_orderposition.addons.all()) op_candidates += list(media.linked_orderposition.addons.all())
+2 -2
View File
@@ -254,7 +254,7 @@ 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:
new_event.copy_data_from(copy_from, skip_meta_data='meta_data' in serializer.validated_data) new_event.copy_data_from(copy_from)
if plugins is not None: if plugins is not None:
new_event.set_active_plugins(plugins) new_event.set_active_plugins(plugins)
@@ -291,7 +291,7 @@ class EventViewSet(viewsets.ModelViewSet):
try: try:
with transaction.atomic(): with transaction.atomic():
instance.organizer.log_action( instance.organizer.log_action(
'pretix.event.deleted', user=self.request.user, auth=self.request.auth, 'pretix.event.deleted', user=self.request.user,
data={ data={
'event_id': instance.pk, 'event_id': instance.pk,
'name': str(instance.name), 'name': str(instance.name),
+1 -1
View File
@@ -42,7 +42,7 @@ class IdempotencyQueryView(APIView):
idempotency_key = request.GET.get("key") idempotency_key = request.GET.get("key")
auth_hash_parts = '{}:{}'.format( auth_hash_parts = '{}:{}'.format(
request.headers.get('Authorization', ''), request.headers.get('Authorization', ''),
request.COOKIES.get('__Host-' + settings.SESSION_COOKIE_NAME, request.COOKIES.get(settings.SESSION_COOKIE_NAME, '')) request.COOKIES.get(settings.SESSION_COOKIE_NAME, '')
) )
auth_hash = sha1(auth_hash_parts.encode()).hexdigest() auth_hash = sha1(auth_hash_parts.encode()).hexdigest()
if not idempotency_key: if not idempotency_key:
-2
View File
@@ -222,8 +222,6 @@ class OrderViewSetMixin:
qs = qs.prefetch_related('refunds', 'refunds__payment') qs = qs.prefetch_related('refunds', 'refunds__payment')
if 'invoice_address' not in self.request.GET.getlist('exclude'): if 'invoice_address' not in self.request.GET.getlist('exclude'):
qs = qs.select_related('invoice_address') qs = qs.select_related('invoice_address')
if 'customer' not in self.request.GET.getlist('exclude'):
qs = qs.select_related('customer')
qs = qs.prefetch_related(self._positions_prefetch(self.request)) qs = qs.prefetch_related(self._positions_prefetch(self.request))
return qs return qs
+1 -1
View File
@@ -384,7 +384,7 @@ def register_default_webhook_events(sender, **kwargs):
def notify_webhooks(logentry_ids: list): def notify_webhooks(logentry_ids: list):
if not isinstance(logentry_ids, list): if not isinstance(logentry_ids, list):
logentry_ids = [logentry_ids] logentry_ids = [logentry_ids]
qs = LogEntry.all.select_related('event', 'event__organizer', 'organizer').filter(id__in=logentry_ids) qs = LogEntry.all.select_related('event', 'event__organizer', 'organizer_link').filter(id__in=logentry_ids)
_org, _at, webhooks = None, None, None _org, _at, webhooks = None, None, None
for logentry in qs: for logentry in qs:
if not logentry.organizer: if not logentry.organizer:
+505 -12
View File
@@ -19,7 +19,10 @@
# 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 inspect
import logging import logging
from datetime import timedelta
from decimal import Decimal
from itertools import groupby from itertools import groupby
from smtplib import SMTPResponseException from smtplib import SMTPResponseException
from typing import TypeVar from typing import TypeVar
@@ -30,21 +33,21 @@ from django.core.mail.backends.smtp import EmailBackend
from django.db.models import Count from django.db.models import Count
from django.dispatch import receiver from django.dispatch import receiver
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.formats import date_format
from django.utils.timezone import now
from django.utils.translation import get_language, gettext_lazy as _ from django.utils.translation import get_language, gettext_lazy as _
from pretix.base.i18n import (
LazyCurrencyNumber, LazyDate, LazyExpiresDate, LazyNumber,
)
from pretix.base.models import Event from pretix.base.models import Event
from pretix.base.signals import register_html_mail_renderers from pretix.base.reldate import RelativeDateWrapper
from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized
from pretix.base.signals import (
register_html_mail_renderers, register_mail_placeholders,
)
from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.base.services.placeholders import ( # noqa
get_available_placeholders, PlaceholderContext
)
from pretix.base.services.placeholders import ( # noqa
BaseTextPlaceholder as BaseMailTextPlaceholder,
SimpleFunctionalTextPlaceholder as SimpleFunctionalMailTextPlaceholder,
)
from pretix.base.settings import get_name_parts_localized # noqa
logger = logging.getLogger('pretix.base.email') logger = logging.getLogger('pretix.base.email')
T = TypeVar("T", bound=EmailBackend) T = TypeVar("T", bound=EmailBackend)
@@ -189,7 +192,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
tpl = get_template(self.template_name) tpl = get_template(self.template_name)
body_html = tpl.render(htmlctx) body_html = tpl.render(htmlctx)
inliner = css_inline.CSSInliner(keep_style_tags=False) inliner = css_inline.CSSInliner(remove_style_tags=True)
body_html = inliner.inline(body_html) body_html = inliner.inline(body_html)
return body_html return body_html
@@ -214,5 +217,495 @@ def base_renderers(sender, **kwargs):
return [ClassicMailRenderer, UnembellishedMailRenderer] return [ClassicMailRenderer, UnembellishedMailRenderer]
class BaseMailTextPlaceholder:
"""
This is the base class for for all email text placeholders.
"""
@property
def required_context(self):
"""
This property should return a list of all attribute names that need to be
contained in the base context so that this placeholder is available. By default,
it returns a list containing the string "event".
"""
return ["event"]
@property
def identifier(self):
"""
This should return the identifier of this placeholder in the email.
"""
raise NotImplementedError()
def render(self, context):
"""
This method is called to generate the actual text that is being
used in the email. You will be passed a context dictionary with the
base context attributes specified in ``required_context``. You are
expected to return a plain-text string.
"""
raise NotImplementedError()
def render_sample(self, event):
"""
This method is called to generate a text to be used in email previews.
This may only depend on the event.
"""
raise NotImplementedError()
class SimpleFunctionalMailTextPlaceholder(BaseMailTextPlaceholder):
def __init__(self, identifier, args, func, sample):
self._identifier = identifier
self._args = args
self._func = func
self._sample = sample
@property
def identifier(self):
return self._identifier
@property
def required_context(self):
return self._args
def render(self, context):
return self._func(**{k: context[k] for k in self._args})
def render_sample(self, event):
if callable(self._sample):
return self._sample(event)
else:
return self._sample
def get_available_placeholders(event, base_parameters):
if 'order' in base_parameters:
base_parameters.append('invoice_address')
base_parameters.append('position_or_address')
params = {}
for r, val in register_mail_placeholders.send(sender=event):
if not isinstance(val, (list, tuple)):
val = [val]
for v in val:
if all(rp in base_parameters for rp in v.required_context):
params[v.identifier] = v
return params
def get_email_context(**kwargs): def get_email_context(**kwargs):
return PlaceholderContext(**kwargs).render_all() from pretix.base.models import InvoiceAddress
event = kwargs['event']
if 'position' in kwargs:
kwargs.setdefault("position_or_address", kwargs['position'])
if 'order' in kwargs:
try:
if not kwargs.get('invoice_address'):
kwargs['invoice_address'] = kwargs['order'].invoice_address
except InvoiceAddress.DoesNotExist:
kwargs['invoice_address'] = InvoiceAddress(order=kwargs['order'])
finally:
kwargs.setdefault("position_or_address", kwargs['invoice_address'])
ctx = {}
for r, val in register_mail_placeholders.send(sender=event):
if not isinstance(val, (list, tuple)):
val = [val]
for v in val:
if all(rp in kwargs for rp in v.required_context):
try:
ctx[v.identifier] = v.render(kwargs)
except:
ctx[v.identifier] = '(error)'
logger.exception(f'Failed to process email placeholder {v.identifier}.')
return ctx
def _placeholder_payments(order, payments):
d = []
for payment in payments:
if 'payment' in inspect.signature(payment.payment_provider.order_pending_mail_render).parameters:
d.append(str(payment.payment_provider.order_pending_mail_render(order, payment)))
else:
d.append(str(payment.payment_provider.order_pending_mail_render(order)))
d = [line for line in d if line.strip()]
if d:
return '\n\n'.join(d)
else:
return ''
def get_best_name(position_or_address, parts=False):
"""
Return the best name we got for either an invoice address or an order position, falling back to the respective other
"""
from pretix.base.models import InvoiceAddress, OrderPosition
if isinstance(position_or_address, InvoiceAddress):
if position_or_address.name:
return position_or_address.name_parts if parts else position_or_address.name
elif position_or_address.order:
position_or_address = position_or_address.order.positions.exclude(attendee_name_cached="").exclude(attendee_name_cached__isnull=True).first()
if isinstance(position_or_address, OrderPosition):
if position_or_address.attendee_name:
return position_or_address.attendee_name_parts if parts else position_or_address.attendee_name
elif position_or_address.order:
try:
return position_or_address.order.invoice_address.name_parts if parts else position_or_address.order.invoice_address.name
except InvoiceAddress.DoesNotExist:
pass
return {} if parts else ""
@receiver(register_mail_placeholders, dispatch_uid="pretixbase_register_mail_placeholders")
def base_placeholders(sender, **kwargs):
from pretix.multidomain.urlreverse import build_absolute_uri
ph = [
SimpleFunctionalMailTextPlaceholder(
'event', ['event'], lambda event: event.name, lambda event: event.name
),
SimpleFunctionalMailTextPlaceholder(
'event', ['event_or_subevent'], lambda event_or_subevent: event_or_subevent.name,
lambda event_or_subevent: event_or_subevent.name
),
SimpleFunctionalMailTextPlaceholder(
'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug
),
SimpleFunctionalMailTextPlaceholder(
'code', ['order'], lambda order: order.code, 'F8VVL'
),
SimpleFunctionalMailTextPlaceholder(
'total', ['order'], lambda order: LazyNumber(order.total), lambda event: LazyNumber(Decimal('42.23'))
),
SimpleFunctionalMailTextPlaceholder(
'currency', ['event'], lambda event: event.currency, lambda event: event.currency
),
SimpleFunctionalMailTextPlaceholder(
'order_email', ['order'], lambda order: order.email, 'john@example.org'
),
SimpleFunctionalMailTextPlaceholder(
'invoice_number', ['invoice'],
lambda invoice: invoice.full_invoice_no,
f'{sender.settings.invoice_numbers_prefix or (sender.slug.upper() + "-")}00000'
),
SimpleFunctionalMailTextPlaceholder(
'refund_amount', ['event_or_subevent', 'refund_amount'],
lambda event_or_subevent, refund_amount: LazyCurrencyNumber(refund_amount, event_or_subevent.currency),
lambda event_or_subevent: LazyCurrencyNumber(Decimal('42.23'), event_or_subevent.currency)
),
SimpleFunctionalMailTextPlaceholder(
'pending_sum', ['event', 'pending_sum'],
lambda event, pending_sum: LazyCurrencyNumber(pending_sum, event.currency),
lambda event: LazyCurrencyNumber(Decimal('42.23'), event.currency)
),
SimpleFunctionalMailTextPlaceholder(
'total_with_currency', ['event', 'order'], lambda event, order: LazyCurrencyNumber(order.total,
event.currency),
lambda event: LazyCurrencyNumber(Decimal('42.23'), event.currency)
),
SimpleFunctionalMailTextPlaceholder(
'expire_date', ['event', 'order'], lambda event, order: LazyExpiresDate(order.expires.astimezone(event.timezone)),
lambda event: LazyDate(now() + timedelta(days=15))
),
SimpleFunctionalMailTextPlaceholder(
'url', ['order', 'event'], lambda order, event: build_absolute_uri(
event,
'presale:event.order.open', kwargs={
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_hash()
}
), lambda event: build_absolute_uri(
event,
'presale:event.order.open', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
'hash': '98kusd8ofsj8dnkd'
}
),
),
SimpleFunctionalMailTextPlaceholder(
'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri(
event,
'presale:event.order.modify', kwargs={
'order': order.code,
'secret': order.secret,
}
), lambda event: build_absolute_uri(
event,
'presale:event.order.modify', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
}
),
),
SimpleFunctionalMailTextPlaceholder(
'url_products_change', ['order', 'event'], lambda order, event: build_absolute_uri(
event,
'presale:event.order.change', kwargs={
'order': order.code,
'secret': order.secret,
}
), lambda event: build_absolute_uri(
event,
'presale:event.order.change', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
}
),
),
SimpleFunctionalMailTextPlaceholder(
'url_cancel', ['order', 'event'], lambda order, event: build_absolute_uri(
event,
'presale:event.order.cancel', kwargs={
'order': order.code,
'secret': order.secret,
}
), lambda event: build_absolute_uri(
event,
'presale:event.order.cancel', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
}
),
),
SimpleFunctionalMailTextPlaceholder(
'url', ['event', 'position'], lambda event, position: build_absolute_uri(
event,
'presale:event.order.position',
kwargs={
'order': position.order.code,
'secret': position.web_secret,
'position': position.positionid
}
),
lambda event: build_absolute_uri(
event,
'presale:event.order.position', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
'position': '123'
}
),
),
SimpleFunctionalMailTextPlaceholder(
'order_modification_deadline_date_and_time', ['order', 'event'],
lambda order, event:
date_format(order.modify_deadline.astimezone(event.timezone), 'SHORT_DATETIME_FORMAT')
if order.modify_deadline
else '',
lambda event: date_format(
event.settings.get(
'last_order_modification_date', as_type=RelativeDateWrapper
).datetime(event).astimezone(event.timezone),
'SHORT_DATETIME_FORMAT'
) if event.settings.get('last_order_modification_date') else '',
),
SimpleFunctionalMailTextPlaceholder(
'event_location', ['event_or_subevent'], lambda event_or_subevent: str(event_or_subevent.location or ''),
lambda event: str(event.location or ''),
),
SimpleFunctionalMailTextPlaceholder(
'event_admission_time', ['event_or_subevent'],
lambda event_or_subevent:
date_format(event_or_subevent.date_admission.astimezone(event_or_subevent.timezone), 'TIME_FORMAT')
if event_or_subevent.date_admission
else '',
lambda event: date_format(event.date_admission.astimezone(event.timezone), 'TIME_FORMAT') if event.date_admission else '',
),
SimpleFunctionalMailTextPlaceholder(
'subevent', ['waiting_list_entry', 'event'],
lambda waiting_list_entry, event: str(waiting_list_entry.subevent or event),
lambda event: str(event if not event.has_subevents or not event.subevents.exists() else event.subevents.first())
),
SimpleFunctionalMailTextPlaceholder(
'subevent_date_from', ['waiting_list_entry', 'event'],
lambda waiting_list_entry, event: (waiting_list_entry.subevent or event).get_date_from_display(),
lambda event: (event if not event.has_subevents or not event.subevents.exists() else event.subevents.first()).get_date_from_display()
),
SimpleFunctionalMailTextPlaceholder(
'url_remove', ['waiting_list_voucher', 'event'],
lambda waiting_list_voucher, event: build_absolute_uri(
event, 'presale:event.waitinglist.remove'
) + '?voucher=' + waiting_list_voucher.code,
lambda event: build_absolute_uri(
event,
'presale:event.waitinglist.remove',
) + '?voucher=68CYU2H6ZTP3WLK5',
),
SimpleFunctionalMailTextPlaceholder(
'url', ['waiting_list_voucher', 'event'],
lambda waiting_list_voucher, event: build_absolute_uri(
event, 'presale:event.redeem'
) + '?voucher=' + waiting_list_voucher.code,
lambda event: build_absolute_uri(
event,
'presale:event.redeem',
) + '?voucher=68CYU2H6ZTP3WLK5',
),
SimpleFunctionalMailTextPlaceholder(
'invoice_name', ['invoice_address'], lambda invoice_address: invoice_address.name or '',
_('John Doe')
),
SimpleFunctionalMailTextPlaceholder(
'invoice_company', ['invoice_address'], lambda invoice_address: invoice_address.company or '',
_('Sample Corporation')
),
SimpleFunctionalMailTextPlaceholder(
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
'* {} - {}'.format(
order.full_code,
build_absolute_uri(event, 'presale:event.order.open', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_hash(),
}),
)
for order in orders
), lambda event: '\n' + '\n\n'.join(
'* {} - {}'.format(
'{}-{}'.format(event.slug.upper(), order['code']),
build_absolute_uri(event, 'presale:event.order.open', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'order': order['code'],
'secret': order['secret'],
'hash': order['hash'],
}),
)
for order in [
{'code': 'F8VVL', 'secret': '6zzjnumtsx136ddy', 'hash': 'abcdefghi'},
{'code': 'HIDHK', 'secret': '98kusd8ofsj8dnkd', 'hash': 'jklmnopqr'},
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd', 'hash': 'stuvwxy2z'}
]
),
),
SimpleFunctionalMailTextPlaceholder(
'hours', ['event', 'waiting_list_entry'], lambda event, waiting_list_entry:
event.settings.waiting_list_hours,
lambda event: event.settings.waiting_list_hours
),
SimpleFunctionalMailTextPlaceholder(
'product', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.item.name,
_('Sample Admission Ticket')
),
SimpleFunctionalMailTextPlaceholder(
'code', ['waiting_list_voucher'], lambda waiting_list_voucher: waiting_list_voucher.code,
'68CYU2H6ZTP3WLK5'
),
SimpleFunctionalMailTextPlaceholder(
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list),
' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2'
),
SimpleFunctionalMailTextPlaceholder(
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
'voucher_url_list', ['event', 'voucher_list'],
lambda event, voucher_list: ' \n'.join([
build_absolute_uri(
event, 'presale:event.redeem'
) + '?voucher=' + c
for c in voucher_list
]),
lambda event: ' \n'.join([
build_absolute_uri(
event, 'presale:event.redeem'
) + '?voucher=' + c
for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2']
]),
),
SimpleFunctionalMailTextPlaceholder(
'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
}), lambda event: build_absolute_uri(event, 'presale:event.index', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
})
),
SimpleFunctionalMailTextPlaceholder(
'name', ['name'], lambda name: name,
_('John Doe')
),
SimpleFunctionalMailTextPlaceholder(
'comment', ['comment'], lambda comment: comment,
_('An individual text with a reason can be inserted here.'),
),
SimpleFunctionalMailTextPlaceholder(
'payment_info', ['order', 'payments'], _placeholder_payments,
_('The amount has been charged to your card.'),
),
SimpleFunctionalMailTextPlaceholder(
'payment_info', ['payment_info'], lambda payment_info: payment_info,
_('Please transfer money to this bank account: 9999-9999-9999-9999'),
),
SimpleFunctionalMailTextPlaceholder(
'attendee_name', ['position'], lambda position: position.attendee_name,
_('John Doe'),
),
SimpleFunctionalMailTextPlaceholder(
'positionid', ['position'], lambda position: str(position.positionid),
'1'
),
SimpleFunctionalMailTextPlaceholder(
'name', ['position_or_address'],
get_best_name,
_('John Doe'),
),
]
name_scheme = PERSON_NAME_SCHEMES[sender.settings.name_scheme]
if "concatenation_for_salutation" in name_scheme:
concatenation_for_salutation = name_scheme["concatenation_for_salutation"]
else:
concatenation_for_salutation = name_scheme["concatenation"]
ph.append(SimpleFunctionalMailTextPlaceholder(
"name_for_salutation", ["waiting_list_entry"],
lambda waiting_list_entry: concatenation_for_salutation(waiting_list_entry.name_parts),
_("Mr Doe"),
))
ph.append(SimpleFunctionalMailTextPlaceholder(
"name", ["waiting_list_entry"],
lambda waiting_list_entry: waiting_list_entry.name or "",
_("Mr Doe"),
))
ph.append(SimpleFunctionalMailTextPlaceholder(
"name_for_salutation", ["position_or_address"],
lambda position_or_address: concatenation_for_salutation(get_best_name(position_or_address, parts=True)),
_("Mr Doe"),
))
for f, l, w in name_scheme['fields']:
if f == 'full_name':
continue
ph.append(SimpleFunctionalMailTextPlaceholder(
'name_%s' % f, ['waiting_list_entry'], lambda waiting_list_entry, f=f: get_name_parts_localized(waiting_list_entry.name_parts, f),
name_scheme['sample'][f]
))
ph.append(SimpleFunctionalMailTextPlaceholder(
'attendee_name_%s' % f, ['position'], lambda position, f=f: get_name_parts_localized(position.attendee_name_parts, f),
name_scheme['sample'][f]
))
ph.append(SimpleFunctionalMailTextPlaceholder(
'name_%s' % f, ['position_or_address'],
lambda position_or_address, f=f: get_name_parts_localized(get_best_name(position_or_address, parts=True), f),
name_scheme['sample'][f]
))
for k, v in sender.meta_data.items():
ph.append(SimpleFunctionalMailTextPlaceholder(
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
v
))
ph.append(SimpleFunctionalMailTextPlaceholder(
'meta_%s' % k, ['event_or_subevent'], lambda event_or_subevent, k=k: event_or_subevent.meta_data[k],
v
))
return ph
-1
View File
@@ -28,5 +28,4 @@ from .items import * # noqa
from .json import * # noqa from .json import * # noqa
from .mail import * # noqa from .mail import * # noqa
from .orderlist import * # noqa from .orderlist import * # noqa
from .reusablemedia import * # noqa
from .waitinglist import * # noqa from .waitinglist import * # noqa
+11 -29
View File
@@ -209,7 +209,7 @@ class OrderListExporter(MultiSheetListExporter):
return qs.annotate(**annotations).filter(**filters) return qs.annotate(**annotations).filter(**filters)
return qs return qs
def orders_qs(self, form_data): def iterate_orders(self, form_data: dict):
p_date = OrderPayment.objects.filter( p_date = OrderPayment.objects.filter(
order=OuterRef('pk'), order=OuterRef('pk'),
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED), state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
@@ -250,15 +250,11 @@ class OrderListExporter(MultiSheetListExporter):
if form_data['paid_only']: if form_data['paid_only']:
qs = qs.filter(status=Order.STATUS_PAID) qs = qs.filter(status=Order.STATUS_PAID)
return qs
def iterate_orders(self, form_data: dict):
qs = self.orders_qs(form_data)
tax_rates = self._get_all_tax_rates(qs) tax_rates = self._get_all_tax_rates(qs)
headers = [ headers = [
_('Event slug'), _('Event name'), _('Order code'), _('Order total'), _('Status'), _('Email'), _('Event slug'), _('Order code'), _('Order total'), _('Status'), _('Email'), _('Phone number'),
_('Phone number'), _('Order date'), _('Order time'), _('Company'), _('Name'), _('Order date'), _('Order time'), _('Company'), _('Name'),
] ]
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme] if not self.is_multievent else None
if name_scheme and len(name_scheme['fields']) > 1: if name_scheme and len(name_scheme['fields']) > 1:
@@ -335,7 +331,6 @@ class OrderListExporter(MultiSheetListExporter):
row = [ row = [
self.event_object_cache[order.event_id].slug, self.event_object_cache[order.event_id].slug,
str(self.event_object_cache[order.event_id].name),
order.code, order.code,
order.total, order.total,
order.get_extended_status_display(), order.get_extended_status_display(),
@@ -411,7 +406,7 @@ class OrderListExporter(MultiSheetListExporter):
row += self.event_object_cache[order.event_id].meta_data.values() row += self.event_object_cache[order.event_id].meta_data.values()
yield row yield row
def fees_qs(self, form_data): def iterate_fees(self, form_data: dict):
p_providers = OrderPayment.objects.filter( p_providers = OrderPayment.objects.filter(
order=OuterRef('order'), order=OuterRef('order'),
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED, state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
@@ -430,14 +425,9 @@ class OrderListExporter(MultiSheetListExporter):
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False) qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
qs = self._date_filter(qs, form_data, rel='order__') qs = self._date_filter(qs, form_data, rel='order__')
return qs
def iterate_fees(self, form_data: dict):
qs = self.fees_qs(form_data)
headers = [ headers = [
_('Event slug'), _('Event slug'),
_('Event name'),
_('Order code'), _('Order code'),
_('Status'), _('Status'),
_('Email'), _('Email'),
@@ -474,7 +464,6 @@ class OrderListExporter(MultiSheetListExporter):
tz = ZoneInfo(order.event.settings.timezone) tz = ZoneInfo(order.event.settings.timezone)
row = [ row = [
self.event_object_cache[order.event_id].slug, self.event_object_cache[order.event_id].slug,
str(self.event_object_cache[order.event_id].name),
order.code, order.code,
_("canceled") if op.canceled else order.get_extended_status_display(), _("canceled") if op.canceled else order.get_extended_status_display(),
order.email, order.email,
@@ -517,19 +506,7 @@ class OrderListExporter(MultiSheetListExporter):
row += self.event_object_cache[order.event_id].meta_data.values() row += self.event_object_cache[order.event_id].meta_data.values()
yield row yield row
def positions_qs(self, form_data: dict):
qs = OrderPosition.all.filter(
order__event__in=self.events,
)
if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
qs = self._date_filter(qs, form_data, rel='order__')
return qs
def iterate_positions(self, form_data: dict): def iterate_positions(self, form_data: dict):
base_qs = self.positions_qs(form_data)
p_providers = OrderPayment.objects.filter( p_providers = OrderPayment.objects.filter(
order=OuterRef('order'), order=OuterRef('order'),
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED, state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
@@ -539,6 +516,9 @@ class OrderListExporter(MultiSheetListExporter):
).values( ).values(
'm' 'm'
).order_by() ).order_by()
base_qs = OrderPosition.all.filter(
order__event__in=self.events,
)
qs = base_qs.annotate( qs = base_qs.annotate(
payment_providers=Subquery(p_providers, output_field=CharField()), payment_providers=Subquery(p_providers, output_field=CharField()),
).select_related( ).select_related(
@@ -548,12 +528,15 @@ class OrderListExporter(MultiSheetListExporter):
'subevent', 'subevent__meta_values', 'subevent', 'subevent__meta_values',
'answers', 'answers__question', 'answers__options' 'answers', 'answers__question', 'answers__options'
) )
if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
qs = self._date_filter(qs, form_data, rel='order__')
has_subevents = self.events.filter(has_subevents=True).exists() has_subevents = self.events.filter(has_subevents=True).exists()
headers = [ headers = [
_('Event slug'), _('Event slug'),
_('Event name'),
_('Order code'), _('Order code'),
_('Position ID'), _('Position ID'),
_('Status'), _('Status'),
@@ -655,7 +638,6 @@ class OrderListExporter(MultiSheetListExporter):
tz = ZoneInfo(self.event_object_cache[order.event_id].settings.timezone) tz = ZoneInfo(self.event_object_cache[order.event_id].settings.timezone)
row = [ row = [
self.event_object_cache[order.event_id].slug, self.event_object_cache[order.event_id].slug,
str(self.event_object_cache[order.event_id].name),
order.code, order.code,
op.positionid, op.positionid,
_("canceled") if op.canceled else order.get_extended_status_display(), _("canceled") if op.canceled else order.get_extended_status_display(),
@@ -1,78 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.translation import gettext_lazy as _, pgettext, pgettext_lazy
from ..exporter import ListExporter, OrganizerLevelExportMixin
from ..models import ReusableMedium
from ..signals import register_multievent_data_exporters
class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'reusablemedia'
verbose_name = _('Reusable media')
category = pgettext_lazy('export_category', 'Reusable media')
description = _('Download a spread sheet with the data of all reusable medias on your account.')
def iterate_list(self, form_data):
media = ReusableMedium.objects.filter(
organizer=self.organizer,
).select_related(
'customer', 'linked_orderposition', 'linked_giftcard',
).order_by('created')
headers = [
pgettext('reusable_medium', 'Media type'),
pgettext('reusable_medium', 'Identifier'),
_('Active'),
_('Expiration date'),
_('Customer account'),
_('Linked ticket'),
_('Linked gift card'),
_('Notes'),
]
yield headers
yield self.ProgressSetTotal(total=media.count())
for medium in media.iterator(chunk_size=1000):
row = [
medium.type,
medium.identifier,
_('Yes') if medium.active else _('No'),
date_format(medium.expires, 'SHORT_DATETIME_FORMAT') if medium.expires else '',
medium.customer.identifier if medium.customer_id else '',
f"{medium.linked_orderposition.order.code}-{medium.linked_orderposition.positionid}" if medium.linked_orderposition_id else '',
medium.linked_giftcard.secret if medium.linked_giftcard_id else '',
medium.notes,
]
yield row
def get_filename(self):
return f'{self.organizer.slug}_media'
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_reusablemedia")
def register_multievent_i_reusable_media_exporter(sender, **kwargs):
return ReusableMediaExporter
+4 -11
View File
@@ -125,7 +125,7 @@ class NamePartsWidget(forms.MultiWidget):
if fname == 'title' and self.titles: if fname == 'title' and self.titles:
widgets.append(Select(attrs=a, choices=[('', '')] + [(d, d) for d in self.titles[1]])) widgets.append(Select(attrs=a, choices=[('', '')] + [(d, d) for d in self.titles[1]]))
elif fname == 'salutation': elif fname == 'salutation':
widgets.append(Select(attrs=a, choices=[('', '---'), ('empty', '')] + PERSON_NAME_SALUTATIONS)) widgets.append(Select(attrs=a, choices=[('', '---')] + PERSON_NAME_SALUTATIONS))
else: else:
widgets.append(self.widget(attrs=a)) widgets.append(self.widget(attrs=a))
super().__init__(widgets, attrs) super().__init__(widgets, attrs)
@@ -136,10 +136,7 @@ class NamePartsWidget(forms.MultiWidget):
data = [] data = []
for i, field in enumerate(self.scheme['fields']): for i, field in enumerate(self.scheme['fields']):
fname, label, size = field fname, label, size = field
fval = value.get(fname, "") data.append(value.get(fname, ""))
if fname == "salutation" and fname in value and fval == "":
fval = "empty"
data.append(fval)
if '_legacy' in value and not data[-1]: if '_legacy' in value and not data[-1]:
data[-1] = value.get('_legacy', '') data[-1] = value.get('_legacy', '')
elif not any(d for d in data) and '_scheme' in value: elif not any(d for d in data) and '_scheme' in value:
@@ -193,8 +190,7 @@ class NamePartsFormField(forms.MultiValueField):
data = {} data = {}
data['_scheme'] = self.scheme_name data['_scheme'] = self.scheme_name
for i, value in enumerate(data_list): for i, value in enumerate(data_list):
key = self.scheme['fields'][i][0] data[self.scheme['fields'][i][0]] = value or ''
data[key] = value or ''
return data return data
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -243,7 +239,7 @@ class NamePartsFormField(forms.MultiValueField):
d.pop('validators', None) d.pop('validators', None)
field = forms.ChoiceField( field = forms.ChoiceField(
**d, **d,
choices=[('', '---'), ('empty', '')] + PERSON_NAME_SALUTATIONS choices=[('', '---')] + PERSON_NAME_SALUTATIONS
) )
else: else:
field = forms.CharField(**defaults) field = forms.CharField(**defaults)
@@ -269,9 +265,6 @@ class NamePartsFormField(forms.MultiValueField):
if sum(len(v) for v in value.values() if v) > 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":
value["salutation"] = ""
return value return value
+63
View File
@@ -0,0 +1,63 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from bootstrap3.renderers import (
FieldRenderer as BaseFieldRenderer,
InlineFieldRenderer as BaseInlineFieldRenderer,
)
from django.forms import (
CheckboxInput, CheckboxSelectMultiple, ClearableFileInput, RadioSelect,
SelectDateWidget,
)
class FieldRenderer(BaseFieldRenderer):
# Local application of https://github.com/zostera/django-bootstrap3/pull/859
def post_widget_render(self, html):
if isinstance(self.widget, CheckboxSelectMultiple):
html = self.list_to_class(html, "checkbox")
elif isinstance(self.widget, RadioSelect):
html = self.list_to_class(html, "radio")
elif isinstance(self.widget, SelectDateWidget):
html = self.fix_date_select_input(html)
elif isinstance(self.widget, ClearableFileInput):
html = self.fix_clearable_file_input(html)
elif isinstance(self.widget, CheckboxInput):
html = self.put_inside_label(html)
return html
class InlineFieldRenderer(BaseInlineFieldRenderer):
# Local application of https://github.com/zostera/django-bootstrap3/pull/859
def post_widget_render(self, html):
if isinstance(self.widget, CheckboxSelectMultiple):
html = self.list_to_class(html, "checkbox")
elif isinstance(self.widget, RadioSelect):
html = self.list_to_class(html, "radio")
elif isinstance(self.widget, SelectDateWidget):
html = self.fix_date_select_input(html)
elif isinstance(self.widget, ClearableFileInput):
html = self.fix_clearable_file_input(html)
elif isinstance(self.widget, CheckboxInput):
html = self.put_inside_label(html)
return html
+1 -4
View File
@@ -209,10 +209,7 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
date_attrs['placeholder'] = lazy(date_placeholder, str) date_attrs['placeholder'] = lazy(date_placeholder, str)
time_attrs['placeholder'] = lazy(time_placeholder, str) time_attrs['placeholder'] = lazy(time_placeholder, str)
date_attrs['aria-label'] = _('Date')
time_attrs['aria-label'] = _('Time')
if 'aria-label' in attrs:
del attrs['aria-label']
widgets = ( widgets = (
forms.DateInput(attrs=date_attrs, format=date_format), forms.DateInput(attrs=date_attrs, format=date_format),
forms.TimeInput(attrs=time_attrs, format=time_format), forms.TimeInput(attrs=time_attrs, format=time_format),
+2 -33
View File
@@ -855,7 +855,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
class Modern1Renderer(ClassicInvoiceRenderer): class Modern1Renderer(ClassicInvoiceRenderer):
identifier = 'modern1' identifier = 'modern1'
verbose_name = gettext_lazy('Default invoice renderer (European-style letter)') verbose_name = gettext_lazy('Modern Invoice Renderer (pretix 2.7)')
bottom_margin = 16.9 * mm bottom_margin = 16.9 * mm
top_margin = 16.9 * mm top_margin = 16.9 * mm
right_margin = 20 * mm right_margin = 20 * mm
@@ -989,37 +989,6 @@ class Modern1Renderer(ClassicInvoiceRenderer):
canvas.drawText(textobject) canvas.drawText(textobject)
class Modern1SimplifiedRenderer(Modern1Renderer):
identifier = 'modern1simplified'
verbose_name = gettext_lazy('Simplified invoice renderer')
logo_left = Modern1Renderer.left_margin
logo_width = pagesizes.A4[0] - Modern1Renderer.right_margin - logo_left
logo_height = 25 * mm
logo_top = 13 * mm
logo_anchor = 'nw'
def _draw_invoice_from(self, canvas):
super(Modern1Renderer, self)._draw_invoice_from(canvas)
def _draw_event(self, canvas):
pass
def _get_intro(self):
i = []
if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
i.append(Paragraph(
pgettext('invoice', 'Event date: {date_range}').format(
date_range=self.invoice.event.get_date_range_display(),
),
self.stylesheet['Normal'],
))
i.append(Spacer(2 * mm, 2 * mm))
return i + super()._get_intro()
@receiver(register_invoice_renderers, dispatch_uid="invoice_renderer_classic") @receiver(register_invoice_renderers, dispatch_uid="invoice_renderer_classic")
def recv_classic(sender, **kwargs): def recv_classic(sender, **kwargs):
return [ClassicInvoiceRenderer, Modern1Renderer, Modern1SimplifiedRenderer] return [ClassicInvoiceRenderer, Modern1Renderer]
+14 -24
View File
@@ -44,25 +44,6 @@ from pretix.multidomain.urlreverse import (
_supported = None _supported = None
def get_supported_language(requested_language, allowed_languages, default_language):
language = requested_language
if language not in allowed_languages:
firstpart = language.split('-')[0]
if firstpart in allowed_languages:
language = firstpart
else:
language = default_language
for lang in allowed_languages:
if lang.startswith(firstpart + '-'):
language = lang
break
if language not in allowed_languages:
# This seems redundant, but can happen in the rare edge case that settings.locale is (wrongfully)
# not part of settings.locales
language = allowed_languages[0]
return language
class LocaleMiddleware(MiddlewareMixin): class LocaleMiddleware(MiddlewareMixin):
""" """
@@ -84,11 +65,20 @@ class LocaleMiddleware(MiddlewareMixin):
settings_holder = None settings_holder = None
if settings_holder: if settings_holder:
language = get_supported_language( if language not in settings_holder.settings.locales:
language, firstpart = language.split('-')[0]
settings_holder.settings.locales, if firstpart in settings_holder.settings.locales:
settings_holder.settings.locale, language = firstpart
) else:
language = settings_holder.settings.locale
for lang in settings_holder.settings.locales:
if lang.startswith(firstpart + '-'):
language = lang
break
if language not in settings_holder.settings.locales:
# This seems redundant, but can happen in the rare edge case that settings.locale is (wrongfully)
# not part of settings.locales
language = settings_holder.settings.locales[0]
if '-' not in language and settings_holder.settings.region: if '-' not in language and settings_holder.settings.region:
language += '-' + settings_holder.settings.region language += '-' + settings_holder.settings.region
else: else:
@@ -1,28 +0,0 @@
# Generated by Django 4.2.4 on 2023-12-06 14:53
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0253_checkin_info"),
]
operations = [
migrations.AlterField(
model_name="logentry",
name="organizer_link",
field=models.ForeignKey(
db_column="organizer_link_id",
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="pretixbase.organizer",
),
),
migrations.RenameField(
model_name="logentry",
old_name="organizer_link",
new_name="organizer",
),
]
@@ -1,22 +0,0 @@
# Generated by Django 4.2.4 on 2023-11-22 20:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0254_alter_logentry_organizer_link_and_more"),
]
operations = [
migrations.AddField(
model_name="item",
name="available_from_mode",
field=models.CharField(default="hide", max_length=16),
),
migrations.AddField(
model_name="item",
name="available_until_mode",
field=models.CharField(default="hide", max_length=16),
)
]
@@ -1,22 +0,0 @@
# Generated by Django 4.2.4 on 2024-01-11 15:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0255_item_unavail_modes"),
]
operations = [
migrations.AddField(
model_name="itemvariation",
name="available_from_mode",
field=models.CharField(default="hide", max_length=16),
),
migrations.AddField(
model_name="itemvariation",
name="available_until_mode",
field=models.CharField(default="hide", max_length=16),
),
]
+27 -15
View File
@@ -37,7 +37,9 @@ import json
import operator import operator
from datetime import timedelta from datetime import timedelta
from functools import reduce from functools import reduce
from urllib.parse import urlparse
import webauthn
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import ( from django.contrib.auth.models import (
AbstractBaseUser, BaseUserManager, PermissionsMixin, AbstractBaseUser, BaseUserManager, PermissionsMixin,
@@ -51,12 +53,13 @@ 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
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from webauthn.helpers.structs import PublicKeyCredentialDescriptor from u2flib_server.utils import (
pub_key_from_der, websafe_decode, websafe_encode,
)
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.helpers.urls import build_absolute_uri from pretix.helpers.urls import build_absolute_uri
from ...helpers.u2f import pub_key_from_der, websafe_decode
from .base import LoggingMixin from .base import LoggingMixin
@@ -605,12 +608,7 @@ class U2FDevice(Device):
json_data = models.TextField() json_data = models.TextField()
@property @property
def webauthndevice(self): def webauthnuser(self):
d = json.loads(self.json_data)
return PublicKeyCredentialDescriptor(websafe_decode(d['keyHandle']))
@property
def webauthnpubkey(self):
d = json.loads(self.json_data) d = json.loads(self.json_data)
# We manually need to convert the pubkey from DER format (used in our # We manually need to convert the pubkey from DER format (used in our
# former U2F implementation) to the format required by webauthn. This # former U2F implementation) to the format required by webauthn. This
@@ -622,7 +620,16 @@ class U2FDevice(Device):
pub_key.public_numbers().x, pub_key.public_numbers().y pub_key.public_numbers().x, pub_key.public_numbers().y
) )
) )
return pub_key return webauthn.WebAuthnUser(
d['keyHandle'],
self.user.email,
str(self.user),
settings.SITE_URL,
d['keyHandle'],
websafe_encode(pub_key),
1,
urlparse(settings.SITE_URL).netloc
)
class WebAuthnDevice(Device): class WebAuthnDevice(Device):
@@ -634,9 +641,14 @@ class WebAuthnDevice(Device):
sign_count = models.IntegerField(default=0) sign_count = models.IntegerField(default=0)
@property @property
def webauthndevice(self): def webauthnuser(self):
return PublicKeyCredentialDescriptor(websafe_decode(self.credential_id)) return webauthn.WebAuthnUser(
self.ukey,
@property self.user.email,
def webauthnpubkey(self): str(self.user),
return websafe_decode(self.pub_key) settings.SITE_URL,
self.credential_id,
self.pub_key,
self.sign_count,
urlparse(settings.SITE_URL).netloc
)
+1 -1
View File
@@ -115,7 +115,7 @@ class LoggingMixin:
kwargs['api_token'] = api_token kwargs['api_token'] = api_token
logentry = LogEntry(content_object=self, user=user, action_type=action, event=event, logentry = LogEntry(content_object=self, user=user, action_type=action, event=event,
organizer_id=organizer_id, **kwargs) organizer_link_id=organizer_id, **kwargs)
if isinstance(data, dict): if isinstance(data, dict):
sensitivekeys = ['password', 'secret', 'api_key'] sensitivekeys = ['password', 'secret', 'api_key']
+2 -3
View File
@@ -280,8 +280,7 @@ class CheckinList(LoggedModel):
'<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and' '<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and'
} }
allowed_operators = top_level_operators | { allowed_operators = top_level_operators | {
'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before', 'entries_days_since', 'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before'
'entries_days_before',
} }
allowed_vars = { allowed_vars = {
'product', 'variation', 'now', 'now_isoweekday', 'entries_number', 'entries_today', 'entries_days', 'product', 'variation', 'now', 'now_isoweekday', 'entries_number', 'entries_today', 'entries_days',
@@ -310,7 +309,7 @@ class CheckinList(LoggedModel):
raise ValidationError(f'Logic variable "{values[0]}" is currently not allowed.') raise ValidationError(f'Logic variable "{values[0]}" is currently not allowed.')
return rules return rules
if operator in ('entries_since', 'entries_before', 'entries_days_since', 'entries_days_before'): if operator in ('entries_since', 'entries_before'):
if len(values) != 1 or "buildTime" not in values[0]: if len(values) != 1 or "buildTime" not in values[0]:
raise ValidationError(f'Operator "{operator}" takes exactly one "buildTime" argument.') raise ValidationError(f'Operator "{operator}" takes exactly one "buildTime" argument.')
+1 -1
View File
@@ -344,7 +344,7 @@ class Discount(LoggedModel):
elif self.subevent_mode == self.SUBEVENT_MODE_SAME: elif self.subevent_mode == self.SUBEVENT_MODE_SAME:
def key(idx): def key(idx):
return positions[idx][1] or 0 # subevent_id return positions[idx][1] # subevent_id
# Build groups of candidates with the same subevent, then apply our regular algorithm # Build groups of candidates with the same subevent, then apply our regular algorithm
# to each group # to each group
+6 -7
View File
@@ -775,7 +775,7 @@ class Event(EventMixin, LoggedModel):
time(hour=23, minute=59, second=59) time(hour=23, minute=59, second=59)
), tz) ), tz)
def copy_data_from(self, other, skip_meta_data=False): def copy_data_from(self, other):
from pretix.presale.style import regenerate_css from pretix.presale.style import regenerate_css
from ..signals import event_copy_data from ..signals import event_copy_data
@@ -798,11 +798,10 @@ class Event(EventMixin, LoggedModel):
self.save() self.save()
self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk}) self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk})
if not skip_meta_data: for emv in EventMetaValue.objects.filter(event=other):
for emv in EventMetaValue.objects.filter(event=other): emv.pk = None
emv.pk = None emv.event = self
emv.event = self emv.save(force_insert=True)
emv.save(force_insert=True)
for fl in EventFooterLink.objects.filter(event=other): for fl in EventFooterLink.objects.filter(event=other):
fl.pk = None fl.pk = None
@@ -1064,7 +1063,7 @@ class Event(EventMixin, LoggedModel):
providers[pp.identifier] = pp providers[pp.identifier] = pp
self._cached_payment_providers = OrderedDict(sorted( self._cached_payment_providers = OrderedDict(sorted(
providers.items(), key=lambda v: (-v[1].priority, str(v[1].verbose_name).title()) providers.items(), key=lambda v: (-v[1].priority, str(v[1].verbose_name))
)) ))
return self._cached_payment_providers return self._cached_payment_providers
+3 -66
View File
@@ -263,8 +263,8 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False):
# IMPORTANT: If this is updated, also update the ItemVariation query # IMPORTANT: If this is updated, also update the ItemVariation query
# in models/event.py: EventMixin.annotated() # in models/event.py: EventMixin.annotated()
Q(active=True) Q(active=True)
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()) | Q(available_from_mode='info')) & Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()) | Q(available_until_mode='info')) & Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
& Q(sales_channels__contains=channel) & Q(require_bundling=False) & Q(sales_channels__contains=channel) & Q(require_bundling=False)
) )
if not allow_addons: if not allow_addons:
@@ -374,13 +374,6 @@ class Item(LoggedModel):
(VALIDITY_MODE_DYNAMIC, _('Dynamic validity')), (VALIDITY_MODE_DYNAMIC, _('Dynamic validity')),
) )
UNAVAIL_MODE_HIDDEN = "hide"
UNAVAIL_MODE_INFO = "info"
UNAVAIL_MODES = (
(UNAVAIL_MODE_HIDDEN, _("Hide product if unavailable")),
(UNAVAIL_MODE_INFO, _("Show info text if unavailable")),
)
MEDIA_POLICY_REUSE = 'reuse' MEDIA_POLICY_REUSE = 'reuse'
MEDIA_POLICY_NEW = 'new' MEDIA_POLICY_NEW = 'new'
MEDIA_POLICY_REUSE_OR_NEW = 'reuse_or_new' MEDIA_POLICY_REUSE_OR_NEW = 'reuse_or_new'
@@ -494,21 +487,11 @@ class Item(LoggedModel):
null=True, blank=True, null=True, blank=True,
help_text=_('This product will not be sold before the given date.') help_text=_('This product will not be sold before the given date.')
) )
available_from_mode = models.CharField(
choices=UNAVAIL_MODES,
default=UNAVAIL_MODE_HIDDEN,
max_length=16,
)
available_until = models.DateTimeField( available_until = models.DateTimeField(
verbose_name=_("Available until"), verbose_name=_("Available until"),
null=True, blank=True, null=True, blank=True,
help_text=_('This product will not be sold after the given date.') help_text=_('This product will not be sold after the given date.')
) )
available_until_mode = models.CharField(
choices=UNAVAIL_MODES,
default=UNAVAIL_MODE_HIDDEN,
max_length=16,
)
hidden_if_available = models.ForeignKey( hidden_if_available = models.ForeignKey(
'Quota', 'Quota',
null=True, blank=True, null=True, blank=True,
@@ -648,7 +631,7 @@ class Item(LoggedModel):
null=True, blank=True, max_length=16, null=True, blank=True, max_length=16,
verbose_name=_('Validity'), verbose_name=_('Validity'),
help_text=_( help_text=_(
'When setting up a regular event, or an event series with time slots, you typically do NOT need to change ' 'When setting up a regular event, or an event series with time slots, you typically to NOT need to change '
'this value. The default setting means that the validity time of tickets will not be decided by the ' 'this value. The default setting means that the validity time of tickets will not be decided by the '
'product, but by the event and check-in configuration. Only use the other options if you need them to ' 'product, but by the event and check-in configuration. Only use the other options if you need them to '
'realize e.g. a booking of a year-long ticket with a dynamic start date. Note that the validity will be ' 'realize e.g. a booking of a year-long ticket with a dynamic start date. Note that the validity will be '
@@ -720,8 +703,6 @@ class Item(LoggedModel):
return str(self.internal_name or self.name) return str(self.internal_name or self.name)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.hide_without_voucher:
self.require_voucher = True
super().save(*args, **kwargs) super().save(*args, **kwargs)
if self.event: if self.event:
self.event.cache.clear() self.event.cache.clear()
@@ -799,24 +780,6 @@ class Item(LoggedModel):
return False return False
return True return True
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
now_dt = now_dt or now()
subevent_item = subevent and subevent.item_overrides.get(self.pk)
if not self.active:
return 'active'
elif self.available_from and self.available_from > now_dt:
return 'available_from'
elif self.available_until and self.available_until < now_dt:
return 'available_until'
elif (self.require_voucher or self.hide_without_voucher) and not has_voucher:
return 'require_voucher'
elif subevent_item and subevent_item.available_from and subevent_item.available_from > now_dt:
return 'available_from'
elif subevent_item and subevent_item.available_until and subevent_item.available_until < now_dt:
return 'available_until'
else:
return None
def _get_quotas(self, ignored_quotas=None, subevent=None): def _get_quotas(self, ignored_quotas=None, subevent=None):
check_quotas = set(getattr( check_quotas = set(getattr(
self, '_subevent_quotas', # Utilize cache in product list self, '_subevent_quotas', # Utilize cache in product list
@@ -1115,21 +1078,11 @@ class ItemVariation(models.Model):
null=True, blank=True, null=True, blank=True,
help_text=_('This variation will not be sold before the given date.') help_text=_('This variation will not be sold before the given date.')
) )
available_from_mode = models.CharField(
choices=Item.UNAVAIL_MODES,
default=Item.UNAVAIL_MODE_HIDDEN,
max_length=16,
)
available_until = models.DateTimeField( available_until = models.DateTimeField(
verbose_name=_("Available until"), verbose_name=_("Available until"),
null=True, blank=True, null=True, blank=True,
help_text=_('This variation will not be sold after the given date.') help_text=_('This variation will not be sold after the given date.')
) )
available_until_mode = models.CharField(
choices=Item.UNAVAIL_MODES,
default=Item.UNAVAIL_MODE_HIDDEN,
max_length=16,
)
sales_channels = fields.MultiStringField( sales_channels = fields.MultiStringField(
verbose_name=_('Sales channels'), verbose_name=_('Sales channels'),
default=_all_sales_channels_identifiers, default=_all_sales_channels_identifiers,
@@ -1307,22 +1260,6 @@ class ItemVariation(models.Model):
return False return False
return True return True
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
now_dt = now_dt or now()
subevent_var = subevent and subevent.var_overrides.get(self.pk)
if not self.active:
return 'active'
elif self.available_from and self.available_from > now_dt:
return 'available_from'
elif self.available_until and self.available_until < now_dt:
return 'available_until'
elif subevent_var and subevent_var.available_from and subevent_var.available_from > now_dt:
return 'available_from'
elif subevent_var and subevent_var.available_until and subevent_var.available_until < now_dt:
return 'available_until'
else:
return None
@property @property
def meta_data(self): def meta_data(self):
data = self.item.meta_data data = self.item.meta_data
+17 -1
View File
@@ -78,7 +78,7 @@ class LogEntry(models.Model):
device = models.ForeignKey('Device', null=True, blank=True, on_delete=models.PROTECT) device = models.ForeignKey('Device', null=True, blank=True, on_delete=models.PROTECT)
oauth_application = models.ForeignKey('pretixapi.OAuthApplication', null=True, blank=True, on_delete=models.PROTECT) oauth_application = models.ForeignKey('pretixapi.OAuthApplication', null=True, blank=True, on_delete=models.PROTECT)
event = models.ForeignKey('Event', null=True, blank=True, on_delete=models.SET_NULL) event = models.ForeignKey('Event', null=True, blank=True, on_delete=models.SET_NULL)
organizer = models.ForeignKey('Organizer', null=True, blank=True, on_delete=models.PROTECT, db_column='organizer_link_id') organizer_link = models.ForeignKey('Organizer', null=True, blank=True, on_delete=models.PROTECT)
action_type = models.CharField(max_length=255) action_type = models.CharField(max_length=255)
data = models.TextField(default='{}') data = models.TextField(default='{}')
visible = models.BooleanField(default=True) visible = models.BooleanField(default=True)
@@ -123,6 +123,22 @@ class LogEntry(models.Model):
typepath = typepath.rsplit('.', 1)[0] typepath = typepath.rsplit('.', 1)[0]
return no_type return no_type
@cached_property
def organizer(self):
from .organizer import Organizer
if self.organizer_link:
return self.organizer_link
elif self.event:
return self.event.organizer
elif hasattr(self.content_object, 'event'):
return self.content_object.event.organizer
elif hasattr(self.content_object, 'organizer'):
return self.content_object.organizer
elif isinstance(self.content_object, Organizer):
return self.content_object
return None
@cached_property @cached_property
def display_object(self): def display_object(self):
from . import ( from . import (
+12 -19
View File
@@ -44,7 +44,7 @@ from datetime import datetime, time, timedelta
from decimal import Decimal from decimal import Decimal
from functools import reduce from functools import reduce
from time import sleep from time import sleep
from typing import Any, Dict, Iterable, List, Union from typing import Any, Dict, List, Union
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import dateutil import dateutil
@@ -79,7 +79,7 @@ from pretix.base.i18n import language
from pretix.base.models import Customer, User from pretix.base.models import Customer, User
from pretix.base.reldate import RelativeDateWrapper from pretix.base.reldate import RelativeDateWrapper
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import allow_ticket_download, order_gracefully_delete from pretix.base.signals import order_gracefully_delete
from ...helpers import OF_SELF from ...helpers import OF_SELF
from ...helpers.countries import CachedCountries, FastCountryField from ...helpers.countries import CachedCountries, FastCountryField
@@ -1090,6 +1090,9 @@ class Order(LockModel, LoggedModel):
if not self.email and not (position and position.attendee_email): if not self.email and not (position and position.attendee_email):
return return
for k, v in self.event.meta_data.items():
context['meta_' + k] = v
with language(self.locale, self.event.settings.region): with language(self.locale, self.event.settings.region):
recipient = self.email recipient = self.email
if position and position.attendee_email: if position and position.attendee_email:
@@ -1134,19 +1137,12 @@ class Order(LockModel, LoggedModel):
attach_tickets=True, attach_tickets=True,
) )
@property
def positions_with_tickets_ignoring_plugins(self):
return (op for op in self.positions.select_related('item') if op.generate_ticket)
@property @property
def positions_with_tickets(self): def positions_with_tickets(self):
signal_response = allow_ticket_download.send(self.event, order=self) for op in self.positions.select_related('item'):
if all([r is True for rr, r in signal_response]): if not op.generate_ticket:
return self.positions_with_tickets_ignoring_plugins continue
elif any([r is False for rr, r in signal_response]): yield op
return []
else:
return set.intersection(set(self.positions_with_tickets_ignoring_plugins), *[set(r) for rr, r in signal_response if isinstance(r, Iterable)])
def create_transactions(self, is_new=False, positions=None, fees=None, dt_now=None, migrated=False, def create_transactions(self, is_new=False, positions=None, fees=None, dt_now=None, migrated=False,
_backfill_before_cancellation=False, save=True): _backfill_before_cancellation=False, save=True):
@@ -2147,12 +2143,6 @@ class OrderRefund(models.Model):
self.local_id = (self.order.refunds.aggregate(m=Max('local_id'))['m'] or 0) + 1 self.local_id = (self.order.refunds.aggregate(m=Max('local_id'))['m'] or 0) + 1
if 'update_fields' in kwargs: if 'update_fields' in kwargs:
kwargs['update_fields'] = {'local_id'}.union(kwargs['update_fields']) kwargs['update_fields'] = {'local_id'}.union(kwargs['update_fields'])
if self.state == OrderRefund.REFUND_STATE_DONE and not self.execution_date:
self.execution_date = now()
if 'update_fields' in kwargs:
kwargs['update_fields'] = {'execution_date'}.union(kwargs['update_fields'])
super().save(*args, **kwargs) super().save(*args, **kwargs)
@@ -2654,6 +2644,9 @@ class OrderPosition(AbstractPosition):
if not self.attendee_email: if not self.attendee_email:
return return
for k, v in self.event.meta_data.items():
context['meta_' + k] = v
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
try: try:
+1 -2
View File
@@ -251,8 +251,7 @@ class Voucher(LoggedModel):
null=True, blank=True, null=True, blank=True,
on_delete=models.PROTECT, # We use a fake version of SET_NULL in Item.delete() on_delete=models.PROTECT, # We use a fake version of SET_NULL in Item.delete()
help_text=_( help_text=_(
"This product is added to the user's cart if the voucher is redeemed. Instead of a specific product, you " "This product is added to the user's cart if the voucher is redeemed."
"can also select a quota. In this case, all products assigned to this quota can be selected."
) )
) )
variation = models.ForeignKey( variation = models.ForeignKey(
+3
View File
@@ -259,6 +259,9 @@ class WaitingListEntry(LoggedModel):
if not self.email: if not self.email:
return return
for k, v in self.event.meta_data.items():
context['meta_' + k] = v
with language(self.locale, self.event.settings.region): with language(self.locale, self.event.settings.region):
recipient = self.email recipient = self.email
+1 -1
View File
@@ -849,7 +849,7 @@ class BasePaymentProvider:
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
pass pass
else: else:
if str(ia.country) != '' and str(ia.country) not in restricted_countries: if str(ia.country) not in restricted_countries:
return False return False
if order.sales_channel not in self.settings.get('_restrict_to_sales_channels', as_type=list, default=['web']): if order.sales_channel not in self.settings.get('_restrict_to_sales_channels', as_type=list, default=['web']):
+8 -53
View File
@@ -41,7 +41,6 @@ from typing import List, Optional
from celery.exceptions import MaxRetriesExceededError from celery.exceptions import MaxRetriesExceededError
from django import forms from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import DatabaseError, transaction from django.db import DatabaseError, transaction
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Value from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Value
@@ -113,15 +112,6 @@ error_messages = {
'Some of the products you selected are no longer available in ' 'Some of the products you selected are no longer available in '
'the quantity you selected. Please see below for details.' 'the quantity you selected. Please see below for details.'
), ),
'unavailable_listed': gettext_lazy(
'Some of the products you selected are no longer available. '
'The following products are affected and have not been added to your cart: %s'
),
'in_part_listed': gettext_lazy(
'Some of the products you selected are no longer available in '
'the quantity you selected. The following products are affected and have not '
'been added to your cart: %s'
),
'max_items': ngettext_lazy( 'max_items': ngettext_lazy(
"You cannot select more than %s item per order.", "You cannot select more than %s item per order.",
"You cannot select more than %s items per order." "You cannot select more than %s items per order."
@@ -388,9 +378,8 @@ class CartManager:
cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation) and not op.addon_to]) cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation) and not op.addon_to])
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation) if cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation) if
not op.position.addon_to_id]) not op.position.addon_to_id])
limit = min(int(self.event.settings.max_items_per_order), settings.PRETIX_MAX_ORDER_SIZE) if cartsize > int(self.event.settings.max_items_per_order):
if cartsize > limit: raise CartError(error_messages['max_items'] % self.event.settings.max_items_per_order)
raise CartError(error_messages['max_items'] % limit)
def _check_item_constraints(self, op, current_ops=[]): def _check_item_constraints(self, op, current_ops=[]):
if isinstance(op, (self.AddOperation, self.ExtendOperation)): if isinstance(op, (self.AddOperation, self.ExtendOperation)):
@@ -1114,8 +1103,6 @@ class CartManager:
if 'sleep-after-quota-check' in debugflags_var.get(): if 'sleep-after-quota-check' in debugflags_var.get():
sleep(2) sleep(2)
err_unavailable_products = []
for iop, op in enumerate(self._operations): for iop, op in enumerate(self._operations):
if isinstance(op, self.RemoveOperation): if isinstance(op, self.RemoveOperation):
if op.position.expires > self.now_dt: if op.position.expires > self.now_dt:
@@ -1143,15 +1130,9 @@ class CartManager:
voucher_available_count = min(voucher_available_count, vouchers_ok[op.voucher]) voucher_available_count = min(voucher_available_count, vouchers_ok[op.voucher])
if quota_available_count < 1: if quota_available_count < 1:
err = err or error_messages['unavailable_listed'] err = err or error_messages['unavailable']
err_unavailable_products.append(
f'{op.item.name} {op.variation}' if op.variation else op.item.name
)
elif quota_available_count < requested_count: elif quota_available_count < requested_count:
err = err or error_messages['in_part_listed'] err = err or error_messages['in_part']
err_unavailable_products.append(
f'{op.item.name} {op.variation}' if op.variation else op.item.name
)
if voucher_available_count < 1: if voucher_available_count < 1:
if op.voucher in self._voucher_depend_on_cart: if op.voucher in self._voucher_depend_on_cart:
@@ -1168,25 +1149,16 @@ class CartManager:
b_quotas = list(b.quotas) b_quotas = list(b.quotas)
if not b_quotas: if not b_quotas:
if not op.voucher or not op.voucher.allow_ignore_quota: if not op.voucher or not op.voucher.allow_ignore_quota:
err = err or error_messages['unavailable_listed'] err = err or error_messages['unavailable']
err_unavailable_products.append(
f'{op.item.name} {op.variation}' if op.variation else op.item.name
)
available_count = 0 available_count = 0
continue continue
b_quota_available_count = min(available_count * b.count, min(quotas_ok[q] for q in b_quotas)) b_quota_available_count = min(available_count * b.count, min(quotas_ok[q] for q in b_quotas))
if b_quota_available_count < b.count: if b_quota_available_count < b.count:
err = err or error_messages['unavailable_listed'] err = err or error_messages['unavailable']
err_unavailable_products.append(
f'{op.item.name} {op.variation}' if op.variation else op.item.name
)
available_count = 0 available_count = 0
elif b_quota_available_count < available_count * b.count: elif b_quota_available_count < available_count * b.count:
err = err or error_messages['in_part_listed'] err = err or error_messages['in_part']
available_count = b_quota_available_count // b.count available_count = b_quota_available_count // b.count
err_unavailable_products.append(
f'{op.item.name} {op.variation}' if op.variation else op.item.name
)
for q in b_quotas: for q in b_quotas:
quotas_ok[q] -= available_count * b.count quotas_ok[q] -= available_count * b.count
# TODO: is this correct? # TODO: is this correct?
@@ -1325,23 +1297,10 @@ class CartManager:
op.position.price_after_voucher = op.price_after_voucher op.position.price_after_voucher = op.price_after_voucher
op.position.voucher = op.voucher op.position.voucher = op.voucher
# op.position.price will be set in recompute_final_prices_and_taxes # op.posiiton.price will be set in recompute_final_prices_and_taxes
op.position.save(update_fields=['price_after_voucher', 'voucher']) op.position.save(update_fields=['price_after_voucher', 'voucher'])
vouchers_ok[op.voucher] -= 1 vouchers_ok[op.voucher] -= 1
if op.voucher.all_bundles_included or op.voucher.all_addons_included:
for a in op.position.addons.all():
if a.is_bundled and op.voucher.all_bundles_included and a.price:
a.listed_price = Decimal("0.00")
a.price_after_voucher = Decimal("0.00")
# a.price will be set in recompute_final_prices_and_taxes
a.save(update_fields=['listed_price', 'price_after_voucher'])
elif not a.is_bundled and op.voucher.all_addons_included and a.price and not a.custom_price_input:
a.listed_price = Decimal("0.00")
a.price_after_voucher = Decimal("0.00")
# op.positon.price will be set in recompute_final_prices_and_taxes
a.save(update_fields=['listed_price', 'price_after_voucher'])
for p in new_cart_positions: for p in new_cart_positions:
if getattr(p, '_answers', None): if getattr(p, '_answers', None):
if not p.pk: # We stored some to the database already before if not p.pk: # We stored some to the database already before
@@ -1351,10 +1310,6 @@ class CartManager:
if 'sleep-before-commit' in debugflags_var.get(): if 'sleep-before-commit' in debugflags_var.get():
sleep(2) sleep(2)
if err in (error_messages['unavailable_listed'], error_messages['in_part_listed']):
err = err % ', '.join(str(p) for p in err_unavailable_products)
return err return err
def recompute_final_prices_and_taxes(self): def recompute_final_prices_and_taxes(self):
+5 -86
View File
@@ -31,7 +31,6 @@
# 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 logging
import os import os
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from functools import partial, reduce from functools import partial, reduce
@@ -66,8 +65,6 @@ from pretix.helpers.jsonlogic_query import (
MinutesSince, tolerance, MinutesSince, tolerance,
) )
logger = logging.getLogger(__name__)
def _build_time(t=None, value=None, ev=None, now_dt=None): def _build_time(t=None, value=None, ev=None, now_dt=None):
now_dt = now_dt or now() now_dt = now_dt or now()
@@ -202,7 +199,7 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
'var': values[0]["var"], 'var': values[0]["var"],
'rhs': values[1:], 'rhs': values[1:],
} }
elif any(t in values[0] for t in ("entries_since", "entries_before", "entries_days_since", "entries_days_before")): elif "entries_since" in values[0] or "entries_before" in values[0]:
_var_explanations[new_var_name] = { _var_explanations[new_var_name] = {
'operator': operator, 'operator': operator,
'var': values[0], 'var': values[0],
@@ -280,13 +277,11 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
var_weights[vname] = (500, 0) var_weights[vname] = (500, 0)
var_texts[vname] = _('Wrong entrance gate') var_texts[vname] = _('Wrong entrance gate')
elif var in ('entries_number', 'entries_today', 'entries_days', 'minutes_since_last_entry', 'minutes_since_first_entry', 'now_isoweekday') \ elif var in ('entries_number', 'entries_today', 'entries_days', 'minutes_since_last_entry', 'minutes_since_first_entry', 'now_isoweekday') \
or (isinstance(var, dict) and any(t in var for t in ("entries_since", "entries_before", "entries_days_since", "entries_days_before"))): or (isinstance(var, dict) and ("entries_since" in var or "entries_before" in var)):
w = { w = {
'minutes_since_first_entry': 80, 'minutes_since_first_entry': 80,
'minutes_since_last_entry': 90, 'minutes_since_last_entry': 90,
'entries_days': 100, 'entries_days': 100,
'entries_days_since': 105,
'entries_days_before': 105,
'entries_since': 110, 'entries_since': 110,
'entries_before': 110, 'entries_before': 110,
'entries_number': 120, 'entries_number': 120,
@@ -309,12 +304,10 @@ def _logic_explain(rules, ev, rule_data, now_dt=None):
'entries_today': _('number of entries today'), 'entries_today': _('number of entries today'),
'entries_since': _('number of entries since {datetime}'), 'entries_since': _('number of entries since {datetime}'),
'entries_before': _('number of entries before {datetime}'), 'entries_before': _('number of entries before {datetime}'),
'entries_days_since': _('number of days with an entry since {datetime}'),
'entries_days_before': _('number of days with an entry before {datetime}'),
'now_isoweekday': _('week day'), 'now_isoweekday': _('week day'),
} }
if isinstance(var, dict) and any(t in var for t in ("entries_since", "entries_before", "entries_days_since", "entries_days_before")): if isinstance(var, dict) and ("entries_since" in var or "entries_before" in var):
varname = list(var.keys())[0] varname = list(var.keys())[0]
cutoff = _build_time(*var[varname][0]['buildTime'], ev=ev, now_dt=now_dt).astimezone(ev.timezone) cutoff = _build_time(*var[varname][0]['buildTime'], ev=ev, now_dt=now_dt).astimezone(ev.timezone)
if abs(now_dt - cutoff) < timedelta(hours=12): if abs(now_dt - cutoff) < timedelta(hours=12):
@@ -414,8 +407,6 @@ def _get_logic_environment(ev, rule_data, now_dt):
logic.add_operation('isAfter', lambda t1, t2, tol=None: is_before(t2, t1, tol)) logic.add_operation('isAfter', lambda t1, t2, tol=None: is_before(t2, t1, tol))
logic.add_operation('entries_since', lambda t1: rule_data.entries_since(t1)) logic.add_operation('entries_since', lambda t1: rule_data.entries_since(t1))
logic.add_operation('entries_before', lambda t1: rule_data.entries_before(t1)) logic.add_operation('entries_before', lambda t1: rule_data.entries_before(t1))
logic.add_operation('entries_days_since', lambda t1: rule_data.entries_days_since(t1))
logic.add_operation('entries_days_before', lambda t1: rule_data.entries_days_before(t1))
return logic return logic
@@ -473,32 +464,6 @@ class LazyRuleVars:
self.__cache['entries_before', cutoff] = self._position.checkins.filter(type=Checkin.TYPE_ENTRY, list=self._clist, datetime__lt=cutoff).count() self.__cache['entries_before', cutoff] = self._position.checkins.filter(type=Checkin.TYPE_ENTRY, list=self._clist, datetime__lt=cutoff).count()
return self.__cache['entries_before', cutoff] return self.__cache['entries_before', cutoff]
def entries_days_since(self, cutoff):
tz = self._clist.event.timezone
with override(tz):
if ('entries_days_since', cutoff) not in self.__cache:
self.__cache['entries_days_since', cutoff] = self._position.checkins.filter(
type=Checkin.TYPE_ENTRY,
list=self._clist,
datetime__gte=cutoff
).annotate(
day=TruncDate('datetime', tzinfo=tz)
).values('day').distinct().count()
return self.__cache['entries_days_since', cutoff]
def entries_days_before(self, cutoff):
tz = self._clist.event.timezone
with override(tz):
if ('entries_days_before', cutoff) not in self.__cache:
self.__cache['entries_days_before', cutoff] = self._position.checkins.filter(
type=Checkin.TYPE_ENTRY,
list=self._clist,
datetime__lt=cutoff
).annotate(
day=TruncDate('datetime', tzinfo=tz)
).values('day').distinct().count()
return self.__cache['entries_days_before', cutoff]
@cached_property @cached_property
def entries_days(self): def entries_days(self):
tz = self._clist.event.timezone tz = self._clist.event.timezone
@@ -565,8 +530,7 @@ class SQLLogic:
"isBefore": partial(self.comparison_to_q, operator=LowerThan, modifier=partial(tolerance, sign=1)), "isBefore": partial(self.comparison_to_q, operator=LowerThan, modifier=partial(tolerance, sign=1)),
"isAfter": partial(self.comparison_to_q, operator=GreaterThan, modifier=partial(tolerance, sign=-1)), "isAfter": partial(self.comparison_to_q, operator=GreaterThan, modifier=partial(tolerance, sign=-1)),
} }
self.expression_ops = {'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before', self.expression_ops = {'buildTime', 'objectList', 'lookup', 'var', 'entries_since', 'entries_before'}
'entries_days_since', 'entries_days_before'}
def operation_to_expression(self, rule): def operation_to_expression(self, rule):
if not isinstance(rule, dict): if not isinstance(rule, dict):
@@ -644,42 +608,6 @@ class SQLLogic:
Value(0), Value(0),
output_field=IntegerField() output_field=IntegerField()
) )
elif operator == 'entries_days_since':
tz = self.list.event.timezone
return Coalesce(
Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
type=Checkin.TYPE_ENTRY,
list_id=self.list.pk,
datetime__gte=self.operation_to_expression(values[0]),
).annotate(
day=TruncDate('datetime', tzinfo=tz)
).values('position_id').order_by().annotate(
c=Count('day', distinct=True)
).values('c')
),
Value(0),
output_field=IntegerField()
)
elif operator == 'entries_days_before':
tz = self.list.event.timezone
return Coalesce(
Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
type=Checkin.TYPE_ENTRY,
list_id=self.list.pk,
datetime__lt=self.operation_to_expression(values[0]),
).annotate(
day=TruncDate('datetime', tzinfo=tz)
).values('position_id').order_by().annotate(
c=Count('day', distinct=True)
).values('c')
),
Value(0),
output_field=IntegerField()
)
elif operator == 'var': elif operator == 'var':
if values[0] == 'now': if values[0] == 'now':
return Value(now().astimezone(timezone.utc)) return Value(now().astimezone(timezone.utc))
@@ -1036,16 +964,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
if type == Checkin.TYPE_ENTRY and clist.rules: if type == Checkin.TYPE_ENTRY and clist.rules:
rule_data = LazyRuleVars(op, clist, dt, gate=gate) rule_data = LazyRuleVars(op, clist, dt, gate=gate)
logic = _get_logic_environment(op.subevent or clist.event, rule_data, now_dt=dt) logic = _get_logic_environment(op.subevent or clist.event, rule_data, now_dt=dt)
try: if not logic.apply(clist.rules, rule_data):
logic_result = logic.apply(clist.rules, rule_data)
except Exception:
logger.exception("Check-in rule evaluation failed")
raise CheckInError(
_('Evaluation of custom rules has failed.'),
'rules',
)
if not logic_result:
if force: if force:
force_used = True force_used = True
else: else:
+7 -7
View File
@@ -104,10 +104,10 @@ def build_invoice(invoice: Invoice) -> Invoice:
expire_date=date_format(invoice.order.expires, "SHORT_DATE_FORMAT") expire_date=date_format(invoice.order.expires, "SHORT_DATE_FORMAT")
) )
invoice.introductory_text = str(introductory).replace('\n', '<br />').replace('\r', '') invoice.introductory_text = str(introductory).replace('\n', '<br />')
invoice.additional_text = str(additional).replace('\n', '<br />').replace('\r', '') invoice.additional_text = str(additional).replace('\n', '<br />')
invoice.footer_text = str(footer) invoice.footer_text = str(footer)
invoice.payment_provider_text = str(payment).replace('\n', '<br />').replace('\r', '') invoice.payment_provider_text = str(payment).replace('\n', '<br />')
invoice.payment_provider_stamp = str(payment_stamp) if payment_stamp else None invoice.payment_provider_stamp = str(payment_stamp) if payment_stamp else None
try: try:
@@ -462,10 +462,10 @@ def build_preview_invoice_pdf(event):
footer = event.settings.get('invoice_footer_text', as_type=LazyI18nString) footer = event.settings.get('invoice_footer_text', as_type=LazyI18nString)
payment = _("A payment provider specific text might appear here.") payment = _("A payment provider specific text might appear here.")
invoice.introductory_text = str(introductory).replace('\n', '<br />').replace('\r', '') invoice.introductory_text = str(introductory).replace('\n', '<br />')
invoice.additional_text = str(additional).replace('\n', '<br />').replace('\r', '') invoice.additional_text = str(additional).replace('\n', '<br />')
invoice.footer_text = str(footer) invoice.footer_text = str(footer)
invoice.payment_provider_text = str(payment).replace('\n', '<br />').replace('\r', '') invoice.payment_provider_text = str(payment).replace('\n', '<br />')
invoice.payment_provider_stamp = _('paid') invoice.payment_provider_stamp = _('paid')
invoice.invoice_to_name = _("John Doe") invoice.invoice_to_name = _("John Doe")
invoice.invoice_to_street = _("214th Example Street") invoice.invoice_to_street = _("214th Example Street")
@@ -488,7 +488,7 @@ def build_preview_invoice_pdf(event):
InvoiceLine.objects.create( InvoiceLine.objects.create(
invoice=invoice, description=_("Sample product {}").format(i + 1), invoice=invoice, description=_("Sample product {}").format(i + 1),
gross_value=tax.gross, tax_value=tax.tax, gross_value=tax.gross, tax_value=tax.tax,
tax_rate=tax.rate, tax_name=tax.name tax_rate=tax.rate
) )
else: else:
for i in range(5): for i in range(5):
+6 -6
View File
@@ -183,9 +183,12 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
if auto_email: if auto_email:
headers['X-Auto-Response-Suppress'] = 'OOF, NRN, AutoReply, RN' headers['X-Auto-Response-Suppress'] = 'OOF, NRN, AutoReply, RN'
headers['Auto-Submitted'] = 'auto-generated' headers['Auto-Submitted'] = 'auto-generated'
headers.setdefault('X-Mailer', 'pretix')
with language(locale): with language(locale):
if isinstance(context, dict) and event:
for k, v in event.meta_data.items():
context['meta_' + k] = v
if isinstance(context, dict) and order: if isinstance(context, dict) and order:
try: try:
context.update({ context.update({
@@ -570,11 +573,8 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
except smtplib.SMTPRecipientsRefused as e: except smtplib.SMTPRecipientsRefused as e:
smtp_codes = [a[0] for a in e.recipients.values()] smtp_codes = [a[0] for a in e.recipients.values()]
if not any(c >= 500 for c in smtp_codes) or any(b'Message is too large' in a[1] for a in e.recipients.values()): if not any(c >= 500 for c in smtp_codes):
# This is not a permanent failure (mailbox full, service unavailable), retry later, but with large # Not a permanent failure (mailbox full, service unavailable), retry later, but with large intervals
# intervals. One would think that "Message is too lage" is a permanent failure, but apparently it is not.
# We have documented cases of emails to Microsoft returning the error occasionally and then later
# allowing the very same email.
try: try:
self.retry(max_retries=5, countdown=[60, 300, 600, 1200, 1800, 1800][self.request.retries]) self.retry(max_retries=5, countdown=[60, 300, 600, 1200, 1800, 1800][self.request.retries])
except MaxRetriesExceededError: except MaxRetriesExceededError:
+1 -1
View File
@@ -136,7 +136,7 @@ def send_notification_mail(notification: Notification, user: User):
tpl_html = get_template('pretixbase/email/notification.html') tpl_html = get_template('pretixbase/email/notification.html')
body_html = tpl_html.render(ctx) body_html = tpl_html.render(ctx)
inliner = css_inline.CSSInliner(keep_style_tags=False) inliner = css_inline.CSSInliner(remove_style_tags=True)
body_html = inliner.inline(body_html) body_html = inliner.inline(body_html)
tpl_plain = get_template('pretixbase/email/notification.txt') tpl_plain = get_template('pretixbase/email/notification.txt')
+1 -15
View File
@@ -23,7 +23,6 @@ import csv
import io import io
from decimal import Decimal from decimal import Decimal
from django.conf import settings as django_settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction from django.db import transaction
from django.utils.timezone import now from django.utils.timezone import now
@@ -92,15 +91,7 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
user = User.objects.get(pk=user) user = User.objects.get(pk=user)
with language(locale, event.settings.region): with language(locale, event.settings.region):
cols = get_all_columns(event) cols = get_all_columns(event)
try: parsed = parse_csv(cf.file, charset=charset)
parsed = parse_csv(cf.file, charset=charset)
except UnicodeDecodeError as e:
raise DataImportError(
_(
'Error decoding special characters in your file: {message}').format(
message=str(e)
)
)
orders = [] orders = []
order = None order = None
data = [] data = []
@@ -125,11 +116,6 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
) )
data.append(values) data.append(values)
if settings['orders'] == 'one' and len(data) > django_settings.PRETIX_MAX_ORDER_SIZE:
raise DataImportError(
_('Orders cannot have more than %(max)s positions.') % {'max': django_settings.PRETIX_MAX_ORDER_SIZE}
)
# Prepare model objects. Yes, this might consume lots of RAM, but allows us to make the actual SQL transaction # Prepare model objects. Yes, this might consume lots of RAM, but allows us to make the actual SQL transaction
# shorter. We'll see what works better in reality… # shorter. We'll see what works better in reality…
lock_seats = [] lock_seats = []
+17 -16
View File
@@ -98,9 +98,10 @@ from pretix.base.services.pricing import (
from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
from pretix.base.signals import ( from pretix.base.signals import (
order_approved, order_canceled, order_changed, order_denied, order_expired, allow_ticket_download, order_approved, order_canceled, order_changed,
order_fee_calculation, order_paid, order_placed, order_split, order_denied, order_expired, order_fee_calculation, order_paid,
order_valid_if_pending, periodic_task, validate_order, order_placed, order_split, order_valid_if_pending, periodic_task,
validate_order,
) )
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
@@ -1407,16 +1408,23 @@ def send_download_reminders(sender, **kwargs):
if o.download_reminder_sent: if o.download_reminder_sent:
# Race condition # Race condition
continue continue
positions = list(o.positions_with_tickets) if not all([r for rr, r in allow_ticket_download.send(event, order=o)]):
if not positions:
continue continue
if not o.ticket_download_available: if not o.ticket_download_available:
continue continue
positions = o.positions.select_related('item')
if o.status != Order.STATUS_PAID: if o.status != Order.STATUS_PAID:
if o.status != Order.STATUS_PENDING or o.require_approval or (not o.valid_if_pending and not o.event.settings.ticket_download_pending): if o.status != Order.STATUS_PENDING or o.require_approval or (not o.valid_if_pending and not o.event.settings.ticket_download_pending):
continue continue
send = False
for p in positions:
if p.generate_ticket:
send = True
break
if not send:
continue
with language(o.locale, o.event.settings.region): with language(o.locale, o.event.settings.region):
o.download_reminder_sent = True o.download_reminder_sent = True
@@ -1434,7 +1442,10 @@ def send_download_reminders(sender, **kwargs):
logger.exception('Reminder email could not be sent') logger.exception('Reminder email could not be sent')
if event.settings.mail_send_download_reminder_attendee: if event.settings.mail_send_download_reminder_attendee:
for p in positions: for p in o.positions.all():
if not p.generate_ticket:
continue
if p.subevent_id: if p.subevent_id:
reminder_date = (p.subevent.date_from - timedelta(days=days)).replace( reminder_date = (p.subevent.date_from - timedelta(days=days)).replace(
hour=0, minute=0, second=0, microsecond=0 hour=0, minute=0, second=0, microsecond=0
@@ -1501,7 +1512,6 @@ class OrderChangeManager:
"You need to select at least %(min)s items of the product %(product)s.", "You need to select at least %(min)s items of the product %(product)s.",
"min" "min"
), ),
'max_order_size': gettext_lazy('Orders cannot have more than %(max)s positions.'),
} }
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation')) ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation'))
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent')) SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent'))
@@ -2589,14 +2599,6 @@ class OrderChangeManager:
self.order.total = total + payment_fee self.order.total = total + payment_fee
self.order.save() self.order.save()
def _check_order_size(self):
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(
self.error_messages['max_order_size'] % {
'max': settings.PRETIX_MAX_ORDER_SIZE,
}
)
def _payment_fee_diff(self): def _payment_fee_diff(self):
total = self.order.total + self._totaldiff total = self.order.total + self._totaldiff
if self.open_payment: if self.open_payment:
@@ -2737,7 +2739,6 @@ class OrderChangeManager:
# finally, incorporate difference in payment fees # finally, incorporate difference in payment fees
self._payment_fee_diff() self._payment_fee_diff()
self._check_order_size()
with transaction.atomic(): with transaction.atomic():
locked_instance = Order.objects.select_for_update(of=OF_SELF).get(pk=self.order.pk) locked_instance = Order.objects.select_for_update(of=OF_SELF).get(pk=self.order.pk)
-584
View File
@@ -1,584 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <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 inspect
import logging
from datetime import timedelta
from decimal import Decimal
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from pretix.base.forms import PlaceholderValidator
from pretix.base.forms.widgets import format_placeholders_help_text
from pretix.base.i18n import (
LazyCurrencyNumber, LazyDate, LazyExpiresDate, LazyNumber,
)
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized
from pretix.base.signals import (
register_mail_placeholders, register_text_placeholders,
)
from pretix.helpers.format import SafeFormatter
logger = logging.getLogger('pretix.base.services.placeholders')
class BaseTextPlaceholder:
"""
This is the base class for all email text placeholders.
"""
@property
def required_context(self):
"""
This property should return a list of all attribute names that need to be
contained in the base context so that this placeholder is available. By default,
it returns a list containing the string "event".
"""
return ["event"]
@property
def identifier(self):
"""
This should return the identifier of this placeholder in the email.
"""
raise NotImplementedError()
def render(self, context):
"""
This method is called to generate the actual text that is being
used in the email. You will be passed a context dictionary with the
base context attributes specified in ``required_context``. You are
expected to return a plain-text string.
"""
raise NotImplementedError()
def render_sample(self, event):
"""
This method is called to generate a text to be used in email previews.
This may only depend on the event.
"""
raise NotImplementedError()
class SimpleFunctionalTextPlaceholder(BaseTextPlaceholder):
def __init__(self, identifier, args, func, sample):
self._identifier = identifier
self._args = args
self._func = func
self._sample = sample
@property
def identifier(self):
return self._identifier
@property
def required_context(self):
return self._args
def render(self, context):
return self._func(**{k: context[k] for k in self._args})
def render_sample(self, event):
if callable(self._sample):
return self._sample(event)
else:
return self._sample
class PlaceholderContext(SafeFormatter):
"""
Holds the contextual arguments and corresponding list of available placeholders for formatting
an email or other templated text.
Example:
context = PlaceholderContext(event=my_event, order=my_order)
formatted_doc = context.format(input_doc)
"""
def __init__(self, **kwargs):
super().__init__({})
self.context_args = kwargs
self._extend_context_args()
self.placeholders = {}
self.cache = {}
event = kwargs['event']
for r, val in [
*register_mail_placeholders.send(sender=event),
*register_text_placeholders.send(sender=event)
]:
if not isinstance(val, (list, tuple)):
val = [val]
for v in val:
if all(rp in kwargs for rp in v.required_context):
self.placeholders[v.identifier] = v
def _extend_context_args(self):
from pretix.base.models import InvoiceAddress
if 'position' in self.context_args:
self.context_args.setdefault("position_or_address", self.context_args['position'])
if 'order' in self.context_args:
try:
if not self.context_args.get('invoice_address'):
self.context_args['invoice_address'] = self.context_args['order'].invoice_address
except InvoiceAddress.DoesNotExist:
self.context_args['invoice_address'] = InvoiceAddress(order=self.context_args['order'])
finally:
self.context_args.setdefault("position_or_address", self.context_args['invoice_address'])
def render_placeholder(self, placeholder):
try:
return self.cache[placeholder.identifier]
except KeyError:
try:
value = self.cache[placeholder.identifier] = placeholder.render(self.context_args)
return value
except:
logger.exception(f'Failed to process template placeholder {placeholder.identifier}.')
return '(error)'
def render_all(self):
return {identifier: self.render_placeholder(placeholder)
for (identifier, placeholder) in self.placeholders.items()}
def get_value(self, key, args, kwargs):
if key not in self.placeholders:
return '{' + str(key) + '}'
return self.render_placeholder(self.placeholders[key])
def _placeholder_payments(order, payments):
d = []
for payment in payments:
if 'payment' in inspect.signature(payment.payment_provider.order_pending_mail_render).parameters:
d.append(str(payment.payment_provider.order_pending_mail_render(order, payment)))
else:
d.append(str(payment.payment_provider.order_pending_mail_render(order)))
d = [line for line in d if line.strip()]
if d:
return '\n\n'.join(d)
else:
return ''
def get_best_name(position_or_address, parts=False):
"""
Return the best name we got for either an invoice address or an order position, falling back to the respective other
"""
from pretix.base.models import InvoiceAddress, OrderPosition
if isinstance(position_or_address, InvoiceAddress):
if position_or_address.name:
return position_or_address.name_parts if parts else position_or_address.name
elif position_or_address.order:
position_or_address = position_or_address.order.positions.exclude(attendee_name_cached="").exclude(attendee_name_cached__isnull=True).first()
if isinstance(position_or_address, OrderPosition):
if position_or_address.attendee_name:
return position_or_address.attendee_name_parts if parts else position_or_address.attendee_name
elif position_or_address.order:
try:
return position_or_address.order.invoice_address.name_parts if parts else position_or_address.order.invoice_address.name
except InvoiceAddress.DoesNotExist:
pass
return {} if parts else ""
@receiver(register_text_placeholders, dispatch_uid="pretixbase_register_text_placeholders")
def base_placeholders(sender, **kwargs):
from pretix.multidomain.urlreverse import build_absolute_uri
ph = [
SimpleFunctionalTextPlaceholder(
'event', ['event'], lambda event: event.name, lambda event: event.name
),
SimpleFunctionalTextPlaceholder(
'event', ['event_or_subevent'], lambda event_or_subevent: event_or_subevent.name,
lambda event_or_subevent: event_or_subevent.name
),
SimpleFunctionalTextPlaceholder(
'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug
),
SimpleFunctionalTextPlaceholder(
'code', ['order'], lambda order: order.code, 'F8VVL'
),
SimpleFunctionalTextPlaceholder(
'total', ['order'], lambda order: LazyNumber(order.total), lambda event: LazyNumber(Decimal('42.23'))
),
SimpleFunctionalTextPlaceholder(
'currency', ['event'], lambda event: event.currency, lambda event: event.currency
),
SimpleFunctionalTextPlaceholder(
'order_email', ['order'], lambda order: order.email, 'john@example.org'
),
SimpleFunctionalTextPlaceholder(
'invoice_number', ['invoice'],
lambda invoice: invoice.full_invoice_no,
f'{sender.settings.invoice_numbers_prefix or (sender.slug.upper() + "-")}00000'
),
SimpleFunctionalTextPlaceholder(
'refund_amount', ['event_or_subevent', 'refund_amount'],
lambda event_or_subevent, refund_amount: LazyCurrencyNumber(refund_amount, event_or_subevent.currency),
lambda event_or_subevent: LazyCurrencyNumber(Decimal('42.23'), event_or_subevent.currency)
),
SimpleFunctionalTextPlaceholder(
'pending_sum', ['event', 'pending_sum'],
lambda event, pending_sum: LazyCurrencyNumber(pending_sum, event.currency),
lambda event: LazyCurrencyNumber(Decimal('42.23'), event.currency)
),
SimpleFunctionalTextPlaceholder(
'total_with_currency', ['event', 'order'], lambda event, order: LazyCurrencyNumber(order.total,
event.currency),
lambda event: LazyCurrencyNumber(Decimal('42.23'), event.currency)
),
SimpleFunctionalTextPlaceholder(
'expire_date', ['event', 'order'], lambda event, order: LazyExpiresDate(order.expires.astimezone(event.timezone)),
lambda event: LazyDate(now() + timedelta(days=15))
),
SimpleFunctionalTextPlaceholder(
'url', ['order', 'event'], lambda order, event: build_absolute_uri(
event,
'presale:event.order.open', kwargs={
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_hash()
}
), lambda event: build_absolute_uri(
event,
'presale:event.order.open', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
'hash': '98kusd8ofsj8dnkd'
}
),
),
SimpleFunctionalTextPlaceholder(
'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri(
event,
'presale:event.order.modify', kwargs={
'order': order.code,
'secret': order.secret,
}
), lambda event: build_absolute_uri(
event,
'presale:event.order.modify', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
}
),
),
SimpleFunctionalTextPlaceholder(
'url_products_change', ['order', 'event'], lambda order, event: build_absolute_uri(
event,
'presale:event.order.change', kwargs={
'order': order.code,
'secret': order.secret,
}
), lambda event: build_absolute_uri(
event,
'presale:event.order.change', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
}
),
),
SimpleFunctionalTextPlaceholder(
'url_cancel', ['order', 'event'], lambda order, event: build_absolute_uri(
event,
'presale:event.order.cancel', kwargs={
'order': order.code,
'secret': order.secret,
}
), lambda event: build_absolute_uri(
event,
'presale:event.order.cancel', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
}
),
),
SimpleFunctionalTextPlaceholder(
'url', ['event', 'position'], lambda event, position: build_absolute_uri(
event,
'presale:event.order.position',
kwargs={
'order': position.order.code,
'secret': position.web_secret,
'position': position.positionid
}
),
lambda event: build_absolute_uri(
event,
'presale:event.order.position', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
'position': '123'
}
),
),
SimpleFunctionalTextPlaceholder(
'order_modification_deadline_date_and_time', ['order', 'event'],
lambda order, event:
date_format(order.modify_deadline.astimezone(event.timezone), 'SHORT_DATETIME_FORMAT')
if order.modify_deadline
else '',
lambda event: date_format(
event.settings.get(
'last_order_modification_date', as_type=RelativeDateWrapper
).datetime(event).astimezone(event.timezone),
'SHORT_DATETIME_FORMAT'
) if event.settings.get('last_order_modification_date') else '',
),
SimpleFunctionalTextPlaceholder(
'event_location', ['event_or_subevent'], lambda event_or_subevent: str(event_or_subevent.location or ''),
lambda event: str(event.location or ''),
),
SimpleFunctionalTextPlaceholder(
'event_admission_time', ['event_or_subevent'],
lambda event_or_subevent:
date_format(event_or_subevent.date_admission.astimezone(event_or_subevent.timezone), 'TIME_FORMAT')
if event_or_subevent.date_admission
else '',
lambda event: date_format(event.date_admission.astimezone(event.timezone), 'TIME_FORMAT') if event.date_admission else '',
),
SimpleFunctionalTextPlaceholder(
'subevent', ['waiting_list_entry', 'event'],
lambda waiting_list_entry, event: str(waiting_list_entry.subevent or event),
lambda event: str(event if not event.has_subevents or not event.subevents.exists() else event.subevents.first())
),
SimpleFunctionalTextPlaceholder(
'subevent_date_from', ['waiting_list_entry', 'event'],
lambda waiting_list_entry, event: (waiting_list_entry.subevent or event).get_date_from_display(),
lambda event: (event if not event.has_subevents or not event.subevents.exists() else event.subevents.first()).get_date_from_display()
),
SimpleFunctionalTextPlaceholder(
'url_remove', ['waiting_list_voucher', 'event'],
lambda waiting_list_voucher, event: build_absolute_uri(
event, 'presale:event.waitinglist.remove'
) + '?voucher=' + waiting_list_voucher.code,
lambda event: build_absolute_uri(
event,
'presale:event.waitinglist.remove',
) + '?voucher=68CYU2H6ZTP3WLK5',
),
SimpleFunctionalTextPlaceholder(
'url', ['waiting_list_voucher', 'event'],
lambda waiting_list_voucher, event: build_absolute_uri(
event, 'presale:event.redeem'
) + '?voucher=' + waiting_list_voucher.code,
lambda event: build_absolute_uri(
event,
'presale:event.redeem',
) + '?voucher=68CYU2H6ZTP3WLK5',
),
SimpleFunctionalTextPlaceholder(
'invoice_name', ['invoice_address'], lambda invoice_address: invoice_address.name or '',
_('John Doe')
),
SimpleFunctionalTextPlaceholder(
'invoice_company', ['invoice_address'], lambda invoice_address: invoice_address.company or '',
_('Sample Corporation')
),
SimpleFunctionalTextPlaceholder(
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
'* {} - {}'.format(
order.full_code,
build_absolute_uri(event, 'presale:event.order.open', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_hash(),
}),
)
for order in orders
), lambda event: '\n' + '\n\n'.join(
'* {} - {}'.format(
'{}-{}'.format(event.slug.upper(), order['code']),
build_absolute_uri(event, 'presale:event.order.open', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'order': order['code'],
'secret': order['secret'],
'hash': order['hash'],
}),
)
for order in [
{'code': 'F8VVL', 'secret': '6zzjnumtsx136ddy', 'hash': 'abcdefghi'},
{'code': 'HIDHK', 'secret': '98kusd8ofsj8dnkd', 'hash': 'jklmnopqr'},
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd', 'hash': 'stuvwxy2z'}
]
),
),
SimpleFunctionalTextPlaceholder(
'hours', ['event', 'waiting_list_entry'], lambda event, waiting_list_entry:
event.settings.waiting_list_hours,
lambda event: event.settings.waiting_list_hours
),
SimpleFunctionalTextPlaceholder(
'product', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.item.name,
_('Sample Admission Ticket')
),
SimpleFunctionalTextPlaceholder(
'code', ['waiting_list_voucher'], lambda waiting_list_voucher: waiting_list_voucher.code,
'68CYU2H6ZTP3WLK5'
),
SimpleFunctionalTextPlaceholder(
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list),
' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2'
),
SimpleFunctionalTextPlaceholder(
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
'voucher_url_list', ['event', 'voucher_list'],
lambda event, voucher_list: ' \n'.join([
build_absolute_uri(
event, 'presale:event.redeem'
) + '?voucher=' + c
for c in voucher_list
]),
lambda event: ' \n'.join([
build_absolute_uri(
event, 'presale:event.redeem'
) + '?voucher=' + c
for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2']
]),
),
SimpleFunctionalTextPlaceholder(
'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
}), lambda event: build_absolute_uri(event, 'presale:event.index', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
})
),
SimpleFunctionalTextPlaceholder(
'name', ['name'], lambda name: name,
_('John Doe')
),
SimpleFunctionalTextPlaceholder(
'comment', ['comment'], lambda comment: comment,
_('An individual text with a reason can be inserted here.'),
),
SimpleFunctionalTextPlaceholder(
'payment_info', ['order', 'payments'], _placeholder_payments,
_('The amount has been charged to your card.'),
),
SimpleFunctionalTextPlaceholder(
'payment_info', ['payment_info'], lambda payment_info: payment_info,
_('Please transfer money to this bank account: 9999-9999-9999-9999'),
),
SimpleFunctionalTextPlaceholder(
'attendee_name', ['position'], lambda position: position.attendee_name,
_('John Doe'),
),
SimpleFunctionalTextPlaceholder(
'positionid', ['position'], lambda position: str(position.positionid),
'1'
),
SimpleFunctionalTextPlaceholder(
'name', ['position_or_address'],
get_best_name,
_('John Doe'),
),
]
name_scheme = PERSON_NAME_SCHEMES[sender.settings.name_scheme]
if "concatenation_for_salutation" in name_scheme:
concatenation_for_salutation = name_scheme["concatenation_for_salutation"]
else:
concatenation_for_salutation = name_scheme["concatenation"]
ph.append(SimpleFunctionalTextPlaceholder(
"name_for_salutation", ["waiting_list_entry"],
lambda waiting_list_entry: concatenation_for_salutation(waiting_list_entry.name_parts),
_("Mr Doe"),
))
ph.append(SimpleFunctionalTextPlaceholder(
"name", ["waiting_list_entry"],
lambda waiting_list_entry: waiting_list_entry.name or "",
_("Mr Doe"),
))
ph.append(SimpleFunctionalTextPlaceholder(
"name_for_salutation", ["position_or_address"],
lambda position_or_address: concatenation_for_salutation(get_best_name(position_or_address, parts=True)),
_("Mr Doe"),
))
for f, l, w in name_scheme['fields']:
if f == 'full_name':
continue
ph.append(SimpleFunctionalTextPlaceholder(
'name_%s' % f, ['waiting_list_entry'], lambda waiting_list_entry, f=f: get_name_parts_localized(waiting_list_entry.name_parts, f),
name_scheme['sample'][f]
))
ph.append(SimpleFunctionalTextPlaceholder(
'attendee_name_%s' % f, ['position'], lambda position, f=f: get_name_parts_localized(position.attendee_name_parts, f),
name_scheme['sample'][f]
))
ph.append(SimpleFunctionalTextPlaceholder(
'name_%s' % f, ['position_or_address'],
lambda position_or_address, f=f: get_name_parts_localized(get_best_name(position_or_address, parts=True), f),
name_scheme['sample'][f]
))
for k, v in sender.meta_data.items():
ph.append(SimpleFunctionalTextPlaceholder(
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
v
))
ph.append(SimpleFunctionalTextPlaceholder(
'meta_%s' % k, ['event_or_subevent'], lambda event_or_subevent, k=k: event_or_subevent.meta_data[k],
v
))
return ph
class FormPlaceholderMixin:
def _set_field_placeholders(self, fn, base_parameters):
placeholders = get_available_placeholders(self.event, base_parameters)
ht = format_placeholders_help_text(placeholders, self.event)
if self.fields[fn].help_text:
self.fields[fn].help_text += ' ' + str(ht)
else:
self.fields[fn].help_text = ht
self.fields[fn].validators.append(
PlaceholderValidator(['{%s}' % p for p in placeholders.keys()])
)
def get_available_placeholders(event, base_parameters):
if 'order' in base_parameters:
base_parameters.append('invoice_address')
base_parameters.append('position_or_address')
params = {}
for r, val in [*register_mail_placeholders.send(sender=event), *register_text_placeholders.send(sender=event)]:
if not isinstance(val, (list, tuple)):
val = [val]
for v in val:
if all(rp in base_parameters for rp in v.required_context):
params[v.identifier] = v
return params
+2 -7
View File
@@ -112,7 +112,7 @@ def dictsum(*dicts) -> dict:
def order_overview( def order_overview(
event: Event, subevent: SubEvent=None, date_filter='', date_from=None, date_until=None, fees=False, event: Event, subevent: SubEvent=None, date_filter='', date_from=None, date_until=None, fees=False,
admission_only=False, base_qs=None, base_fees_qs=None, subevent_date_from=None, subevent_date_until=None admission_only=False, base_qs=None, base_fees_qs=None,
) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]: ) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]:
items = event.items.all().select_related( items = event.items.all().select_related(
'category', # for re-grouping 'category', # for re-grouping
@@ -125,11 +125,6 @@ def order_overview(
qs = qs.filter(subevent__in=subevent) qs = qs.filter(subevent__in=subevent)
elif subevent: elif subevent:
qs = qs.filter(subevent=subevent) qs = qs.filter(subevent=subevent)
if subevent_date_from:
qs = qs.filter(subevent__date_from__gte=subevent_date_from)
if subevent_date_until:
qs = qs.filter(subevent__date_from__lt=subevent_date_until)
if admission_only: if admission_only:
qs = qs.filter(item__admission=True) qs = qs.filter(item__admission=True)
items = items.filter(admission=True) items = items.filter(admission=True)
@@ -237,7 +232,7 @@ def order_overview(
payment_cat_obj.name = _('Fees') payment_cat_obj.name = _('Fees')
payment_items = [] payment_items = []
if subevent is None and not subevent_date_from and not subevent_date_until and fees: if subevent is None and fees:
qs = OrderFee.all if base_fees_qs is None else base_fees_qs qs = OrderFee.all if base_fees_qs is None else base_fees_qs
qs = qs.filter( qs = qs.filter(
order__event=event order__event=event
+6 -3
View File
@@ -34,7 +34,7 @@ from pretix.base.models import (
) )
from pretix.base.services.tasks import EventTask, ProfiledTask from pretix.base.services.tasks import EventTask, ProfiledTask
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import register_ticket_outputs from pretix.base.signals import allow_ticket_download, register_ticket_outputs
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers.database import rolledback_transaction from pretix.helpers.database import rolledback_transaction
@@ -124,8 +124,8 @@ def preview(event: int, provider: str):
def get_tickets_for_order(order, base_position=None): def get_tickets_for_order(order, base_position=None):
positions = list(order.positions_with_tickets) can_download = all([r for rr, r in allow_ticket_download.send(order.event, order=order)])
if not positions: if not can_download:
return [] return []
if not order.ticket_download_available: if not order.ticket_download_available:
return [] return []
@@ -135,8 +135,10 @@ def get_tickets_for_order(order, base_position=None):
for receiver, response for receiver, response
in register_ticket_outputs.send(order.event) in register_ticket_outputs.send(order.event)
] ]
tickets = [] tickets = []
positions = list(order.positions_with_tickets)
if base_position: if base_position:
# Only the given position and its children # Only the given position and its children
positions = [ positions = [
@@ -200,6 +202,7 @@ def get_tickets_for_order(order, base_position=None):
)) ))
except: except:
logger.exception('Failed to generate ticket.') logger.exception('Failed to generate ticket.')
return tickets return tickets
+1 -28
View File
@@ -306,11 +306,9 @@ DEFAULTS = {
'serializer_class': serializers.IntegerField, 'serializer_class': serializers.IntegerField,
'serializer_kwargs': dict( 'serializer_kwargs': dict(
min_value=1, min_value=1,
max_value=settings.PRETIX_MAX_ORDER_SIZE,
), ),
'form_kwargs': dict( 'form_kwargs': dict(
min_value=1, min_value=1,
max_value=settings.PRETIX_MAX_ORDER_SIZE,
required=True, required=True,
label=_("Maximum number of items per order"), label=_("Maximum number of items per order"),
help_text=_("Add-on products will not be counted.") help_text=_("Add-on products will not be counted.")
@@ -2912,25 +2910,6 @@ Your {organizer} team""")) # noqa: W291
label=_('Use header image also for events without an individually uploaded logo'), label=_('Use header image also for events without an individually uploaded logo'),
) )
}, },
'favicon': {
'default': None,
'type': File,
'form_class': ExtFileField,
'form_kwargs': dict(
label=_('Favicon'),
ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_FAVICON,
max_size=settings.FILE_UPLOAD_MAX_SIZE_FAVICON,
help_text=_('If you provide a favicon, we will show it instead of the default pretix icon. '
'We recommend a size of at least 200x200px to accommodate most devices.')
),
'serializer_class': UploadedFileField,
'serializer_kwargs': dict(
allowed_types=[
'image/png', 'image/jpeg', 'image/gif', 'image/x-icon', 'image/vnd.microsoft.icon',
],
max_size=settings.FILE_UPLOAD_MAX_SIZE_FAVICON,
)
},
'og_image': { 'og_image': {
'default': None, 'default': None,
'type': File, 'type': File,
@@ -3229,12 +3208,6 @@ Your {organizer} team""")) # noqa: W291
label=_('Length of gift card codes'), label=_('Length of gift card codes'),
help_text=_('The system generates by default {}-character long gift card codes. However, if a different length ' help_text=_('The system generates by default {}-character long gift card codes. However, if a different length '
'is required, it can be set here.'.format(settings.ENTROPY['giftcard_secret'])), 'is required, it can be set here.'.format(settings.ENTROPY['giftcard_secret'])),
min_value=6,
max_value=64,
),
'serializer_kwargs': dict(
min_value=6,
max_value=64,
) )
}, },
'giftcard_expiry_years': { 'giftcard_expiry_years': {
@@ -3663,7 +3636,7 @@ COUNTRIES_WITH_STATE_IN_ADDRESS = {
'BR': (['State'], 'short'), 'BR': (['State'], 'short'),
'CA': (['Province', 'Territory'], 'short'), 'CA': (['Province', 'Territory'], 'short'),
# 'CN': (['Province', 'Autonomous region', 'Munincipality'], 'long'), # 'CN': (['Province', 'Autonomous region', 'Munincipality'], 'long'),
'MY': (['State', 'Federal territory'], 'long'), 'MY': (['State'], 'long'),
'MX': (['State', 'Federal district'], 'short'), 'MX': (['State', 'Federal district'], 'short'),
'US': (['State', 'Outlying area', 'District'], 'short'), 'US': (['State', 'Outlying area', 'District'], 'short'),
} }
+11 -39
View File
@@ -153,47 +153,19 @@ class BaseDataShredder:
def shred_log_fields(logentry, banlist=None, whitelist=None): def shred_log_fields(logentry, banlist=None, whitelist=None):
def _shred(d, banlist, whitelist):
shredded = False
if whitelist:
for k, v in d.items():
if k not in whitelist:
if isinstance(d[k], list):
newlist = []
for i in d[k]:
if isinstance(i, dict):
_shred(i, None, [None])
else:
i = ''
newlist.append(i)
d[k] = newlist
elif isinstance(d[k], dict):
_shred(d[k], None, [None])
elif d[k]:
d[k] = ''
shredded = True
elif banlist:
for k in banlist:
if k in d:
if isinstance(d[k], list):
newlist = []
for i in d[k]:
if isinstance(i, dict):
_shred(i, None, [None])
else:
i = ''
newlist.append(i)
d[k] = newlist
elif isinstance(d[k], dict):
_shred(d[k], None, [None])
elif d[k]:
d[k] = ''
shredded = True
return shredded
d = logentry.parsed_data d = logentry.parsed_data
initial_data = copy.copy(d) initial_data = copy.copy(d)
shredded = _shred(d, banlist, whitelist) shredded = False
if whitelist:
for k, v in d.items():
if k not in whitelist:
d[k] = ''
shredded = True
elif banlist:
for f in banlist:
if f in d:
d[f] = ''
shredded = True
if d != initial_data: if d != initial_data:
logentry.data = json.dumps(d) logentry.data = json.dumps(d)
logentry.shredded = logentry.shredded or shredded logentry.shredded = logentry.shredded or shredded
+5 -11
View File
@@ -220,18 +220,12 @@ subclass of pretix.base.payment.BasePaymentProvider or a list of these
As with all event-plugin signals, the ``sender`` keyword argument will contain the event. As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
""" """
register_text_placeholders = EventPluginSignal()
"""
This signal is sent out to get all known text placeholders. Receivers should return
an instance of a subclass of pretix.base.services.placeholders.BaseTextPlaceholder or a
list of these.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
register_mail_placeholders = EventPluginSignal() register_mail_placeholders = EventPluginSignal()
""" """
**DEPRECATED**: This signal has a new name, please use ``register_text_placeholders`` instead. This signal is sent out to get all known email text placeholders. Receivers should return
an instance of a subclass of pretix.base.email.BaseMailTextPlaceholder or a list of these.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
""" """
register_html_mail_renderers = EventPluginSignal() register_html_mail_renderers = EventPluginSignal()
@@ -652,7 +646,7 @@ allow_ticket_download = EventPluginSignal()
Arguments: ``order`` Arguments: ``order``
This signal is sent out to check if tickets for an order can be downloaded. If any receiver returns false, This signal is sent out to check if tickets for an order can be downloaded. If any receiver returns false,
a download will not be offered. If a receiver returns a list of OrderPositions, only those will be downloadable. a download will not be offered.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event. As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
""" """
@@ -0,0 +1,6 @@
{# this is the version from django 3.x, prior to https://github.com/django/django/commit/5942ab5eb165ee2e759174e297148a40dd855920 so that django-bootstrap3 can keep doing its magic #}
{% with id=widget.attrs.id %}<ul{% if id %} id="{{ id }}"{% endif %}{% if widget.attrs.class %} class="{{ widget.attrs.class }}"{% endif %}>{% for group, options, index in widget.optgroups %}{% if group %}
<li>{{ group }}<ul{% if id %} id="{{ id }}_{{ index }}"{% endif %}>{% endif %}{% for option in options %}
<li>{% include option.template_name with widget=option %}</li>{% endfor %}{% if group %}
</ul></li>{% endif %}{% endfor %}
</ul>{% endwith %}
@@ -65,8 +65,6 @@ ALLOWED_TAGS_SNIPPET = [
'i', 'i',
'strong', 'strong',
'span', 'span',
'strike',
's',
# Update doc/user/markdown.rst if you change this! # Update doc/user/markdown.rst if you change this!
] ]
ALLOWED_TAGS = ALLOWED_TAGS_SNIPPET + [ ALLOWED_TAGS = ALLOWED_TAGS_SNIPPET + [
+1 -4
View File
@@ -96,9 +96,6 @@ class BaseTicketOutput:
""" """
raise NotImplementedError() raise NotImplementedError()
def get_tickets_to_print(self, order):
return order.positions_with_tickets
def generate_order(self, order: Order) -> Tuple[str, str, str]: def generate_order(self, order: Order) -> Tuple[str, str, str]:
""" """
This method is the same as order() but should not generate one file per order position This method is the same as order() but should not generate one file per order position
@@ -119,7 +116,7 @@ class BaseTicketOutput:
""" """
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf: with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
for pos in self.get_tickets_to_print(order): for pos in order.positions_with_tickets:
fname, __, content = self.generate(pos) fname, __, content = self.generate(pos)
zipf.writestr('{}-{}{}'.format( zipf.writestr('{}-{}{}'.format(
order.code, pos.positionid, os.path.splitext(fname)[1] order.code, pos.positionid, os.path.splitext(fname)[1]
+2 -4
View File
@@ -24,7 +24,6 @@ from datetime import datetime, time, timedelta
from django.db.models import Q from django.db.models import Q
from django.urls import reverse from django.urls import reverse
from django.utils.text import format_lazy
from django.utils.timezone import make_aware from django.utils.timezone import make_aware
from django.utils.translation import pgettext_lazy from django.utils.translation import pgettext_lazy
@@ -90,10 +89,9 @@ def timeline_for_event(event, subevent=None):
datetime=( datetime=(
ev.presale_end or ev.date_to or ev.date_from.astimezone(ev.timezone).replace(hour=23, minute=59, second=59) ev.presale_end or ev.date_to or ev.date_from.astimezone(ev.timezone).replace(hour=23, minute=59, second=59)
), ),
description=format_lazy( description='{}{}'.format(
'{} ({})',
pgettext_lazy('timeline', 'End of ticket sales'), pgettext_lazy('timeline', 'End of ticket sales'),
pgettext_lazy('timeline', 'automatically because the event is over and no end of presale has been configured') if not ev.presale_end else "" f" ({pgettext_lazy('timeline', 'automatically because the event is over and no end of presale has been configured')})" if not ev.presale_end else ""
), ),
edit_url=ev_edit_url edit_url=ev_edit_url
)) ))
-2
View File
@@ -72,7 +72,6 @@ class EventSlugBanlistValidator(BanlistValidator):
'widget', 'widget',
'customer', 'customer',
'account', 'account',
'lead',
] ]
@@ -93,7 +92,6 @@ class OrganizerSlugBanlistValidator(BanlistValidator):
'api', 'api',
'csp_report', 'csp_report',
'widget', 'widget',
'lead',
] ]
+6 -7
View File
@@ -34,7 +34,7 @@ from django.core.exceptions import PermissionDenied, ValidationError
from django.core.files.uploadedfile import UploadedFile from django.core.files.uploadedfile import UploadedFile
from django.db import transaction from django.db import transaction
from django.http import HttpResponse, JsonResponse, QueryDict from django.http import HttpResponse, JsonResponse, QueryDict
from django.shortcuts import render from django.shortcuts import redirect, render
from django.test import RequestFactory from django.test import RequestFactory
from django.utils import timezone, translation from django.utils import timezone, translation
from django.utils.datastructures import MultiValueDict from django.utils.datastructures import MultiValueDict
@@ -47,7 +47,6 @@ from redis import ResponseError
from pretix.base.models import CachedFile, User from pretix.base.models import CachedFile, User
from pretix.base.services.tasks import ProfiledEventTask from pretix.base.services.tasks import ProfiledEventTask
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers.http import redirect_to_url
logger = logging.getLogger('pretix.base.tasks') logger = logging.getLogger('pretix.base.tasks')
@@ -153,7 +152,7 @@ class AsyncMixin:
'redirect': self.get_success_url(value), 'redirect': self.get_success_url(value),
'message': str(self.get_success_message(value)) 'message': str(self.get_success_message(value))
}) })
return redirect_to_url(self.get_success_url(value)) return redirect(self.get_success_url(value))
def error(self, exception): def error(self, exception):
if isinstance(exception, PermissionDenied): if isinstance(exception, PermissionDenied):
@@ -166,7 +165,7 @@ class AsyncMixin:
'redirect': self.get_error_url(), 'redirect': self.get_error_url(),
'message': str(self.get_error_message(exception)) 'message': str(self.get_error_message(exception))
}) })
return redirect_to_url(self.get_error_url()) return redirect(self.get_error_url())
def get_error_message(self, exception): def get_error_message(self, exception):
if isinstance(exception, dict) and exception['exc_type'] in self.known_errortypes: if isinstance(exception, dict) and exception['exc_type'] in self.known_errortypes:
@@ -204,7 +203,7 @@ class AsyncAction(AsyncMixin):
return self.success(res.info) return self.success(res.info)
else: else:
return self.error(res.info) return self.error(res.info)
return redirect_to_url(self.get_check_url(res.id, False)) return redirect(self.get_check_url(res.id, False))
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
if 'async_id' in request.GET and settings.HAS_CELERY: if 'async_id' in request.GET and settings.HAS_CELERY:
@@ -376,7 +375,7 @@ class AsyncFormView(AsyncMixin, FormView):
return self.success(res.info) return self.success(res.info)
else: else:
return self.error(res.info) return self.error(res.info)
return redirect_to_url(self.get_check_url(res.id, False)) return redirect(self.get_check_url(res.id, False))
class AsyncPostView(AsyncMixin, View): class AsyncPostView(AsyncMixin, View):
@@ -479,4 +478,4 @@ class AsyncPostView(AsyncMixin, View):
return self.success(res.info) return self.success(res.info)
else: else:
return self.error(res.info) return self.error(res.info)
return redirect_to_url(self.get_check_url(res.id, False)) return redirect(self.get_check_url(res.id, False))
+3 -28
View File
@@ -219,17 +219,15 @@ class ExtValidationMixin:
def clean(self, *args, **kwargs): def clean(self, *args, **kwargs):
data = super().clean(*args, **kwargs) data = super().clean(*args, **kwargs)
if isinstance(data, UploadedFile):
from ...base.models import CachedFile filename = data.name
if isinstance(data, (UploadedFile, CachedFile)):
filename = data.name if isinstance(data, UploadedFile) else data.filename
ext = os.path.splitext(filename)[1] ext = os.path.splitext(filename)[1]
ext = ext.lower() ext = ext.lower()
if ext not in self.ext_whitelist: if ext not in self.ext_whitelist:
raise forms.ValidationError(_("Filetype not allowed!")) raise forms.ValidationError(_("Filetype not allowed!"))
if ext in IMAGE_EXTS: if ext in IMAGE_EXTS:
validate_uploaded_file_for_valid_image(data if isinstance(data, UploadedFile) else data.file) validate_uploaded_file_for_valid_image(data)
return data return data
@@ -259,12 +257,6 @@ class CachedFileField(ExtFileField):
if isinstance(data, File): if isinstance(data, File):
if hasattr(data, '_uploaded_to'): if hasattr(data, '_uploaded_to'):
return data._uploaded_to return data._uploaded_to
try:
self.clean(data)
except ValidationError:
return None
cf = CachedFile.objects.create( cf = CachedFile.objects.create(
expires=now() + datetime.timedelta(days=1), expires=now() + datetime.timedelta(days=1),
date=now(), date=now(),
@@ -276,9 +268,6 @@ class CachedFileField(ExtFileField):
cf.save() cf.save()
data._uploaded_to = cf data._uploaded_to = cf
return cf return cf
if isinstance(data, CachedFile):
return data
return super().bound_data(data, initial) return super().bound_data(data, initial)
def clean(self, *args, **kwargs): def clean(self, *args, **kwargs):
@@ -426,17 +415,3 @@ class FontSelect(forms.RadioSelect):
class ItemMultipleChoiceField(SafeModelMultipleChoiceField): class ItemMultipleChoiceField(SafeModelMultipleChoiceField):
def label_from_instance(self, obj): def label_from_instance(self, obj):
return str(obj) if obj.active else mark_safe(f'<strike class="text-muted">{escape(obj)}</strike>') return str(obj) if obj.active else mark_safe(f'<strike class="text-muted">{escape(obj)}</strike>')
class ButtonGroupRadioSelect(forms.RadioSelect):
template_name = 'pretixcontrol/button_group_radio.html'
option_template_name = 'pretixcontrol/button_group_radio_option.html'
def __init__(self, *args, **kwargs):
self.option_icons = kwargs.pop('option_icons')
super().__init__(*args, **kwargs)
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
attrs['icon'] = self.option_icons[value]
opt = super().create_option(name, value, label, selected, index, subindex, attrs)
return opt
+15 -10
View File
@@ -60,11 +60,12 @@ from i18nfield.forms import (
from pytz import common_timezones from pytz import common_timezones
from pretix.base.channels import get_all_sales_channels from pretix.base.channels import get_all_sales_channels
from pretix.base.email import get_available_placeholders
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
from pretix.base.forms.widgets import format_placeholders_help_text
from pretix.base.models import Event, Organizer, TaxRule, Team from pretix.base.models import Event, Organizer, TaxRule, Team
from pretix.base.models.event import EventFooterLink, EventMetaValue, SubEvent from pretix.base.models.event import EventFooterLink, EventMetaValue, SubEvent
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.base.services.placeholders import FormPlaceholderMixin
from pretix.base.settings import ( from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, DEFAULTS, PERSON_NAME_SCHEMES, COUNTRIES_WITH_STATE_IN_ADDRESS, DEFAULTS, PERSON_NAME_SCHEMES,
PERSON_NAME_TITLE_GROUPS, validate_event_settings, PERSON_NAME_TITLE_GROUPS, validate_event_settings,
@@ -502,7 +503,7 @@ class EventSettingsValidationMixin:
del self.cleaned_data[field] del self.cleaned_data[field]
class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, SettingsForm): class EventSettingsForm(EventSettingsValidationMixin, SettingsForm):
timezone = forms.ChoiceField( timezone = forms.ChoiceField(
choices=((a, a) for a in common_timezones), choices=((a, a) for a in common_timezones),
label=_("Event timezone"), label=_("Event timezone"),
@@ -592,10 +593,6 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
'og_image', 'og_image',
] ]
base_context = {
'frontpage_text': ['event'],
}
def _resolve_virtual_keys_input(self, data, prefix=''): def _resolve_virtual_keys_input(self, data, prefix=''):
# set all dependants of virtual_keys and # set all dependants of virtual_keys and
# delete all virtual_fields to prevent them from being saved # delete all virtual_fields to prevent them from being saved
@@ -685,9 +682,6 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
else: else:
self.initial[virtual_key] = 'do_not_ask' self.initial[virtual_key] = 'do_not_ask'
for k, v in self.base_context.items():
self._set_field_placeholders(k, v)
@cached_property @cached_property
def changed_data(self): def changed_data(self):
data = [] data = []
@@ -938,7 +932,7 @@ def contains_web_channel_validate(val):
raise ValidationError(_("The online shop must be selected to receive these emails.")) raise ValidationError(_("The online shop must be selected to receive these emails."))
class MailSettingsForm(FormPlaceholderMixin, SettingsForm): class MailSettingsForm(SettingsForm):
auto_fields = [ auto_fields = [
'mail_prefix', 'mail_prefix',
'mail_from_name', 'mail_from_name',
@@ -1350,6 +1344,17 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
'mail_attach_ical_description': ['event', 'event_or_subevent'], 'mail_attach_ical_description': ['event', 'event_or_subevent'],
} }
def _set_field_placeholders(self, fn, base_parameters):
placeholders = get_available_placeholders(self.event, base_parameters)
ht = format_placeholders_help_text(placeholders, self.event)
if self.fields[fn].help_text:
self.fields[fn].help_text += ' ' + str(ht)
else:
self.fields[fn].help_text = ht
self.fields[fn].validators.append(
PlaceholderValidator(['{%s}' % p for p in placeholders.keys()])
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.event = event = kwargs.get('obj') self.event = event = kwargs.get('obj')
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
+5 -34
View File
@@ -57,7 +57,7 @@ from pretix.base.forms.widgets import (
from pretix.base.models import ( from pretix.base.models import (
Checkin, CheckinList, Device, Event, EventMetaProperty, EventMetaValue, Checkin, CheckinList, Device, Event, EventMetaProperty, EventMetaValue,
Gate, Invoice, InvoiceAddress, Item, Order, OrderPayment, OrderPosition, Gate, Invoice, InvoiceAddress, Item, Order, OrderPayment, OrderPosition,
OrderRefund, Organizer, Question, QuestionAnswer, Quota, SubEvent, OrderRefund, Organizer, Question, QuestionAnswer, SubEvent,
SubEventMetaValue, Team, TeamAPIToken, TeamInvite, Voucher, SubEventMetaValue, Team, TeamAPIToken, TeamInvite, Voucher,
) )
from pretix.base.signals import register_payment_providers from pretix.base.signals import register_payment_providers
@@ -230,7 +230,6 @@ class OrderFilterForm(FilterForm):
('partially_paid', _('Partially paid')), ('partially_paid', _('Partially paid')),
('underpaid', _('Underpaid (but confirmed)')), ('underpaid', _('Underpaid (but confirmed)')),
('pendingpaid', _('Pending (but fully paid)')), ('pendingpaid', _('Pending (but fully paid)')),
('pendingnopayment', _('Pending (but no current payment)')),
)), )),
(_('Approval process'), ( (_('Approval process'), (
('na', _('Approved, payment pending')), ('na', _('Approved, payment pending')),
@@ -328,18 +327,6 @@ class OrderFilterForm(FilterForm):
Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0) Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0)
& Q(require_approval=False) & Q(require_approval=False)
) )
elif s == 'pendingnopayment':
qs = qs.exclude(
Exists(
OrderPayment.objects.filter(
order=OuterRef('pk'),
state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING)
)
)
).filter(
status=Order.STATUS_PENDING,
require_approval=False,
)
elif s == 'partially_paid': elif s == 'partially_paid':
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True) qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
qs = qs.filter( qs = qs.filter(
@@ -591,10 +578,11 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
widget=FilterNullBooleanSelect, widget=FilterNullBooleanSelect,
label=_('At least one ticket with check-in'), label=_('At least one ticket with check-in'),
) )
quota = SafeModelChoiceField( checkin_attention = forms.NullBooleanField(
queryset=Quota.objects.none(),
label=_('Affected quota'),
required=False, required=False,
widget=FilterNullBooleanSelect,
label=_('Requires special attention'),
help_text=_('Only matches orders with the attention checkbox set directly for the order, not based on the product.'),
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -679,17 +667,6 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
label=_('Ticket secret'), label=_('Ticket secret'),
required=False required=False
) )
self.fields['quota'].queryset = self.event.quotas.all()
self.fields['quota'].widget = Select2(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:event.items.quotas.select2', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
}
)
self.fields['quota'].widget.choices = self.fields['quota'].choices
for q in self.event.questions.all(): for q in self.event.questions.all():
self.fields['question_{}'.format(q.pk)] = forms.CharField( self.fields['question_{}'.format(q.pk)] = forms.CharField(
label=q.question, label=q.question,
@@ -783,12 +760,6 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
qs = qs.filter( qs = qs.filter(
all_positions__secret__icontains=fdata.get('ticket_secret') all_positions__secret__icontains=fdata.get('ticket_secret')
).distinct() ).distinct()
if fdata.get('quota'):
quota = fdata['quota']
qs = qs.filter(
Q(all_positions__item__in=quota.items.all(), all_positions__variation__isnull=True) |
Q(all_positions__variation__in=quota.variations.all())
).distinct()
for q in self.event.questions.all(): for q in self.event.questions.all():
if fdata.get(f'question_{q.pk}'): if fdata.get(f'question_{q.pk}'):
answers = QuestionAnswer.objects.filter( answers = QuestionAnswer.objects.filter(
+11 -89
View File
@@ -64,10 +64,10 @@ from pretix.base.models import (
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.signals import item_copy_data from pretix.base.signals import item_copy_data
from pretix.control.forms import ( from pretix.control.forms import (
ButtonGroupRadioSelect, ItemMultipleChoiceField, SizeValidationMixin, ItemMultipleChoiceField, SizeValidationMixin, SplitDateTimeField,
SplitDateTimeField, SplitDateTimePickerWidget, SplitDateTimePickerWidget,
) )
from pretix.control.forms.widgets import Select2, Select2ItemVarMulti from pretix.control.forms.widgets import Select2
from pretix.helpers.models import modelcopy from pretix.helpers.models import modelcopy
from pretix.helpers.money import change_decimal_field from pretix.helpers.money import change_decimal_field
@@ -207,20 +207,14 @@ class QuestionOptionForm(I18nModelForm):
class QuotaForm(I18nModelForm): class QuotaForm(I18nModelForm):
itemvars = forms.MultipleChoiceField(
label=_("Products"),
required=True,
)
def __init__(self, **kwargs): def __init__(self, **kwargs):
self.instance = kwargs.get('instance', None) self.instance = kwargs.get('instance', None)
self.event = kwargs.get('event') self.event = kwargs.get('event')
items = kwargs.pop('items', None) or self.event.items.prefetch_related('variations') items = kwargs.pop('items', None) or self.event.items.prefetch_related('variations')
searchable_selection = kwargs.pop('searchable_selection', None)
self.original_instance = modelcopy(self.instance) if self.instance else None self.original_instance = modelcopy(self.instance) if self.instance else None
initial = kwargs.get('initial', {}) initial = kwargs.get('initial', {})
if self.instance and self.instance.pk and 'itemvars' not in initial: if self.instance and self.instance.pk and 'itemvars' not in initial:
initial['itemvars'] = [str(i.pk) for i in self.instance.items.all() if (len(i.variations.all()) == 0)] + [ initial['itemvars'] = [str(i.pk) for i in self.instance.items.all()] + [
'{}-{}'.format(v.item_id, v.pk) for v in self.instance.variations.all() '{}-{}'.format(v.item_id, v.pk) for v in self.instance.variations.all()
] ]
kwargs['initial'] = initial kwargs['initial'] = initial
@@ -237,22 +231,12 @@ class QuotaForm(I18nModelForm):
else: else:
choices.append(('{}'.format(item.pk), str(item) if item.active else mark_safe(f'<strike class="text-muted">{escape(item)}</strike>'))) choices.append(('{}'.format(item.pk), str(item) if item.active else mark_safe(f'<strike class="text-muted">{escape(item)}</strike>')))
if searchable_selection: self.fields['itemvars'] = forms.MultipleChoiceField(
self.fields['itemvars'].widget = Select2ItemVarMulti( label=_('Products'),
attrs={ required=True,
'data-model-select2': 'generic', choices=choices,
'data-select2-url': reverse('control:event.items.itemvars.select2', kwargs={ widget=forms.CheckboxSelectMultiple
'event': self.event.slug, )
'organizer': self.event.organizer.slug,
}),
'data-placeholder': _('No products')
},
choices=choices,
)
else:
self.fields['itemvars'].widget = forms.CheckboxSelectMultiple()
self.fields['itemvars'].choices = choices
if self.event.has_subevents: if self.event.has_subevents:
self.fields['subevent'].queryset = self.event.subevents.all() self.fields['subevent'].queryset = self.event.subevents.all()
@@ -396,9 +380,7 @@ class ItemCreateForm(I18nModelForm):
'description', 'description',
'active', 'active',
'available_from', 'available_from',
'available_from_mode',
'available_until', 'available_until',
'available_until_mode',
'require_voucher', 'require_voucher',
'hide_without_voucher', 'hide_without_voucher',
'allow_cancel', 'allow_cancel',
@@ -580,34 +562,6 @@ class ItemUpdateForm(I18nModelForm):
) )
change_decimal_field(self.fields['default_price'], self.event.currency) change_decimal_field(self.fields['default_price'], self.event.currency)
self.fields['available_from_mode'].widget = ButtonGroupRadioSelect(
choices=self.fields['available_from_mode'].choices,
option_icons={
Item.UNAVAIL_MODE_HIDDEN: 'eye-slash',
Item.UNAVAIL_MODE_INFO: 'info'
}
)
self.fields['available_until_mode'].widget = ButtonGroupRadioSelect(
choices=self.fields['available_until_mode'].choices,
option_icons={
Item.UNAVAIL_MODE_HIDDEN: 'eye-slash',
Item.UNAVAIL_MODE_INFO: 'info'
}
)
self.fields['hide_without_voucher'].widget = ButtonGroupRadioSelect(
choices=(
(True, _("Hide product if unavailable")),
(False, _("Show product with info on why its unavailable")),
),
option_icons={
True: 'eye-slash',
False: 'info'
},
attrs={'data-checkbox-dependency': '#id_require_voucher'}
)
if self.instance.hidden_if_available_id: if self.instance.hidden_if_available_id:
self.fields['hidden_if_available'].queryset = self.event.quotas.all() self.fields['hidden_if_available'].queryset = self.event.quotas.all()
self.fields['hidden_if_available'].help_text = format_html( self.fields['hidden_if_available'].help_text = format_html(
@@ -660,15 +614,6 @@ class ItemUpdateForm(I18nModelForm):
self.fields['free_price_suggestion'].widget.attrs['data-display-dependency'] = '#id_free_price' self.fields['free_price_suggestion'].widget.attrs['data-display-dependency'] = '#id_free_price'
self.fields['validity_dynamic_start_choice'] = forms.TypedChoiceField(
label=_("Start of validity"),
choices=(
("False", _("Purchase date")),
("True", _("Date chosen by customer")),
),
coerce=lambda x: x == 'True',
)
qs = self.event.organizer.membership_types.all() qs = self.event.organizer.membership_types.all()
if qs: if qs:
self.fields['require_membership_types'].queryset = qs self.fields['require_membership_types'].queryset = qs
@@ -702,9 +647,6 @@ class ItemUpdateForm(I18nModelForm):
) )
) )
if not d.get('require_voucher'):
d['hide_without_voucher'] = False
if d.get('require_membership') and not d.get('require_membership_types'): if d.get('require_membership') and not d.get('require_membership_types'):
self.add_error( self.add_error(
'require_membership_types', 'require_membership_types',
@@ -719,7 +661,7 @@ class ItemUpdateForm(I18nModelForm):
if d.get('grant_membership_type'): if d.get('grant_membership_type'):
if not d['grant_membership_type'].transferable and not d['personalized']: if not d['grant_membership_type'].transferable and not d['personalized']:
self.add_error( self.add_error(
'personalized' if d.get('admission') else 'admission', 'personalized' if d['admission'] else 'admission',
_("Your product grants a non-transferable membership and should therefore be a personalized " _("Your product grants a non-transferable membership and should therefore be a personalized "
"admission ticket. Otherwise customers might not be able to use the membership later. If you " "admission ticket. Otherwise customers might not be able to use the membership later. If you "
"want the membership to be non-personalized, set the membership type to be transferable.") "want the membership to be non-personalized, set the membership type to be transferable.")
@@ -762,9 +704,7 @@ class ItemUpdateForm(I18nModelForm):
'free_price_suggestion', 'free_price_suggestion',
'tax_rule', 'tax_rule',
'available_from', 'available_from',
'available_from_mode',
'available_until', 'available_until',
'available_until_mode',
'require_voucher', 'require_voucher',
'require_approval', 'require_approval',
'hide_without_voucher', 'hide_without_voucher',
@@ -901,22 +841,6 @@ class ItemVariationForm(I18nModelForm):
self.fields['free_price_suggestion'].widget.attrs['data-display-dependency'] = '#id_free_price' self.fields['free_price_suggestion'].widget.attrs['data-display-dependency'] = '#id_free_price'
self.fields['available_from_mode'].widget = ButtonGroupRadioSelect(
choices=self.fields['available_from_mode'].choices,
option_icons={
Item.UNAVAIL_MODE_HIDDEN: 'eye-slash',
Item.UNAVAIL_MODE_INFO: 'info'
}
)
self.fields['available_until_mode'].widget = ButtonGroupRadioSelect(
choices=self.fields['available_until_mode'].choices,
option_icons={
Item.UNAVAIL_MODE_HIDDEN: 'eye-slash',
Item.UNAVAIL_MODE_INFO: 'info'
}
)
self.meta_fields = [] self.meta_fields = []
meta_defaults = {} meta_defaults = {}
if self.instance.pk: if self.instance.pk:
@@ -959,9 +883,7 @@ class ItemVariationForm(I18nModelForm):
'checkin_attention', 'checkin_attention',
'checkin_text', 'checkin_text',
'available_from', 'available_from',
'available_from_mode',
'available_until', 'available_until',
'available_until_mode',
'sales_channels', 'sales_channels',
'hide_without_voucher', 'hide_without_voucher',
] ]
+18 -8
View File
@@ -61,7 +61,6 @@ from pretix.base.models import (
TaxRule, TaxRule,
) )
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.services.placeholders import FormPlaceholderMixin
from pretix.base.services.pricing import get_price from pretix.base.services.pricing import get_price
from pretix.control.forms import SplitDateTimeField from pretix.control.forms import SplitDateTimeField
from pretix.control.forms.widgets import Select2 from pretix.control.forms.widgets import Select2
@@ -152,6 +151,10 @@ class ForceQuotaConfirmationForm(forms.Form):
del self.fields['force'] del self.fields['force']
class ConfirmPaymentForm(ForceQuotaConfirmationForm):
pass
class ReactivateOrderForm(ForceQuotaConfirmationForm): class ReactivateOrderForm(ForceQuotaConfirmationForm):
pass pass
@@ -217,11 +220,10 @@ class DenyForm(forms.Form):
) )
class MarkPaidForm(ForceQuotaConfirmationForm): class MarkPaidForm(ConfirmPaymentForm):
send_email = forms.BooleanField( send_email = forms.BooleanField(
required=False, required=False,
label=_('Notify customer by email'), label=_('Notify customer by email'),
help_text=_('A mail will only be sent if the order is fully paid after this.'),
initial=True initial=True
) )
amount = forms.DecimalField( amount = forms.DecimalField(
@@ -238,10 +240,9 @@ class MarkPaidForm(ForceQuotaConfirmationForm):
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
payment = kwargs.pop('payment', None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
change_decimal_field(self.fields['amount'], self.instance.event.currency) change_decimal_field(self.fields['amount'], self.instance.event.currency)
self.fields['amount'].initial = max(Decimal('0.00'), payment.amount if payment else self.instance.pending_sum) self.fields['amount'].initial = max(Decimal('0.00'), self.instance.pending_sum)
class ExporterForm(forms.Form): class ExporterForm(forms.Form):
@@ -704,7 +705,6 @@ class OrderMailForm(forms.Form):
) )
self.fields['attach_invoices'].queryset = order.invoices.all() self.fields['attach_invoices'].queryset = order.invoices.all()
self._set_field_placeholders('message', ['event', 'order']) self._set_field_placeholders('message', ['event', 'order'])
self._set_field_placeholders('subject', ['event', 'order'])
class OrderPositionMailForm(OrderMailForm): class OrderPositionMailForm(OrderMailForm):
@@ -720,7 +720,6 @@ class OrderPositionMailForm(OrderMailForm):
initial=self.order.event.settings.mail_text_order_custom_mail.localize(self.order.locale), initial=self.order.event.settings.mail_text_order_custom_mail.localize(self.order.locale),
) )
self._set_field_placeholders('message', ['event', 'order', 'position']) self._set_field_placeholders('message', ['event', 'order', 'position'])
self._set_field_placeholders('subject', ['event', 'order'])
class OrderRefundForm(forms.Form): class OrderRefundForm(forms.Form):
@@ -768,7 +767,7 @@ class OrderRefundForm(forms.Form):
return data return data
class EventCancelForm(FormPlaceholderMixin, forms.Form): class EventCancelForm(forms.Form):
subevent = forms.ModelChoiceField( subevent = forms.ModelChoiceField(
SubEvent.objects.none(), SubEvent.objects.none(),
label=pgettext_lazy('subevent', 'Date'), label=pgettext_lazy('subevent', 'Date'),
@@ -868,6 +867,17 @@ class EventCancelForm(FormPlaceholderMixin, forms.Form):
send_waitinglist_subject = forms.CharField() send_waitinglist_subject = forms.CharField()
send_waitinglist_message = forms.CharField() send_waitinglist_message = forms.CharField()
def _set_field_placeholders(self, fn, base_parameters):
placeholders = get_available_placeholders(self.event, base_parameters)
ht = format_placeholders_help_text(placeholders, self.event)
if self.fields[fn].help_text:
self.fields[fn].help_text += ' ' + str(ht)
else:
self.fields[fn].help_text = ht
self.fields[fn].validators.append(
PlaceholderValidator(['{%s}' % p for p in placeholders.keys()])
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event') self.event = kwargs.pop('event')
kwargs.setdefault('initial', {}) kwargs.setdefault('initial', {})
+8 -1
View File
@@ -423,7 +423,6 @@ class OrganizerSettingsForm(SettingsForm):
'organizer_link_back', 'organizer_link_back',
'organizer_logo_image_large', 'organizer_logo_image_large',
'organizer_logo_image_inherit', 'organizer_logo_image_inherit',
'favicon',
'giftcard_length', 'giftcard_length',
'giftcard_expiry_years', 'giftcard_expiry_years',
'locales', 'locales',
@@ -465,6 +464,14 @@ class OrganizerSettingsForm(SettingsForm):
'can increase the size with the setting below. We recommend not using small details on the picture ' 'can increase the size with the setting below. We recommend not using small details on the picture '
'as it will be resized on smaller screens.') 'as it will be resized on smaller screens.')
) )
favicon = ExtFileField(
label=_('Favicon'),
ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_FAVICON,
required=False,
max_size=settings.FILE_UPLOAD_MAX_SIZE_FAVICON,
help_text=_('If you provide a favicon, we will show it instead of the default pretix icon. '
'We recommend a size of at least 200x200px to accommodate most devices.')
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
is_admin = kwargs.pop('is_admin', False) is_admin = kwargs.pop('is_admin', False)
+5 -38
View File
@@ -19,17 +19,18 @@
# 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/>.
# #
from bootstrap3.renderers import FieldRenderer, InlineFieldRenderer
from bootstrap3.text import text_value from bootstrap3.text import text_value
from django.forms import CheckboxInput, CheckboxSelectMultiple, RadioSelect from django.forms import CheckboxInput
from django.forms.utils import flatatt from django.forms.utils import flatatt
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import pgettext from django.utils.translation import pgettext
from i18nfield.forms import I18nFormField from i18nfield.forms import I18nFormField
from pretix.base.forms.renderers import FieldRenderer, InlineFieldRenderer
def render_label(content, label_for=None, label_class=None, label_title='', label_id='', optional=False):
def render_label(content, label_for=None, label_class=None, label_title='', optional=False):
""" """
Render a label with content Render a label with content
""" """
@@ -40,8 +41,6 @@ def render_label(content, label_for=None, label_class=None, label_title='', labe
attrs['class'] = label_class attrs['class'] = label_class
if label_title: if label_title:
attrs['title'] = label_title attrs['title'] = label_title
if label_id:
attrs['id'] = label_id
if text_value(content) == '&#160;': if text_value(content) == '&#160;':
# Empty label, e.g. checkbox # Empty label, e.g. checkbox
@@ -62,7 +61,6 @@ class ControlFieldRenderer(FieldRenderer):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
kwargs['layout'] = 'horizontal' kwargs['layout'] = 'horizontal'
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.is_group_widget = isinstance(self.widget, (CheckboxSelectMultiple, RadioSelect, )) or (self.is_multi_widget and len(self.widget.widgets) > 1)
def add_label(self, html): def add_label(self, html):
label = self.get_label() label = self.get_label()
@@ -75,45 +73,14 @@ class ControlFieldRenderer(FieldRenderer):
else: else:
required = self.field.field.required required = self.field.field.required
if self.is_group_widget:
label_for = ""
label_id = "legend-{}".format(self.field.html_name)
else:
label_for = self.field.id_for_label
label_id = ""
html = render_label( html = render_label(
label, label,
label_for=label_for, label_for=self.field.id_for_label,
label_class=self.get_label_class(), label_class=self.get_label_class(),
label_id=label_id,
optional=not required and not isinstance(self.widget, CheckboxInput) optional=not required and not isinstance(self.widget, CheckboxInput)
) + html ) + html
return html return html
def wrap_label_and_field(self, html):
if self.is_group_widget:
attrs = ' role="group" aria-labelledby="legend-{}"'.format(self.field.html_name)
else:
attrs = ''
return '<div class="{klass}"{attrs}>{html}</div>'.format(klass=self.get_form_group_class(), html=html, attrs=attrs)
class ControlFieldWithVisibilityRenderer(ControlFieldRenderer):
def __init__(self, *args, **kwargs):
kwargs['layout'] = 'horizontal'
kwargs['horizontal_field_class'] = 'col-md-7'
self.visibility_field = kwargs['visibility_field']
super().__init__(*args, **kwargs)
def render_visibility_field(self):
return self.visibility_field.as_widget(attrs=self.visibility_field.field.widget.attrs)
def wrap_field(self, html):
html = super().wrap_field(html)
html += '<div class="col-md-2 text-right">' + self.render_visibility_field() + '</div>'
return html
class BulkEditMixin: class BulkEditMixin:
+2 -8
View File
@@ -260,8 +260,6 @@ class SubEventItemForm(SubEventItemOrVariationFormMixin, forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['price'].widget.attrs['placeholder'] = money_filter(self.item.default_price, self.item.event.currency, hide_currency=True) self.fields['price'].widget.attrs['placeholder'] = money_filter(self.item.default_price, self.item.event.currency, hide_currency=True)
self.fields['price'].label = str(self.item) self.fields['price'].label = str(self.item)
self.available_from_mode = self.item.available_from_mode
self.available_until_mode = self.item.available_until_mode
class Meta: class Meta:
model = SubEventItem model = SubEventItem
@@ -289,8 +287,6 @@ class SubEventItemVariationForm(SubEventItemOrVariationFormMixin, forms.ModelFor
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['price'].widget.attrs['placeholder'] = money_filter(self.variation.price, self.item.event.currency, hide_currency=True) self.fields['price'].widget.attrs['placeholder'] = money_filter(self.variation.price, self.item.event.currency, hide_currency=True)
self.fields['price'].label = '{} {}'.format(str(self.item), self.variation.value) self.fields['price'].label = '{} {}'.format(str(self.item), self.variation.value)
self.available_from_mode = self.variation.available_from_mode
self.available_until_mode = self.variation.available_until_mode
class Meta: class Meta:
model = SubEventItemVariation model = SubEventItemVariation
@@ -360,7 +356,6 @@ class BulkSubEventItemVariationForm(SubEventItemVariationForm):
class QuotaFormSet(I18nInlineFormSet): class QuotaFormSet(I18nInlineFormSet):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.searchable_selection = kwargs.pop('searchable_selection', None)
self.event = kwargs.pop('event', None) self.event = kwargs.pop('event', None)
self.locales = self.event.settings.get('locales') self.locales = self.event.settings.get('locales')
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -373,7 +368,7 @@ class QuotaFormSet(I18nInlineFormSet):
kwargs['locales'] = self.locales kwargs['locales'] = self.locales
kwargs['event'] = self.event kwargs['event'] = self.event
kwargs['items'] = self.items kwargs['items'] = self.items
kwargs['searchable_selection'] = self.searchable_selection kwargs['items'] = self.items
return super()._construct_form(i, **kwargs) return super()._construct_form(i, **kwargs)
@property @property
@@ -468,8 +463,7 @@ class RRuleFormSetForm(RRuleForm):
RRuleFormSet = formset_factory( RRuleFormSet = formset_factory(
RRuleFormSetForm, RRuleFormSetForm,
min_num=1, validate_min=True, can_order=False, can_delete=True, extra=1
can_order=False, can_delete=True, extra=0
) )
+1 -2
View File
@@ -63,8 +63,7 @@ class VoucherForm(I18nModelForm):
itemvar = FakeChoiceField( itemvar = FakeChoiceField(
label=_("Product"), label=_("Product"),
help_text=_( help_text=_(
"This product is added to the user's cart if the voucher is redeemed. Instead of a specific product, you " "This product is added to the user's cart if the voucher is redeemed."
"can also select a quota. In this case, all products assigned to this quota can be selected."
), ),
required=True required=True
) )
-16
View File
@@ -77,19 +77,3 @@ class Select2ItemVarQuotaMixin(Select2Mixin):
class Select2ItemVarQuota(Select2ItemVarQuotaMixin, forms.Select): class Select2ItemVarQuota(Select2ItemVarQuotaMixin, forms.Select):
pass pass
class Select2ItemVarMulti(Select2Mixin, forms.SelectMultiple):
def options(self, name, value, attrs=None):
# we need this for multi-selection without a queryset for the selection of items and variations
for i, v in enumerate(value):
yield self.create_option(
None,
v,
dict(self.choices)[v],
True,
i,
subindex=None,
attrs=attrs
)
return
+8 -9
View File
@@ -37,7 +37,7 @@ from urllib.parse import quote, urljoin, urlparse
from django.conf import settings from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME, logout from django.contrib.auth import REDIRECT_FIELD_NAME, logout
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404, resolve_url from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.urls import get_script_prefix, resolve, reverse from django.urls import get_script_prefix, resolve, reverse
from django.utils.encoding import force_str from django.utils.encoding import force_str
@@ -46,7 +46,6 @@ from django_scopes import scope
from pretix.base.models import Event, Organizer from pretix.base.models import Event, Organizer
from pretix.base.models.auth import SuperuserPermissionSet, User from pretix.base.models.auth import SuperuserPermissionSet, User
from pretix.helpers.http import redirect_to_url
from pretix.helpers.security import ( from pretix.helpers.security import (
SessionInvalid, SessionReauthRequired, assert_session_valid, SessionInvalid, SessionReauthRequired, assert_session_valid,
) )
@@ -119,7 +118,7 @@ class PermissionMiddleware:
if hasattr(request, 'organizer'): if hasattr(request, 'organizer'):
# If the user is on a organizer's subdomain, he should be redirected to pretix # If the user is on a organizer's subdomain, he should be redirected to pretix
return redirect_to_url(urljoin(settings.SITE_URL, request.get_full_path())) return redirect(urljoin(settings.SITE_URL, request.get_full_path()))
if url_name in self.EXCEPTIONS: if url_name in self.EXCEPTIONS:
return self.get_response(request) return self.get_response(request)
if not request.user.is_authenticated: if not request.user.is_authenticated:
@@ -133,14 +132,14 @@ class PermissionMiddleware:
return self._login_redirect(request) return self._login_redirect(request)
except SessionReauthRequired: except SessionReauthRequired:
if url_name not in ('user.reauth', 'auth.logout'): if url_name not in ('user.reauth', 'auth.logout'):
return redirect_to_url(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path())) return redirect(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
if request.user.needs_password_change and url_name not in self.EXCEPTIONS_FORCED_PW_CHANGE: if request.user.needs_password_change and url_name not in self.EXCEPTIONS_FORCED_PW_CHANGE:
return redirect_to_url(reverse('control:user.settings') + '?next=' + quote(request.get_full_path())) return redirect(reverse('control:user.settings') + '?next=' + quote(request.get_full_path()))
if not request.user.require_2fa and settings.PRETIX_OBLIGATORY_2FA \ if not request.user.require_2fa and settings.PRETIX_OBLIGATORY_2FA \
and url_name not in self.EXCEPTIONS_2FA and not request.user.needs_password_change: and url_name not in self.EXCEPTIONS_2FA:
return redirect_to_url(reverse('control:user.settings.2fa')) return redirect(reverse('control:user.settings.2fa'))
if 'event' in url.kwargs and 'organizer' in url.kwargs: if 'event' in url.kwargs and 'organizer' in url.kwargs:
if url.kwargs['organizer'] == '-' and url.kwargs['event'] == '-': if url.kwargs['organizer'] == '-' and url.kwargs['event'] == '-':
@@ -153,7 +152,7 @@ class PermissionMiddleware:
k = dict(url.kwargs) k = dict(url.kwargs)
k['organizer'] = ev.organizer.slug k['organizer'] = ev.organizer.slug
k['event'] = ev.slug k['event'] = ev.slug
return redirect_to_url(reverse(url.view_name, kwargs=k, args=url.args)) return redirect(reverse(url.view_name, kwargs=k, args=url.args))
with scope(organizer=None): with scope(organizer=None):
request.event = Event.objects.filter( request.event = Event.objects.filter(
@@ -179,7 +178,7 @@ class PermissionMiddleware:
"have no permission to administrate it.")) "have no permission to administrate it."))
k = dict(url.kwargs) k = dict(url.kwargs)
k['organizer'] = org.slug k['organizer'] = org.slug
return redirect_to_url(reverse(url.view_name, kwargs=k, args=url.args)) return redirect(reverse(url.view_name, kwargs=k, args=url.args))
request.organizer = Organizer.objects.filter( request.organizer = Organizer.objects.filter(
slug=url.kwargs['organizer'], slug=url.kwargs['organizer'],
+2 -3
View File
@@ -35,11 +35,10 @@
from urllib.parse import quote from urllib.parse import quote
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.shortcuts import redirect
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from pretix.helpers.http import redirect_to_url
def current_url(request): def current_url(request):
if request.GET: if request.GET:
@@ -136,7 +135,7 @@ def administrator_permission_required():
raise PermissionDenied() raise PermissionDenied()
if not request.user.has_active_staff_session(request.session.session_key): if not request.user.has_active_staff_session(request.session.session_key):
if request.user.is_staff: if request.user.is_staff:
return redirect_to_url(reverse('control:user.sudo') + '?next=' + quote(current_url(request))) return redirect(reverse('control:user.sudo') + '?next=' + quote(current_url(request)))
raise PermissionDenied(_('You do not have permission to view this content.')) raise PermissionDenied(_('You do not have permission to view this content.'))
return function(request, *args, **kw) return function(request, *args, **kw)
return wrapper return wrapper
@@ -1,6 +0,0 @@
{% with id=widget.attrs.id %}<div data-toggle="buttons"{% if id %} id="{{ id }}"{% endif %} class="btn-group btn-group-toggle {{ widget.attrs.class }}">{% for group, options, index in widget.optgroups %}
{% for option in options %}
{% include option.template_name with widget=option %}
{% endfor %}
{% endfor %}
</div>{% endwith %}
@@ -1,2 +0,0 @@
<label{% if widget.attrs.id %} for="{{ widget.attrs.id }}"{% endif %} class="btn btn-primary-if-active form-field-boundary {% if widget.attrs.checked %} active{% endif %}" title="{{ widget.label }}" data-toggle="tooltip">
{% include "django/forms/widgets/input.html" %} <span class="fa fa-{{ widget.attrs.icon }}"></span></label>
@@ -95,8 +95,8 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% bootstrap_field form.available_from visibility_field=form.available_from_mode layout="control_with_visibility" %} {% bootstrap_field form.available_from layout="control" %}
{% bootstrap_field form.available_until visibility_field=form.available_until_mode layout="control_with_visibility" %} {% bootstrap_field form.available_until layout="control" %}
{% bootstrap_field form.sales_channels layout="control" %} {% bootstrap_field form.sales_channels layout="control" %}
{% bootstrap_field form.hide_without_voucher layout="control" %} {% bootstrap_field form.hide_without_voucher layout="control" %}
{% bootstrap_field form.require_approval layout="control" %} {% bootstrap_field form.require_approval layout="control" %}
@@ -194,8 +194,7 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% bootstrap_field formset.empty_form.available_from visibility_field=formset.empty_form.available_from_mode layout="control_with_visibility" %} {% bootstrap_field formset.empty_form.available_from layout="control" %}
{% bootstrap_field formset.empty_form.available_until visibility_field=formset.empty_form.available_until_mode layout="control_with_visibility" %}
{% bootstrap_field formset.empty_form.available_until layout="control" %} {% bootstrap_field formset.empty_form.available_until layout="control" %}
{% bootstrap_field formset.empty_form.sales_channels layout="control" %} {% bootstrap_field formset.empty_form.sales_channels layout="control" %}
{% bootstrap_field formset.empty_form.hide_without_voucher layout="control" %} {% bootstrap_field formset.empty_form.hide_without_voucher layout="control" %}
@@ -152,28 +152,27 @@
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{% trans "Availability" %}</legend> <legend>{% trans "Availability" %}</legend>
{% bootstrap_field form.sales_channels layout="control" %}
{% bootstrap_field form.available_from layout="control" %}
{% bootstrap_field form.sales_channels layout="control" horizontal_field_class="col-md-7" %} {% bootstrap_field form.available_until layout="control" %}
{% bootstrap_field form.available_from visibility_field=form.available_from_mode layout="control_with_visibility" %} {% bootstrap_field form.max_per_order layout="control" %}
{% bootstrap_field form.available_until visibility_field=form.available_until_mode layout="control_with_visibility" %} {% bootstrap_field form.min_per_order layout="control" %}
{% bootstrap_field form.max_per_order layout="control" horizontal_field_class="col-md-7" %} {% bootstrap_field form.require_voucher layout="control" %}
{% bootstrap_field form.min_per_order layout="control" horizontal_field_class="col-md-7" %} {% bootstrap_field form.hide_without_voucher layout="control" %}
{% bootstrap_field form.require_voucher visibility_field=form.hide_without_voucher layout="control_with_visibility" %} {% bootstrap_field form.require_bundling layout="control" %}
{% bootstrap_field form.require_bundling layout="control" horizontal_field_class="col-md-7" %}
{% if form.require_membership %} {% if form.require_membership %}
{% bootstrap_field form.require_membership layout="control" %} {% bootstrap_field form.require_membership layout="control" %}
<div data-display-dependency="#{{ form.require_membership.id_for_label }}"> <div data-display-dependency="#{{ form.require_membership.id_for_label }}">
{% bootstrap_field form.require_membership_types layout="control" horizontal_field_class="col-md-7" %} {% bootstrap_field form.require_membership_types layout="control" %}
{% bootstrap_field form.require_membership_hidden layout="control" horizontal_field_class="col-md-7" %} {% bootstrap_field form.require_membership_hidden layout="control" %}
</div> </div>
{% endif %} {% endif %}
{% bootstrap_field form.allow_cancel layout="control" horizontal_field_class="col-md-7" %} {% bootstrap_field form.allow_cancel layout="control" %}
{% bootstrap_field form.allow_waitinglist layout="control" horizontal_field_class="col-md-7" %} {% bootstrap_field form.allow_waitinglist layout="control" %}
{% if form.hidden_if_available %} {% if form.hidden_if_available %}
{% bootstrap_field form.hidden_if_available layout="control" horizontal_field_class="col-md-7" %} {% bootstrap_field form.hidden_if_available layout="control" %}
{% endif %} {% endif %}
{% bootstrap_field form.hidden_if_item_available layout="control" horizontal_field_class="col-md-7" %} {% bootstrap_field form.hidden_if_item_available layout="control" %}
</fieldset> </fieldset>
{% for v in formsets.values %} {% for v in formsets.values %}
<fieldset> <fieldset>
@@ -210,7 +209,6 @@
{% bootstrap_field form.validity_fixed_until layout="control" %} {% bootstrap_field form.validity_fixed_until layout="control" %}
</div> </div>
<div data-display-dependency="#{{ form.validity_mode.id_for_label }}" data-display-dependency-value="dynamic"> <div data-display-dependency="#{{ form.validity_mode.id_for_label }}" data-display-dependency-value="dynamic">
{% bootstrap_field form.validity_dynamic_start_choice layout="control" %}
<div class="form-group metadata-group"> <div class="form-group metadata-group">
<label class="col-md-3 control-label">{% trans "Duration" %}</label> <label class="col-md-3 control-label">{% trans "Duration" %}</label>
<div class="col-md-9"> <div class="col-md-9">
@@ -233,13 +231,14 @@
</div> </div>
</div> </div>
</div> </div>
<div data-display-dependency="#{{ form.validity_dynamic_start_choice.id_for_label }}" data-display-dependency-value="True"> {% bootstrap_field form.validity_dynamic_start_choice layout="control" %}
<div data-display-dependency="#{{ form.validity_dynamic_start_choice.id_for_label }}">
{% trans "days" as t_days %} {% trans "days" as t_days %}
{% bootstrap_field form.validity_dynamic_start_choice_day_limit addon_after=t_days layout="control" %} {% bootstrap_field form.validity_dynamic_start_choice_day_limit addon_after=t_days layout="control" %}
</div> </div>
</div> </div>
</fieldset> </fieldset>
<fieldset id="tab-item-additional-settings"> <fieldset>
<legend>{% trans "Additional settings" %}</legend> <legend>{% trans "Additional settings" %}</legend>
{% bootstrap_field form.issue_giftcard layout="control" %} {% bootstrap_field form.issue_giftcard layout="control" %}
{% if form.grant_membership_type %} {% if form.grant_membership_type %}
@@ -70,10 +70,6 @@
{% endblocktrans %} {% endblocktrans %}
</em> </em>
{% endif %} {% endif %}
{% if position.attendee_name %}
<span class="fa fa-user" aria-hidden="true"></span>
{{ position.attendee_name }}
{% endif %}
</h3> </h3>
</div> </div>
<div class="panel-body"> <div class="panel-body">
@@ -21,13 +21,7 @@
Do you really want to mark this payment as complete? Do you really want to mark this payment as complete?
{% endblocktrans %}</p> {% endblocktrans %}</p>
<input type="hidden" name="status" value="p" /> <input type="hidden" name="status" value="p" />
{% bootstrap_form_errors form %} {% bootstrap_form form layout='horizontal' horizontal_label_class='sr-only' horizontal_field_class='col-md-12' %}
{% bootstrap_field form.amount layout='horizontal' %}
{% bootstrap_field form.payment_date layout='horizontal' %}
{% bootstrap_field form.send_email layout='horizontal' %}
{% if form.force %}
{% bootstrap_field form.force layout='horizontal' horizontal_label_class='sr-only' horizontal_field_class='col-md-12' %}
{% endif %}
<div class="form-group submit-group"> <div class="form-group submit-group">
<a class="btn btn-default btn-lg" <a class="btn btn-default btn-lg"
href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}"> href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
@@ -157,7 +157,7 @@
{% bootstrap_field sform.giftcard_expiry_years layout="control" %} {% bootstrap_field sform.giftcard_expiry_years layout="control" %}
{% bootstrap_field sform.giftcard_length layout="control" %} {% bootstrap_field sform.giftcard_length layout="control" %}
</fieldset> </fieldset>
<fieldset id="tab-organizer-privacy"> <fieldset>
<legend>{% trans "Privacy" %}</legend> <legend>{% trans "Privacy" %}</legend>
{% bootstrap_field sform.privacy_url layout="control" %} {% bootstrap_field sform.privacy_url layout="control" %}
<div class="alert alert-legal"> <div class="alert alert-legal">
@@ -26,11 +26,7 @@
{% for u in team.members.all %} {% for u in team.members.all %}
<tr> <tr>
<td> <td>
{% if request.user.is_staff and staff_session %} {{ u.email }}
<strong><a href="{% url "control:users.edit" id=u.pk %}">{{ u.email }}</a></strong>
{% else %}
{{ u.email }}
{% endif %}
{% if u.require_2fa %} {% if u.require_2fa %}
<span class="fa fa-shield text-success" data-toggle="tooltip" <span class="fa fa-shield text-success" data-toggle="tooltip"
title="{% trans "Two-factor authentication enabled" %}"> title="{% trans "Two-factor authentication enabled" %}">
@@ -234,62 +234,7 @@
</select> </select>
</div> </div>
</div> </div>
<div class="row control-group poweredby">
<div class="col-sm-12">
<label>{% trans "Style" %}</label><br>
<select class="input-block-level form-control" id="toolbox-poweredby-style">
<option value="dark">{% trans "Dark" %}</option>
<option value="white">{% trans "Light" %}</option>
</select>
</div>
</div>
<div class="row control-group imagecontent">
<div class="col-sm-12">
<label>{% trans "Image content" %}</label><br>
<select class="input-block-level form-control" id="toolbox-imagecontent">
<option value="">{% trans "Empty" %}</option>
{% for varname, var in images.items %}
<option value="{{ varname }}">{{ var.label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row control-group text textcontent">
<div class="col-sm-12">
<label>{% trans "Content" %}</label><br>
<select class="input-block-level form-control" id="toolbox-content">
{% for varname, var in variables.items %}
{% if not var.hidden %}
<option data-sample="{{ var.editor_sample }}" {% if var.migrate_from %}data-old-value="{{ var.migrate_from }}"{% endif %} value="{{ varname }}">{{ var.label }}</option>
{% endif %}
{% endfor %}
{% for p in request.organizer.meta_properties.all %}
<option value="meta:{{ p.name }}">
{% trans "Event attribute:" %} {{ p.name }}
</option>
{% endfor %}
{% for p in request.event.item_meta_properties.all %}
<option value="itemmeta:{{ p.name }}">
{% trans "Item attribute:" %} {{ p.name }}
</option>
{% endfor %}
<option value="other_i18n">{% trans "Other… (multilingual)" %}</option>
<option value="other">{% trans "Other…" %}</option>
</select>
<textarea type="text" value="" class="input-block-level form-control"
id="toolbox-content-other"></textarea>
<div class="i18n-form-group" id="toolbox-content-other-i18n">
{% for l in request.event.settings.locales %}
<textarea id="toolbox-content-other-{{ l }}" rows="3" class="input-block-level form-control" title="{{ l }}" lang="{{ l }}"></textarea>
{% endfor %}
</div>
<p class="help-block" id="toolbox-content-other-help">
<a href="?placeholders=true" target="_blank">{% trans "Show available placeholders" %}</a>
</p>
</div>
</div>
<div class="row control-group position"> <div class="row control-group position">
<hr/>
<div class="col-sm-6"> <div class="col-sm-6">
<label>{% trans "x (mm)" %}</label><br> <label>{% trans "x (mm)" %}</label><br>
<input type="number" value="13" class="input-block-level form-control" step="0.01" <input type="number" value="13" class="input-block-level form-control" step="0.01"
@@ -332,30 +277,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="row control-group text">
<div class="col-sm-6">
<label>{% trans "Width (mm)" %}</label><br>
<input type="number" value="13" class="input-block-level form-control" step="0.01"
id="toolbox-textwidth">
</div>
<div class="col-sm-6">
<label>{% trans "Rotation (°)" %}</label><br>
<input type="number" value="0" class="input-block-level form-control" step="0.1"
id="toolbox-textrotation">
</div>
</div>
<div class="row control-group text">
<hr/>
<div class="col-sm-12">
<label>{% trans "Font" %}</label><br>
<select class="input-block-level form-control" id="toolbox-fontfamily">
<option>Open Sans</option>
{% for family in fonts.keys %}
<option>{{ family }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row control-group text"> <div class="row control-group text">
<div class="col-sm-6"> <div class="col-sm-6">
<label>{% trans "Font size (pt)" %}</label><br> <label>{% trans "Font size (pt)" %}</label><br>
@@ -414,6 +335,83 @@
</div> </div>
</div> </div>
</div> </div>
<div class="row control-group text">
<div class="col-sm-12">
<label>{% trans "Font" %}</label><br>
<select class="input-block-level form-control" id="toolbox-fontfamily">
<option>Open Sans</option>
{% for family in fonts.keys %}
<option>{{ family }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row control-group text">
<div class="col-sm-6">
<label>{% trans "Width (mm)" %}</label><br>
<input type="number" value="13" class="input-block-level form-control" step="0.01"
id="toolbox-textwidth">
</div>
<div class="col-sm-6">
<label>{% trans "Rotation (°)" %}</label><br>
<input type="number" value="0" class="input-block-level form-control" step="0.1"
id="toolbox-textrotation">
</div>
</div>
<div class="row control-group poweredby">
<div class="col-sm-12">
<label>{% trans "Style" %}</label><br>
<select class="input-block-level form-control" id="toolbox-poweredby-style">
<option value="dark">{% trans "Dark" %}</option>
<option value="white">{% trans "Light" %}</option>
</select>
</div>
</div>
<div class="row control-group imagecontent">
<div class="col-sm-12">
<label>{% trans "Image content" %}</label><br>
<select class="input-block-level form-control" id="toolbox-imagecontent">
<option value="">{% trans "Empty" %}</option>
{% for varname, var in images.items %}
<option value="{{ varname }}">{{ var.label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row control-group text textcontent">
<div class="col-sm-12">
<label>{% trans "Content" %}</label><br>
<select class="input-block-level form-control" id="toolbox-content">
{% for varname, var in variables.items %}
{% if not var.hidden %}
<option data-sample="{{ var.editor_sample }}" {% if var.migrate_from %}data-old-value="{{ var.migrate_from }}"{% endif %} value="{{ varname }}">{{ var.label }}</option>
{% endif %}
{% endfor %}
{% for p in request.organizer.meta_properties.all %}
<option value="meta:{{ p.name }}">
{% trans "Event attribute:" %} {{ p.name }}
</option>
{% endfor %}
{% for p in request.event.item_meta_properties.all %}
<option value="itemmeta:{{ p.name }}">
{% trans "Item attribute:" %} {{ p.name }}
</option>
{% endfor %}
<option value="other_i18n">{% trans "Other… (multilingual)" %}</option>
<option value="other">{% trans "Other…" %}</option>
</select>
<textarea type="text" value="" class="input-block-level form-control"
id="toolbox-content-other"></textarea>
<div class="i18n-form-group" id="toolbox-content-other-i18n">
{% for l in request.event.settings.locales %}
<textarea id="toolbox-content-other-{{ l }}" rows="3" class="input-block-level form-control" title="{{ l }}" lang="{{ l }}"></textarea>
{% endfor %}
</div>
<p class="help-block" id="toolbox-content-other-help">
<a href="?placeholders=true" target="_blank">{% trans "Show available placeholders" %}</a>
</p>
</div>
</div>
</div> </div>
</div> </div>
<div class="editor-toolbox-text panel panel-default"> <div class="editor-toolbox-text panel panel-default">
@@ -503,13 +503,11 @@
</div> </div>
<div class="form-group subevent-itemvar-group"> <div class="form-group subevent-itemvar-group">
<div class="col-md-4 col-md-offset-3"> <div class="col-md-4 col-md-offset-3">
<label for="{{ f.rel_available_from.id_for_label }}" class="text-muted">{% trans "Available from" %}</label> <label for="{{ f.rel_available_from.id_for_label }}" class="text-muted">{% trans "Available from" %}</label><br>
{% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_from_mode %}<br>
{% bootstrap_field f.rel_available_from form_group_class="" layout="inline" %} {% bootstrap_field f.rel_available_from form_group_class="" layout="inline" %}
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label for="{{ f.rel_available_until.id_for_label }}" class="text-muted">{% trans "Available until" %}</label> <label for="{{ f.rel_available_until.id_for_label }}" class="text-muted">{% trans "Available until" %}</label><br>
{% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_until_mode %}<br>
{% bootstrap_field f.rel_available_until form_group_class="" layout="inline" %} {% bootstrap_field f.rel_available_until form_group_class="" layout="inline" %}
</div> </div>
</div> </div>
@@ -167,13 +167,11 @@
</div> </div>
<div class="form-group subevent-itemvar-group"> <div class="form-group subevent-itemvar-group">
<div class="col-md-4 col-md-offset-3"> <div class="col-md-4 col-md-offset-3">
<label for="{{ f.available_from.id_for_label }}" class="text-muted">{% trans "Available from" %}</label> <label for="{{ f.available_from.id_for_label }}" class="text-muted">{% trans "Available from" %}</label><br>
{% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_from_mode %}<br>
{% bootstrap_field f.available_from form_group_class="" layout="bulkedit_inline" %} {% bootstrap_field f.available_from form_group_class="" layout="bulkedit_inline" %}
</div> </div>
<div class="col-md-5"> <div class="col-md-5">
<label for="{{ f.available_until.id_for_label }}" class="text-muted">{% trans "Available until" %}</label> <label for="{{ f.available_until.id_for_label }}" class="text-muted">{% trans "Available until" %}</label><br>
{% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_until_mode %}<br>
{% bootstrap_field f.available_until form_group_class="" layout="bulkedit_inline" %} {% bootstrap_field f.available_until form_group_class="" layout="bulkedit_inline" %}
</div> </div>
</div> </div>
@@ -145,13 +145,11 @@
</div> </div>
<div class="form-group subevent-itemvar-group"> <div class="form-group subevent-itemvar-group">
<div class="col-md-4 col-md-offset-3"> <div class="col-md-4 col-md-offset-3">
<label for="{{ f.available_from.id_for_label }}" class="text-muted">{% trans "Available from" %}</label> <label for="{{ f.available_from.id_for_label }}" class="text-muted">{% trans "Available from" %}</label><br>
{% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_from_mode %}<br> {% bootstrap_field f.available_from form_group_class="" layout="inline" %}
{% bootstrap_field f.available_from form_group_class="foo" layout="inline" %}
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label for="{{ f.available_until.id_for_label }}" class="text-muted">{% trans "Available until" %}</label> <label for="{{ f.available_until.id_for_label }}" class="text-muted">{% trans "Available until" %}</label><br>
{% include "pretixcontrol/subevents/fragment_unavail_mode_indicator.html" with mode=f.available_until_mode %}<br>
{% bootstrap_field f.available_until form_group_class="" layout="inline" %} {% bootstrap_field f.available_until form_group_class="" layout="inline" %}
</div> </div>
</div> </div>

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