Compare commits

..

1 Commits

Author SHA1 Message Date
Raphael Michel
b00b063e6d Explicitly set SQL connection timezone 2022-02-01 18:09:56 +01:00
280 changed files with 95633 additions and 163683 deletions

View File

@@ -55,7 +55,7 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-pip- ${{ runner.os }}-pip-
- name: Install system dependencies - name: Install system dependencies
run: sudo apt update && sudo apt install gettext mariadb-client-10.3 run: sudo apt update && sudo apt install gettext mariadb-client
- name: Install Python dependencies - name: Install Python dependencies
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
working-directory: ./src working-directory: ./src

View File

@@ -65,9 +65,6 @@ Example::
A comma-separated list of plugins that are not available even though they are installed. A comma-separated list of plugins that are not available even though they are installed.
Defaults to an empty string. Defaults to an empty string.
``plugins_show_meta``
Whether to show authors and versions of plugins, defaults to ``on``.
``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``.

View File

@@ -108,18 +108,6 @@ Now restart redis-server::
# systemctl restart redis-server # systemctl restart redis-server
In this setup, systemd will delete ``/var/run/redis`` on every redis restart, which will cause issues with pretix. To
prevent this, you can execute::
# systemctl edit redis-server
And insert the following::
[Service]
# Keep the directory around so that pretix.service in docker does not need to be
# restarted when redis is restarted.
RuntimeDirectoryPreserve=yes
.. warning:: Setting the socket permissions to 777 is a possible security problem. If you have untrusted users on your .. warning:: Setting the socket permissions to 777 is a possible security problem. If you have untrusted users on your
system or have high security requirements, please don't do this and let redis listen to a TCP socket system or have high security requirements, please don't do this and let redis listen to a TCP socket
instead. We recommend the socket approach because the TCP socket in combination with docker's networking instead. We recommend the socket approach because the TCP socket in combination with docker's networking

View File

@@ -99,8 +99,7 @@ following endpoint:
"hardware_brand": "Samsung", "hardware_brand": "Samsung",
"hardware_model": "Galaxy S", "hardware_model": "Galaxy S",
"software_brand": "pretixdroid", "software_brand": "pretixdroid",
"software_version": "4.1.0", "software_version": "4.1.0"
"info": {"arbitrary": "data"}
} }
You will receive a response equivalent to the response of your initialization request. You will receive a response equivalent to the response of your initialization request.

View File

@@ -43,8 +43,6 @@ Possible permissions are:
* Can view vouchers * Can view vouchers
* Can change vouchers * Can change vouchers
.. _`rest-compat`:
Compatibility Compatibility
------------- -------------
@@ -60,7 +58,6 @@ that your clients can deal with them properly:
* Support of new HTTP methods for a given API endpoint * Support of new HTTP methods for a given API endpoint
* Support of new query parameters for a given API endpoint * Support of new query parameters for a given API endpoint
* New fields contained in API responses * New fields contained in API responses
* Response body structure or message texts on failed requests (``4xx``, ``5xx`` response codes)
We treat the following types of changes as *backwards-incompatible*: We treat the following types of changes as *backwards-incompatible*:

View File

@@ -1,11 +1,6 @@
Resources and endpoints Resources and endpoints
======================= =======================
With a few exceptions, this only lists resources bundled in the pretix core modules.
Additional endpoints are provided by pretix plugins. Some of them are documented
at :ref:`plugin-docs`.
.. toctree:: .. toctree::
:maxdepth: 2 :maxdepth: 2
@@ -38,4 +33,4 @@ at :ref:`plugin-docs`.
exporters exporters
sendmail_rules sendmail_rules
billing_invoices billing_invoices
billing_var billing_var

View File

@@ -68,7 +68,6 @@ positions list of objects List of order p
non-canceled positions are included. non-canceled positions are included.
fees list of objects List of fees included in the order total. By default, only fees list of objects List of fees included in the order total. By default, only
non-canceled fees are included. non-canceled fees are included.
├ id integer Internal ID of the fee record
├ fee_type string Type of fee (currently ``payment``, ``passbook``, ├ fee_type string Type of fee (currently ``payment``, ``passbook``,
``other``) ``other``)
├ value money (string) Fee amount ├ value money (string) Fee amount
@@ -137,10 +136,6 @@ last_modified datetime Last modificati
The ``subevent`` query parameters has been added. The ``subevent`` query parameters has been added.
.. versionchanged:: 4.8
The ``order.fees.id`` attribute has been added.
.. _order-position-resource: .. _order-position-resource:
@@ -740,37 +735,6 @@ Generating new secrets
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/regenerate_secrets/
Triggers generation of a new ``secret`` attribute for a single order position.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23/regenerate_secrets/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
(Full order position resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
:param code: The ``id`` field of the order position to update
:statuscode 200: no error
:statuscode 400: The order position could not be updated due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order position.
Deleting orders Deleting orders
--------------- ---------------
@@ -875,7 +839,6 @@ Creating orders
* ``comment`` (optional) * ``comment`` (optional)
* ``custom_followup_at`` (optional) * ``custom_followup_at`` (optional)
* ``checkin_attention`` (optional) * ``checkin_attention`` (optional)
* ``require_approval`` (optional)
* ``invoice_address`` (optional) * ``invoice_address`` (optional)
* ``company`` * ``company``
@@ -935,9 +898,8 @@ Creating orders
* ``force`` (optional). If set to ``true``, quotas will be ignored. * ``force`` (optional). If set to ``true``, quotas will be ignored.
* ``send_email`` (optional). If set to ``true``, the same emails will be sent as for a regular order, regardless of * ``send_email`` (optional). If set to ``true``, the same emails will be sent as for a regular order, regardless of
whether these emails are enabled for certain sales channels. If set to ``null``, behavior will be controlled by pretix' whether these emails are enabled for certain sales channels. Defaults to
settings based on the sales channels (added in pretix 4.7). Defaults to ``false``. ``false``. Used to be ``send_mail`` before pretix 3.14.
Used to be ``send_mail`` before pretix 3.14.
If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually
to incrementing integers starting with ``1``. Then, you can reference one of these to incrementing integers starting with ``1``. Then, you can reference one of these
@@ -1082,9 +1044,6 @@ Order state operations
will instead stay paid, but all positions will be removed (or marked as canceled) and replaced by the cancellation will instead stay paid, but all positions will be removed (or marked as canceled) and replaced by the cancellation
fee as the only component of the order. fee as the only component of the order.
You can control whether the customer is notified through ``send_email`` (defaults to ``true``).
You can pass a ``comment`` that can be visible to the user if it is used in the email template.
**Example request**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
@@ -1096,7 +1055,6 @@ Order state operations
{ {
"send_email": true, "send_email": true,
"comment": "Event was canceled.",
"cancellation_fee": null "cancellation_fee": null
} }
@@ -1682,8 +1640,6 @@ Order position ticket download
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few :statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
seconds. seconds.
.. _rest-orderpositions-manipulate:
Manipulating individual positions Manipulating individual positions
--------------------------------- ---------------------------------
@@ -1691,11 +1647,6 @@ Manipulating individual positions
The ``PATCH`` method has been added for individual positions. The ``PATCH`` method has been added for individual positions.
.. versionchanged:: 4.8
The ``PATCH`` method now supports changing items, variations, subevents, seats, prices, and tax rules.
The ``POST`` endpoint to add individual positions has been added.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/ .. http:patch:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
Updates specific fields on an order position. Currently, only the following fields are supported: Updates specific fields on an order position. Currently, only the following fields are supported:
@@ -1722,21 +1673,6 @@ Manipulating individual positions
and ``option_identifiers`` will be ignored. As a special case, you can submit the magic value and ``option_identifiers`` will be ignored. As a special case, you can submit the magic value
``"file:keep"`` as the answer to a file question to keep the current value without re-uploading it. ``"file:keep"`` as the answer to a file question to keep the current value without re-uploading it.
* ``item``
* ``variation``
* ``subevent``
* ``seat`` (specified as a string mapping to a ``string_guid``)
* ``price``
* ``tax_rule``
Changing parameters such as ``item`` or ``price`` will **not** automatically trigger creation of a new invoice,
you need to take care of that yourself.
**Example request**: **Example request**:
.. sourcecode:: http .. sourcecode:: http
@@ -1758,7 +1694,7 @@ Manipulating individual positions
Vary: Accept Vary: Accept
Content-Type: application/json Content-Type: application/json
(Full order position resource, see above.) (Full order resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event :param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event :param event: The ``slug`` field of the event
@@ -1769,83 +1705,9 @@ Manipulating individual positions
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
Adds a new position to an order. Currently, only the following fields are supported:
* ``order`` (mandatory, specified as a string mapping to a ``code``)
* ``addon_to`` (optional, specified as an integer mapping to the ``positionid`` of the parent position)
* ``item`` (mandatory)
* ``variation`` (mandatory depending on item)
* ``subevent`` (mandatory depending on event)
* ``seat`` (specified as a string mapping to a ``string_guid``, mandatory depending on event and item)
* ``price`` (default price will be used if unset)
* ``attendee_email``
* ``attendee_name_parts`` or ``attendee_name``
* ``company``
* ``street``
* ``zipcode``
* ``city``
* ``country``
* ``state``
* ``answers``: Validation is handled the same way as when creating orders through the API. You are therefore
expected to provide ``question``, ``answer``, and possibly ``options``. ``question_identifier``
and ``option_identifiers`` will be ignored. As a special case, you can submit the magic value
``"file:keep"`` as the answer to a file question to keep the current value without re-uploading it.
This will **not** automatically trigger creation of a new invoice, you need to take care of that yourself.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orderpositions/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"order": "ABC12",
"item": 5,
"addon_to": 1
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
(Full order position resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
:statuscode 200: no error
:statuscode 400: The position could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this position.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/ .. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
Cancels an order position, identified by its internal ID. Deletes an order position, identified by its internal ID.
**Example request**: **Example request**:
@@ -1871,128 +1733,6 @@ Manipulating individual positions
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order position does not exist. :statuscode 404: The requested order position does not exist.
Changing order contents
-----------------------
While you can :ref:`change positions individually <rest-orderpositions-manipulate>` sometimes it is necessary to make
multiple changes to an order at once within one transaction. This makes it possible to e.g. swap the seats of two
attendees in an order without running into conflicts. This interface also offers some possibilities not available
otherwise, such as splitting an order or changing fees.
.. versionchanged:: 4.8
This endpoint has been added to the system.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/change/
Performs a change operation on an order. You can supply the following fields:
* ``patch_positions``: A list of objects with the two keys ``position`` specifying an order position ID and
``body`` specifying the desired changed values of the position (``item``, ``variation``, ``subevent``, ``seat``,
``price``, ``tax_rule``).
* ``cancel_positions``: A list of objects with the single key ``position`` specifying an order position ID.
* ``split_positions``: A list of objects with the single key ``position`` specifying an order position ID.
* ``create_positions``: A list of objects describing new order positions with the same fields supported as when
creating them individually through the ``POST …/orderpositions/`` endpoint.
* ``patch_fees``: A list of objects with the two keys ``fee`` specifying an order fee ID and
``body`` specifying the desired changed values of the position (``value``).
* ``cancel_fees``: A list of objects with the single key ``fee`` specifying an order fee ID.
* ``recalculate_taxes``: If set to ``"keep_net"``, all taxes will be recalculated based on the tax rule and invoice
address, the net price will be kept. If set to ``"keep_gross"``, the gross price will be kept. If set to ``null``
(the default) the taxes are not recalculated.
* ``send_email``: If set to ``true``, the customer will be notified about the change. Defaults to ``false``.
* ``reissue_invoice``: If set to ``true`` and an invoice exists for the order, it will be canceled and a new invoice
will be issued. Defaults to ``true``.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"cancel_positions": [
{
"position": 12373
}
],
"patch_positions": [
{
"position": 12374,
"body": {
"item": 12,
"variation": None,
"subevent": 562,
"seat": "seat-guid-2",
"price": "99.99",
"tax_rule": 15
}
}
],
"split_positions": [
{
"position": 12375
}
],
"create_positions": [
{
"item": 12,
"variation": None,
"subevent": 562,
"seat": "seat-guid-2",
"price": "99.99",
"addon_to": 12374,
"attendee_name": "Peter",
}
],
"cancel_fees": [
{
"fee": 49
}
],
"change_fees": [
{
"fee": 51,
"body": {
"value": "12.00"
}
}
],
"reissue_invoice": true,
"send_email": true,
"recalculate_taxes": "keep_gross"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
(Full order position resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
:param code: The ``code`` field of the order to update
:statuscode 200: no error
:statuscode 400: The order could not be updated due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
Order payment endpoints Order payment endpoints
----------------------- -----------------------

View File

@@ -20,31 +20,20 @@ Basically, three pre-defined flows are supported:
* Authentication mechanisms that rely on **redirection**, e.g. to an OAuth provider. These can be implemented by * Authentication mechanisms that rely on **redirection**, e.g. to an OAuth provider. These can be implemented by
supplying a ``authentication_url`` method and implementing a custom return view. supplying a ``authentication_url`` method and implementing a custom return view.
For security reasons, authentication backends are *not* automatically discovered through a signal. Instead, they must Authentication backends are *not* collected through a signal. Instead, they must explicitly be set through the
explicitly be set through the ``auth_backends`` directive in the ``pretix.cfg`` :ref:`configuration file <config>`. ``auth_backends`` directive in the ``pretix.cfg`` :ref:`configuration file <config>`.
In each of these methods (``form_authenticate``, ``request_authenticate``, or your custom view) you are supposed to In each of these methods (``form_authenticate``, ``request_authenticate`` or your custom view) you are supposed to
use ``User.objects.get_or_create_for_backend`` to get a :py:class:`pretix.base.models.User` object from the database either get an existing :py:class:`pretix.base.models.User` object from the database or create a new one. There are a
or create a new one. few rules you need to follow:
There are a few rules you need to follow: * You **MUST** only return users with the ``auth_backend`` attribute set to the ``identifier`` value of your backend.
* You **MUST** have some kind of identifier for a user that is globally unique and **SHOULD** never change, even if the * You **MUST** create new users with the ``auth_backend`` attribute set to the ``identifier`` value of your backend.
user's name or email address changes. This could e.g. be the ID of the user in an external database. The identifier
must not be longer than 190 characters. If you worry your backend might generated longer identifiers, consider
using a hash function to trim them to a constant length.
* You **SHOULD** not allow users created by other authentication backends to log in through your code, and you **MUST**
only create, modify or return users with ``auth_backend`` set to your backend.
* Every user object **MUST** have an email address. Email addresses are globally unique. If the email address is * Every user object **MUST** have an email address. Email addresses are globally unique. If the email address is
already registered to a user who signs in through a different backend, you **SHOULD** refuse the login. already registered to a user who signs in through a different backend, you **SHOULD** refuse the login.
``User.objects.get_or_create_for_backend`` will follow these rules for you automatically. It works like this:
.. autoclass:: pretix.base.models.auth.UserManager
:members: get_or_create_for_backend
The backend interface The backend interface
--------------------- ---------------------
@@ -70,7 +59,6 @@ The backend interface
.. automethod:: authentication_url .. automethod:: authentication_url
Logging users in Logging users in
---------------- ----------------
@@ -80,45 +68,3 @@ recommend that you use the following utility method to correctly set session val
authentication (if activated): authentication (if activated):
.. autofunction:: pretix.control.views.auth.process_login .. autofunction:: pretix.control.views.auth.process_login
A custom view that is called after a redirect from an external identity provider could look like this::
from django.contrib import messages
from django.shortcuts import redirect
from django.urls import reverse
from pretix.base.models import User
from pretix.base.models.auth import EmailAddressTakenError
from pretix.control.views.auth import process_login
def return_view(request):
# Verify validity of login with the external provider's API
api_response = my_verify_login_function(
code=request.GET.get('code')
)
try:
u = User.objects.get_or_create_for_backend(
'my_backend_name',
api_response['userid'],
api_response['email'],
set_always={
'fullname': '{} {}'.format(
api_response.get('given_name', ''),
api_response.get('family_name', ''),
),
},
set_on_creation={
'locale': api_response.get('locale').lower()[:2],
'timezone': api_response.get('zoneinfo', 'UTC'),
}
)
except EmailAddressTakenError:
messages.error(
request, _('We cannot create your user account as a user account in this system '
'already exists with the same email address.')
)
return redirect(reverse('control:auth.login'))
else:
return process_login(request, u, keep_logged_in=False)

View File

@@ -45,17 +45,13 @@ Attribute Type Description
name string The human-readable name of your plugin name string The human-readable name of your plugin
author string Your name author string Your name
version string A human-readable version code of your plugin version string A human-readable version code of your plugin
description string A more verbose description of what your plugin does. May contain HTML. description string A more verbose description of what your plugin does.
category string Category of a plugin. Either one of ``"FEATURE"``, ``"PAYMENT"``, category string Category of a plugin. Either one of ``"FEATURE"``, ``"PAYMENT"``,
``"INTEGRATION"``, ``"CUSTOMIZATION"``, ``"FORMAT"``, or ``"API"``, ``"INTEGRATION"``, ``"CUSTOMIZATION"``, ``"FORMAT"``, or ``"API"``,
or any other string. or any other string.
picture string (optional) Path to a picture resolvable through the static file system.
featured boolean (optional) ``False`` by default, can promote a plugin if it's something many users will want, use carefully.
visible boolean (optional) ``True`` by default, can hide a plugin so it cannot be normally activated. visible boolean (optional) ``True`` by default, can hide a plugin so it cannot be normally activated.
restricted boolean (optional) ``False`` by default, restricts a plugin such that it can only be enabled restricted boolean (optional) ``False`` by default, restricts a plugin such that it can only be enabled
for an event by system administrators / superusers. for an event by system administrators / superusers.
experimental boolean (optional) ``False`` by default, marks a plugin as an experimental feature in the plugins list.
picture string (optional) Path to a picture resolvable through the static file system.
compatibility string Specifier for compatible pretix versions. compatibility string Specifier for compatible pretix versions.
================== ==================== =========================================================== ================== ==================== ===========================================================
@@ -78,10 +74,8 @@ A working example would be:
name = _("PayPal") name = _("PayPal")
author = _("the pretix team") author = _("the pretix team")
version = '1.0.0' version = '1.0.0'
category = 'PAYMENT' category = 'PAYMENT
picture = 'pretix_paypal/paypal_logo.svg'
visible = True visible = True
featured = False
restricted = False restricted = False
description = _("This plugin allows you to receive payments via PayPal") description = _("This plugin allows you to receive payments via PayPal")
compatibility = "pretix>=2.7.0" compatibility = "pretix>=2.7.0"
@@ -98,7 +92,6 @@ those will be displayed but not block the plugin execution.
The ``AppConfig`` class may implement a method ``is_available(event)`` that checks if a plugin The ``AppConfig`` class may implement a method ``is_available(event)`` that checks if a plugin
is available for a specific event. If not, it will not be shown in the plugin list of that event. is available for a specific event. If not, it will not be shown in the plugin list of that event.
You should not define ``is_available`` and ``restricted`` on the same plugin.
Plugin registration Plugin registration
------------------- -------------------

View File

@@ -61,7 +61,7 @@ Variable Description
``attendee_city`` City of the ticket holder's address (or empty) ``attendee_city`` City of the ticket holder's address (or empty)
``attendee_country`` Country code of the ticket holder's address (or empty) ``attendee_country`` Country code of the ticket holder's address (or empty)
``attendee_state`` State of the ticket holder's address (or empty) ``attendee_state`` State of the ticket holder's address (or empty)
``answers[XYZ]`` Answer to the custom question with identifier ``XYZ`` ``answer[XYZ]`` Answer to the custom question with identifier ``XYZ``
``invoice_name`` Full name of the invoice address (or empty) ``invoice_name`` Full name of the invoice address (or empty)
``invoice_name_*`` Name parts of the invoice address, depending on configuration, e.g. ``invoice_name_given_name`` or ``invoice_name_family_name`` ``invoice_name_*`` Name parts of the invoice address, depending on configuration, e.g. ``invoice_name_given_name`` or ``invoice_name_family_name``
``invoice_company`` Company of the invoice address (or empty) ``invoice_company`` Company of the invoice address (or empty)

View File

@@ -1,630 +0,0 @@
Exhibitors
==========
The exhibitors plugin allows to manage exhibitors at your trade show or conference. After signing up your exhibitors
in the system, you can assign vouchers to exhibitors and give them access to the data of these vouchers. The exhibitors
module is also the basis of the pretixLEAD lead scanning application.
.. note:: On pretix Hosted, using the lead scanning feature of the exhibitors plugin can add additional costs
depending on your contract.
The plugin exposes two APIs. One (REST API) is intended for bulk-data operations from the admin side, and one
(App API) that is used by the pretixLEAD app.
REST API
---------
The REST API for exhibitors requires the usual :ref:`rest-auth`.
Resources
"""""""""
The exhibitors plugin provides a HTTP API that allows you to create new exhibitors.
The exhibitors resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal exhibitor ID in pretix
name string Exhibitor name
internal_id string Can be used for the ID in your exhibition system, your customer ID, etc. Can be ``null``. Maximum 255 characters.
contact_name string Contact person (or ``null``)
contact_name_parts object of strings Decomposition of contact name (i.e. given name, family name)
contact_email string Contact person email address (or ``null``)
booth string Booth number (or ``null``). Maximum 100 characters.
locale string Locale for communication with the exhibitor (or ``null``).
access_code string Access code for the exhibitor to access their data or use the lead scanning app (read-only).
allow_lead_scanning boolean Enables 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
comment string Internal comment, not shown to exhibitor
===================================== ========================== =======================================================
You can also access the scanned leads through the API which contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
attendee_order string Order code of the order the scanned attendee belongs to
attendee_positionid integer ``positionid`` if the attendee within the order specified by ``attendee_order``
rating integer A rating of 0 to 5 stars (or ``null``)
notes string A note taken by the exhibitor after scanning
tags list of strings Additional tags selected by the exhibitor
first_upload datetime Date and time of the first upload of this lead
data list of objects Attendee data set that may be shown to the exhibitor based o
the event's configuration. Each entry contains the fields ``id``,
``label``, ``value``, and ``details``. ``details`` is usually empty
except in a few cases where it contains an additional list of objects
with ``value`` and ``label`` keys (e.g. splitting of names).
device_name string User-defined name for the device used for scanning (or ``null``).
===================================== ========================== =======================================================
Endpoints
"""""""""
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/
Returns a list of all exhibitors configured for an event.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/exhibitors/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"name": "Aperture Science",
"internal_id": null,
"contact_name": "Dr Cave Johnson",
"contact_name_parts": {
"_scheme": "salutation_title_given_family",
"family_name": "Johnson",
"given_name": "Cave",
"salutation": "",
"title": "Dr"
},
"contact_email": "johnson@as.example.org",
"booth": "A2",
"locale": "de",
"access_code": "VKHZ2FU8",
"allow_lead_scanning": true,
"allow_lead_access": true,
"allow_voucher_access": true,
"comment": ""
}
]
}
:query page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the event to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to view it.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/(id)/
Returns information on one exhibitor, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/exhibitors/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": "Aperture Science",
"internal_id": null,
"contact_name": "Dr Cave Johnson",
"contact_name_parts": {
"_scheme": "salutation_title_given_family",
"family_name": "Johnson",
"given_name": "Cave",
"salutation": "",
"title": "Dr"
},
"contact_email": "johnson@as.example.org",
"booth": "A2",
"locale": "de",
"access_code": "VKHZ2FU8",
"allow_lead_scanning": true,
"allow_lead_access": true,
"allow_voucher_access": true,
"comment": ""
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param id: The ``id`` field of the exhibitor to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/exhibitor does not exist **or** you have no permission to view it.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/(id)/leads/
Returns a list of all scanned leads of an exhibitor.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/exhibitors/1/leads/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"attendee_order": "T0E7E",
"attendee_positionid": 1,
"rating": 1,
"notes": "",
"tags": [],
"first_upload": "2021-07-06T11:03:31.414491+01:00",
"data": [
{
"id": "attendee_name",
"label": "Attendee name",
"value": "Peter Miller",
"details": [
{"label": "Given name", "value": "Peter"},
{"label": "Family name", "value": "Miller"},
]
}
]
}
]
}
:query page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the event to fetch
:param id: The ``id`` field of the exhibitor to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event or exhibitor does not exist **or** you have no permission to view it.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/
Create a new exhibitor.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/exhibitors/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 166
{
"name": "Aperture Science",
"internal_id": null,
"contact_name_parts": {
"_scheme": "salutation_title_given_family",
"family_name": "Johnson",
"given_name": "Cave",
"salutation": "",
"title": "Dr"
},
"contact_email": "johnson@as.example.org",
"booth": "A2",
"locale": "de",
"access_code": "VKHZ2FU8",
"allow_lead_scanning": true,
"allow_lead_access": true,
"allow_voucher_access": true,
"comment": ""
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": "Aperture Science",
"internal_id": null,
"contact_name": "Dr Cave Johnson",
"contact_name_parts": {
"_scheme": "salutation_title_given_family",
"family_name": "Johnson",
"given_name": "Cave",
"salutation": "",
"title": "Dr"
},
"contact_email": "johnson@as.example.org",
"booth": "A2",
"locale": "de",
"access_code": "VKHZ2FU8",
"allow_lead_scanning": true,
"allow_lead_access": true,
"allow_voucher_access": true,
"comment": ""
}
:param organizer: The ``slug`` field of the organizer to create new exhibitor for
:param event: The ``slug`` field of the event to create new exhibitor for
:statuscode 201: no error
:statuscode 400: The exhibitor could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create exhibitors.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/(id)/
Update an exhibitor. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/digitalcontents/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 34
{
"internal_id": "ABC"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
"id": 1,
"name": "Aperture Science",
"internal_id": "ABC",
"contact_name": "Dr Cave Johnson",
"contact_name_parts": {
"_scheme": "salutation_title_given_family",
"family_name": "Johnson",
"given_name": "Cave",
"salutation": "",
"title": "Dr"
},
"contact_email": "johnson@as.example.org",
"booth": "A2",
"locale": "de",
"access_code": "VKHZ2FU8",
"allow_lead_scanning": true,
"allow_lead_access": true,
"allow_voucher_access": true,
"comment": ""
}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the exhibitor to modify
:statuscode 200: no error
:statuscode 400: The exhibitor could not be modified due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/exhibitor does not exist **or** you have no permission to change it.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/exhibitors/(id)/
Delete an exhibitor.
.. warning:: This deletes all lead scan data and removes all connections to vouchers (the vouchers are not deleted).
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/exhibitors/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the exhibitor to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/exhibitor does not exist **or** you have no permission to change it
App API
-------
The App API is used for communication between the pretixLEAD app and the pretix server.
.. warning:: We consider this an internal API, it is not intended for external use. You may still use it, but
our :ref:`compatibility commitment <rest-compat>` does not apply.
Authentication
""""""""""""""
Every exhibitor has an "access code", usually consisting of 8 alphanumeric uppercase characters.
This access code is communicated to event exhibitors by the event organizers, so this is also what
exhibitors should enter into a login screen.
All API requests need to contain this access code as a header like this::
Authorization: Exhibitor ABCDE123
Exhibitor profile
"""""""""""""""""
Upon login and in regular intervals after that, the API should fetch the exhibitors profile.
This serves two purposes:
* Checking if the authorization code is actually valid
* Obtaining information that can be shown in the app
The resource consists of the following fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
name string Exhibitor name
booth string Booth number (or ``null``)
event object Object describing the event
├ name multi-lingual string Event name
├ 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.
├ help_url string URL to help page. If not ``null``, a button in the app should link to this page.
├ logo_url string URL to event logo. If not ``null``, this logo may be shown in the app.
├ slug string Event short form
└ organizer string Organizer short form
notes boolean Specifies whether the exhibitor is allowed to take notes on leads
tags list of strings List of tags the exhibitor can assign to their leads
scan_types list of objects Only used for a special case, fixed value that external API consumers should ignore
===================================== ========================== =======================================================
.. http:get:: /exhibitors/api/v1/profile
**Example request:**
.. sourcecode:: http
GET /exhibitors/api/v1/profile HTTP/1.1
Authorization: Exhibitor ABCDE123
Accept: application/json, text/javascript
**Example response:**
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"name": "Aperture Science",
"booth": "A2",
"event": {
"name": {"en": "Sample conference", "de": "Beispielkonferenz"},
"slug": "bigevents",
"imprint_url": null,
"privacy_url": null,
"help_url": null,
"logo_url": null,
"organizer": "sampleconf"
},
"notes": true,
"tags": ["foo", "bar"],
"scan_types": [
{
"key": "lead",
"label": "Lead Scanning"
}
]
}
:statuscode 200: no error
:statuscode 401: Invalid authentication code
Submitting a lead
"""""""""""""""""
After a ticket/badge is scanned, it should immediately be submitted to the server
so the scan is stored and information about the person can be shown in the app. The same
code can be submitted multiple times, so it's no problem to just submit it again after the
exhibitor set a note or a rating (0-5) inside the app.
On the request, you should set the following properties:
* ``code`` with the scanned barcode
* ``notes`` with the exhibitor's notes
* ``scanned`` with the date and time of the actual scan (not the time of the upload)
* ``scan_type`` set to ``lead`` statically
* ``tags`` with the list of selected tags
* ``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``
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
supply other values, the information saved on the server will be overridden.
The response will also contain ``tags``, ``rating``, and ``notes``. Additionally,
it will include ``attendee`` with a list of ``fields`` that can be shown to the
user. Each field has an internal ``id``, a human-readable ``label``, and a ``value`` (all strings).
Note that the ``fields`` array can contain any number of dynamic keys!
Depending on the exhibitors permission and event configuration this might be empty,
or contain lots of details. The app should dynamically show these values (read-only)
with the labels sent by the server.
The request for this looks like this:
.. http:post:: /exhibitors/api/v1/leads/
**Example request:**
.. sourcecode:: http
POST /exhibitors/api/v1/leads/ HTTP/1.1
Authorization: Exhibitor ABCDE123
Accept: application/json, text/javascript
Content-Type: application/json
{
"code": "qrcodecontent",
"notes": "Great customer, wants our newsletter",
"scanned": "2020-10-18T12:24:23.000+00:00",
"scan_type": "lead",
"tags": ["foo"],
"rating": 4,
"device_name": "DEV1"
}
**Example response:**
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"attendee": {
"fields": [
{
"id": "attendee_name",
"label": "Name",
"value": "Jon Doe",
"details": [
{"label": "Given name", "value": "John"},
{"label": "Family name", "value": "Doe"},
]
},
{
"id": "attendee_email",
"label": "Email",
"value": "test@example.com",
"details": []
}
]
},
"rating": 4,
"tags": ["foo"],
"notes": "Great customer, wants our newsletter"
}
:statuscode 200: No error, leads was not scanned for the first time
:statuscode 201: No error, leads was scanned for the first time
:statuscode 400: Invalid data submitted
:statuscode 401: Invalid authentication code
You can also fetch existing leads (if you are authorized to do so):
.. http:get:: /exhibitors/api/v1/leads/
**Example request:**
.. sourcecode:: http
GET /exhibitors/api/v1/leads/ HTTP/1.1
Authorization: Exhibitor ABCDE123
Accept: application/json, text/javascript
**Example response:**
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"attendee": {
"fields": [
{
"id": "attendee_name",
"label": "Name",
"value": "Jon Doe",
"details": [
{"label": "Given name", "value": "John"},
{"label": "Family name", "value": "Doe"},
]
},
{
"id": "attendee_email",
"label": "Email",
"value": "test@example.com",
"details": []
}
]
},
"rating": 4,
"tags": ["foo"],
"notes": "Great customer, wants our newsletter"
}
]
}
:statuscode 200: No error
:statuscode 401: Invalid authentication code
:statuscode 403: Not permitted to access bulk data

View File

@@ -1,5 +1,3 @@
.. _`plugin-docs`:
Plugin documentation Plugin documentation
==================== ====================
@@ -12,13 +10,12 @@ If you want to **create** a plugin, please go to the
:maxdepth: 2 :maxdepth: 2
list list
pretixdroid
banktransfer banktransfer
ticketoutputpdf ticketoutputpdf
badges badges
campaigns campaigns
certificates certificates
digital digital
exhibitors
imported_secrets imported_secrets
webinar webinar
presale-saml

View File

@@ -5,6 +5,6 @@ List of plugins
=============== ===============
A detailed list of plugins that are available for pretix can be found on the A detailed list of plugins that are available for pretix can be found on the
`pretix Marketplace`_. `project website`_.
.. _pretix Marketplace: https://marketplace.pretix.eu .. _project website: https://pretix.eu/about/en/plugins

View File

@@ -1,405 +0,0 @@
.. highlight:: ini
.. spelling::
IdP
skIDentity
ePA
NPA
Presale SAML Authentication
===========================
The Presale SAML Authentication plugin is an advanced plugin, which most event
organizers will not need to use. However, for the select few who do require
strong customer authentication that cannot be covered by the built-in customer
account functionality, this plugin allows pretix to connect to a SAML IdP and
perform authentication and retrieval of user information.
Usage of the plugin is governed by two separate sets of settings: The plugin
installation, the Service Provider (SP) configuration and the event
configuration.
Plugin installation and initial configuration
---------------------------------------------
.. note:: If you are a customer of our hosted `pretix.eu`_ offering, you can
skip this section.
The plugin is installed as any other plugin in the pretix ecosystem. As a
pretix system administrator, please follow the instructions in the the
:ref:`Administrator documentation <admindocs>`.
Once installed, you will need to assess, if you want (or need) your pretix
instance to be a single SP for all organizers and events or if every event
organizer has to provide their own SP.
Take the example of a university which runs pretix under an pretix Enterprise
agreement. Since they only provide ticketing services to themselves (every
organizer is still just a different department of the same university), a
single SP should be enough.
On the other hand, a reseller such as `pretix.eu`_ who services a multitude
of clients would not work that way. Here, every organizer is a separate
legal entity and as such will also need to provide their own SP configuration:
Company A will expect their SP to reflect their company - and not a generalized
"pretix SP".
Once you have decided on the mode of operation, the :ref:`Configuration file
<config>` needs to be extended to reflect your choice.
Example::
[presale-saml]
level=global
``level``
``global`` to use only a single, system-wide SP, ``organizer`` for multiple
SPs, configured on the organizer-level. Defaults to ``organizer``.
Service Provider configuration
------------------------------
Global Level
^^^^^^^^^^^^
.. note:: If you are a customer of our hosted `pretix.eu`_ offering, you can
skip this section and follow the instructions on the upcoming
Organizer Level settings.
As a user with administrative privileges, please activate them by clicking the
`Admin Mode` button in the top right hand corner.
You should now see a new menu-item titled `SAML` appear.
Organizer Level
^^^^^^^^^^^^^^^
Navigate to the organizer settings in the pretix backend. In the navigation
bar, you will find a menu-item titled `SAML` if your user has the `Can
change organizer settings` permission.
.. note:: If you are a customer of our hosted `pretix.eu`_ offering, the menu
will only appear once one of our friendly customer service agents
has enabled the Presale SAML Authentication plugin for at least one
of your events. Feel free to get in touch with us!
Setting up the SP
^^^^^^^^^^^^^^^^^
No matter where your SP configuration lives, you will be greeted by a very
long list of fields of which almost all of them will need to be filled. Please
don't be discouraged - most of the settings don't need to be decided by yourself
and/or are already preset with a sensible default setting.
If you are not sure what setting you should choose for any of the fields, you
should reach out to your IdP operator as they can tell you exactly what the IdP
expects and - more importantly - supports.
``IdP Metadata URL``
Please provide the URL where your IdP outputs its metadata. For most IdPs,
this URL is static and the same for all SPs. If you are a member of the
DFN-AAI, you can find the meta-data for the `Test-, Basic- and
Advanced-Federation`_ on their website. Please do talk with your local
IdP operator though, as you might not even need to go through the DFN-AAI
and might just use your institutions local IdP which will also host their
metadata on a different URL.
The URL needs to be publicly accessible, as saving the settings form will
fail if the IdP metadata cannot be retrieved. pretix will also automatically
refresh the IdP metadata on a regular basis.
``SP Entity Id``
By default, we recommend that you use the system-proposed metadata-URL as
the Entity Id of your SP. However, if so desired or required by your IdP,
you can also set any other, arbitrary URL as the SP Entity Id.
``SP Name / SP Decription``
Most IdP will display the name and description of your SP to the users
during authentication. The description field can be used to explain to the
users how their data is being used.
``SP X.509 Certificate / SP X.509 Private Key``
Your SP needs a certificate and a private key for said certificate. Please
coordinate with your IdP, if you are supposed to generate these yourself or
if they are provided to you.
``SP X.509 New Certificate``
As certificates have an expiry date, they need to be renewed on a regular
basis. In order to facilitate the rollover from the expiring to the new
certificate, you can provide the new certificate already before the expiration
of the existing one. That way, the system will automatically use the correct
one. Once the old certificate has expired and is not used anymore at all,
you can move the new certificate into the slot of the normal certificate and
keep the new slot empty for your next renewal process.
``Requested Attributes``
An IdP can hold a variety of attributes of an authenticating user. While
your IdP will dictate which of the available attributes your SP can consume
in theory, you will still need to define exactly which attributes the SP
should request.
The notation is a JSON list of objects with 5 attributes each:
* ``attributeValue``: Can be defaulted to ``[]``.
* ``friendlyName``: String used in the upcoming event-level settings to
retrieve the attributes data.
* ``isRequired``: Boolean indicating whether the IdP must enforce the
transmission of this attribute. In most cases, ``true`` is the best
choice.
* ``name``: String of the internal, technical name of the requested
attribute. Often starting with ``urn:mace:dir:attribute-def:``,
``urn:oid:`` or ``http://``/``https://``.
* ``nameFormat``: String describing the type of ``name`` that has been
set in the previous section. Often starting with
``urn:mace:shibboleth:1.0:`` or ``urn:oasis:names:tc:SAML:2.0:``.
Your IdP can provide you with a list of available attributes. See below
for a sample configuration in an academic context.
Note, that you can have multiple attributes with the same ``friendlyName``
but different ``name``s. This is often used in systems, where the same
information (for example a persons name) is saved in different fields -
for example because one institution is returning SAML 1.0 and other
institutions are returning SAML 2.0 style attributes. Typically, this only
occurs in mix environments like the DFN-AAI with a large number of
participants. If you are only using your own institutions IdP and not
authenticating anyone outside of your realm, this should not be a common
sight.
``Encrypt/Sign/Require ...``
Does what is says on the box - please inquire with your IdP for the
necessary settings. Most settings can be turned on as they increase security,
however some IdPs might stumble over some of them.
``Signature / Digest Algorithm``
Please chose appropriate algorithms, that both pretix/your SP and the IdP
can communicate with. A common source of issues when connecting to a
Shibboleth-based IdP is the Digest Algorithm: pretix does not support
``http://www.w3.org/2009/xmlenc11#rsa-oaep`` and authentication will fail
if the IdP enforces this.
``Technical/Support Contacts``
Those contacts are encoded into the SPs public meta data and might be
displayed to users having trouble authenticating. It is recommended to
provide a dedicated point of contact for technical issues, as those will
be the ones to change the configuration for the SP.
Event / Authentication configuration
------------------------------------
Basic settings
^^^^^^^^^^^^^^
Once the plugin has been enabled for a pretix event using the Plugins-menu from
the event's settings, a new *SAML* menu item will show up.
On this page, the actual authentication can be configured.
``Checkout Explanation``
Since most users probably won't be familiar with why they have to authenticate
to buy a ticket, you can provide them a small blurb here. Markdown is supported.
``Attribute RegEx``
By default, any successful authentication with the IdP will allow the user to
proceed with their purchase. Should the allowed audience needed to be restricted
further, a set of regular Expressions can be used to do this.
An Attribute RegEx of ``{}`` will allow any authenticated user to pass.
A RegEx of ``{ "affiliation": "^(employee@pretix.eu|staff@pretix.eu)$" }`` will
only allow user to pass which have the ``affiliation`` attribute and whose
attribute either matches ``employee@pretix.eu`` or ``staff@pretix.eu``.
Please make sure that the attribute you are querying is also requested from the
IdP in the first place - for a quick check you can have a look at the top of
the page where all currently configured attributes are listed.
``RegEx Fail Explanation``
Only used in conjunction with the above Attribute RegEx. Should the user not
pass the restrictions imposed by the regular expression, the user is shown
this error-message.
If you are - for example in an university context - restricting access to
students only, you might want to explain here that Employees are not allowed
to book tickets.
``Ticket Secret SAML Attribute``
In very specific instances, it might be desirable that the ticket-secret is
not the randomly one generated by pretix but rather based on one of the
users attributes - for example their unique ID or access card number.
To achieve this, the name of a SAML-attribute can be specified here.
It is however necessary to note, that even with this setting in use,
ticket-secrets need to be unique. This is why when this setting is enabled,
the default, pretix-generated ticket-secret is prefixed with the attributes
value.
Example: A users ``cardid`` attribute has the value of ``01189998819991197253``.
The default random ticket secret would have been
``yczygpw9877akz2xwdhtdyvdqwkv7npj``. The resulting new secret will now be
``01189998819991197253_yczygpw9877akz2xwdhtdyvdqwkv7npj``.
That way, the ticket secret is still unique, but when checking into an event,
the user can easily be searched and found using their identifier.
IdP-provided E-Mail addresses, names
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
By default, pretix will only authenticate the user and not process the received
data any further.
However, there are a few exceptions to this rule.
There are a few `magic` attributes that pretix will use to automatically populate
the corresponding fields within the checkout process **and lock them out from
user editing**.
* ``givenName`` and ``sn``: If both of those attributes are present and pretix
is configured to collect the users name, these attributes' values are used
for the given and family name respectively.
* ``email``: If this attribute is present, the E-Mail-address of the users will
be set to the one transmitted through the attributes.
The latter might pose a problem, if the IdP is transmitting an ``email`` attribute
which does contain a system-level mail address which is only used as an internal
identifier but not as a real mailbox. In this case, please consider setting the
``friendlyName`` of the attribute to a different value than ``email`` or removing
this field from the list of requested attributes altogether.
Saving attributes to questions
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
By setting the ``internal identifier`` of a user-defined question to the same name
as a SAML attribute, pretix will save the value of said attribute into the question.
All the same as in the above section on E-Mail addresses, those fields become
non-editable by the user.
Please be aware that some specialty question types might not be compatible with
the SAML attributes due to specific format requirements. If in doubt (or if the
checkout fails/the information is not properly saved), try setting the question
type to a simple type like "Text (one line)".
Notes and configuration examples
--------------------------------
Requesting SAML 1.0 and 2.0 attributes from an academic IdP
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This requests the ``eduPersonPrincipalName`` (also sometimes called EPPN),
``email``, ``givenName`` and ``sn`` both in SAML 1.0 and SAML 2.0 attributes.
.. sourcecode:: json
[
{
"attributeValue": [],
"friendlyName": "eduPersonPrincipalName",
"isRequired": true,
"name": "urn:mace:dir:attribute-def:eduPersonPrincipalName",
"nameFormat": "urn:mace:shibboleth:1.0:attributeNamespace:uri"
},
{
"attributeValue": [],
"friendlyName": "eduPersonPrincipalName",
"isRequired": true,
"name": "urn:oid:1.3.6.1.4.1.5923.1.1.1.6",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
},
{
"attributeValue": [],
"friendlyName": "email",
"isRequired": true,
"name": "urn:mace:dir:attribute-def:mail",
"nameFormat": "urn:mace:shibboleth:1.0:attributeNamespace:uri"
},
{
"attributeValue": [],
"friendlyName": "email",
"isRequired": true,
"name": "urn:oid:0.9.2342.19200300.100.1.3",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
},
{
"attributeValue": [],
"friendlyName": "givenName",
"isRequired": true,
"name": "urn:mace:dir:attribute-def:givenName",
"nameFormat": "urn:mace:shibboleth:1.0:attributeNamespace:uri"
},
{
"attributeValue": [],
"friendlyName": "givenName",
"isRequired": true,
"name": "urn:oid:2.5.4.42",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
},
{
"attributeValue": [],
"friendlyName": "sn",
"isRequired": true,
"name": "urn:mace:dir:attribute-def:sn",
"nameFormat": "urn:mace:shibboleth:1.0:attributeNamespace:uri"
},
{
"attributeValue": [],
"friendlyName": "sn",
"isRequired": true,
"name": "urn:oid:2.5.4.4",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
}
]
skIDentity IdP Metadata URL
^^^^^^^^^^^^^^^^^^^^^^^^^^^
Since the IdP Metadata URL for `skIDentity`_ is not readily documented/visible
in their backend, we document it here:
``https://service.skidentity.de/fs/saml/metadata``
Requesting skIDentity attributes for electronic identity cards
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This requests the basic ``eIdentifier``, ``IDType``, ``IDIssuer``, and
``NameID`` from the `skIDentity`_ SAML service, which are available for
electronic ID cards such as the German ePA/NPA. (Other attributes such as
the name and address are available at additional cost from the IdP).
.. sourcecode:: json
[
{
"attributeValue": [],
"friendlyName": "eIdentifier",
"isRequired": true,
"name": "http://www.skidentity.de/att/eIdentifier",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
},
{
"attributeValue": [],
"friendlyName": "IDType",
"isRequired": true,
"name": "http://www.skidentity.de/att/IDType",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
},
{
"attributeValue": [],
"friendlyName": "IDIssuer",
"isRequired": true,
"name": "http://www.skidentity.de/att/IDIssuer",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
},
{
"attributeValue": [],
"friendlyName": "NameID",
"isRequired": true,
"name": "http://www.skidentity.de/att/NameID",
"nameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
}
]
.. _pretix.eu: https://pretix.eu
.. _Test-, Basic- and Advanced-Federation: https://doku.tid.dfn.de/en:metadata
.. _skIDentity: https://www.skidentity.de/

368
doc/plugins/pretixdroid.rst Normal file
View File

@@ -0,0 +1,368 @@
pretixdroid HTTP API
====================
The pretixdroid plugin provides a HTTP API that the `pretixdroid Android app`_
uses to communicate with the pretix server.
.. warning:: This API is **DEPRECATED** and will probably go away soon. It is used **only** to serve the pretixdroid
Android app. There are no backwards compatibility guarantees on this API. We will not add features that
are not required for the Android App. There is a general-purpose :ref:`rest-api` that provides all
features that you need to check in.
.. versionchanged:: 1.12
Support for check-in-time questions has been added. The new API features are fully backwards-compatible and
negotiated live, so clients which do not need this feature can ignore the change. For this reason, the API version
has not been increased and is still set to 3.
.. versionchanged:: 1.13
Support for checking in unpaid tickets has been added.
.. http:post:: /pretixdroid/api/(organizer)/(event)/redeem/
Redeems a ticket, i.e. checks the user in.
**Example request**:
.. sourcecode:: http
POST /pretixdroid/api/demoorga/democon/redeem/?key=ABCDEF HTTP/1.1
Host: demo.pretix.eu
Accept: application/json, text/javascript
Content-Type: application/x-www-form-urlencoded
secret=az9u4mymhqktrbupmwkvv6xmgds5dk3&questions_supported=true
You **must** set the parameter secret.
You **must** set the parameter ``questions_supported`` to ``true`` **if** you support asking questions
back to the app operator. You **must not** set it if you do not support this feature. In that case, questions
will just be ignored.
You **may** set the additional parameter ``datetime`` in the body containing an ISO8601-encoded
datetime of the entry attempt. If you don"t, the current date and time will be used.
You **may** set the additional parameter ``force`` to indicate that the request should be logged
regardless of previous check-ins for the same ticket. This might be useful if you made the entry decision offline.
Questions will also always be ignored in this case (i.e. supplied answers will be saved, but no error will be
thrown if they are missing or invalid).
You **may** set the additional parameter ``nonce`` with a globally unique random value to identify this
check-in. This is meant to be used to prevent duplicate check-ins when you are just retrying after a connection
failure.
You **may** set the additional parameter ``ignore_unpaid`` to indicate that the check-in should be performed even
if the order is in pending state.
If questions are supported and required, you will receive a dictionary ``questions`` containing details on the
particular questions to ask. To answer them, just re-send your redemption request with additional parameters of
the form ``answer_<question>=<answer>``, e.g. ``answer_12=24``.
**Example successful response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"status": "ok"
"version": 3,
"data": {
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
"order": "ABCDE",
"item": "Standard ticket",
"item_id": 1,
"variation": null,
"variation_id": null,
"attendee_name": "Peter Higgs",
"attention": false,
"redeemed": true,
"checkin_allowed": true,
"addons_text": "Parking spot",
"paid": true
}
}
**Example response with required questions**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"status": "incomplete"
"version": 3
"data": {
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
"order": "ABCDE",
"item": "Standard ticket",
"item_id": 1,
"variation": null,
"variation_id": null,
"attendee_name": "Peter Higgs",
"attention": false,
"redeemed": true,
"checkin_allowed": true,
"addons_text": "Parking spot",
"paid": true
},
"questions": [
{
"id": 12,
"type": "C",
"question": "Choose a shirt size",
"required": true,
"position": 2,
"items": [1],
"options": [
{
"id": 24,
"answer": "M"
},
{
"id": 25,
"answer": "L"
}
]
}
]
}
**Example error response with data**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"status": "error",
"reason": "already_redeemed",
"version": 3,
"data": {
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
"order": "ABCDE",
"item": "Standard ticket",
"item_id": 1,
"variation": null,
"variation_id": null,
"attendee_name": "Peter Higgs",
"attention": false,
"redeemed": true,
"checkin_allowed": true,
"addons_text": "Parking spot",
"paid": true
}
}
**Example error response without data**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"status": "error",
"reason": "unkown_ticket",
"version": 3
}
Possible error reasons:
* ``unpaid`` - Ticket is not paid for or has been refunded
* ``already_redeemed`` - Ticket already has been redeemed
* ``product`` - Tickets with this product may not be scanned at this device
* ``unknown_ticket`` - Secret does not match a ticket in the database
:query key: Secret API key
:statuscode 200: Valid request
:statuscode 404: Unknown organizer or event
:statuscode 403: Invalid authorization key
.. http:get:: /pretixdroid/api/(organizer)/(event)/search/
Searches for a ticket.
At most 25 results will be returned. **Queries with less than 4 characters will always return an empty result set.**
**Example request**:
.. sourcecode:: http
GET /pretixdroid/api/demoorga/democon/search/?key=ABCDEF&query=Peter HTTP/1.1
Host: demo.pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"results": [
{
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
"order": "ABCE6",
"item": "Standard ticket",
"variation": null,
"attendee_name": "Peter Higgs",
"redeemed": false,
"attention": false,
"checkin_allowed": true,
"addons_text": "Parking spot",
"paid": true
},
...
],
"version": 3
}
:query query: Search query
:query key: Secret API key
:statuscode 200: Valid request
:statuscode 404: Unknown organizer or event
:statuscode 403: Invalid authorization key
.. http:get:: /pretixdroid/api/(organizer)/(event)/download/
Download data for all tickets.
**Example request**:
.. sourcecode:: http
GET /pretixdroid/api/demoorga/democon/download/?key=ABCDEF HTTP/1.1
Host: demo.pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"version": 3,
"results": [
{
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
"order": "ABCE6",
"item": "Standard ticket",
"variation": null,
"attendee_name": "Peter Higgs",
"redeemed": false,
"attention": false,
"checkin_allowed": true,
"paid": true
},
...
],
"questions": [
{
"id": 12,
"type": "C",
"question": "Choose a shirt size",
"required": true,
"position": 2,
"items": [1],
"options": [
{
"id": 24,
"answer": "M"
},
{
"id": 25,
"answer": "L"
}
]
}
]
}
:query key: Secret API key
:statuscode 200: Valid request
:statuscode 404: Unknown organizer or event
:statuscode 403: Invalid authorization key
.. http:get:: /pretixdroid/api/(organizer)/(event)/status/
Returns status information, such as the total number of tickets and the
number of performed check-ins.
**Example request**:
.. sourcecode:: http
GET /pretixdroid/api/demoorga/democon/status/?key=ABCDEF HTTP/1.1
Host: demo.pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"checkins": 17,
"total": 42,
"version": 3,
"event": {
"name": "Demo Conference",
"slug": "democon",
"date_from": "2016-12-27T17:00:00Z",
"date_to": "2016-12-30T18:00:00Z",
"timezone": "UTC",
"url": "https://demo.pretix.eu/demoorga/democon/",
"organizer": {
"name": "Demo Organizer",
"slug": "demoorga"
},
},
"items": [
{
"name": "T-Shirt",
"id": 1,
"checkins": 1,
"admission": False,
"total": 1,
"variations": [
{
"name": "Red",
"id": 1,
"checkins": 1,
"total": 12
},
{
"name": "Blue",
"id": 2,
"checkins": 4,
"total": 8
}
]
},
{
"name": "Ticket",
"id": 2,
"checkins": 15,
"admission": True,
"total": 22,
"variations": []
}
]
}
:query key: Secret API key
:statuscode 200: Valid request
:statuscode 404: Unknown organizer or event
:statuscode 403: Invalid authorization key
.. _pretixdroid Android app: https://github.com/pretix/pretixdroid

View File

@@ -1,6 +1,5 @@
-e ../src/ -e ../src/
sphinx==2.3.* sphinx==2.3.*
jinja2==3.0.*
sphinx-rtd-theme sphinx-rtd-theme
sphinxcontrib-httpdomain sphinxcontrib-httpdomain
sphinxcontrib-images sphinxcontrib-images

View File

@@ -103,7 +103,6 @@ prepending
preprocessor preprocessor
presale presale
pretix pretix
pretixLEAD
pretixSCAN pretixSCAN
pretixdroid pretixdroid
pretixPOS pretixPOS

View File

@@ -253,21 +253,18 @@ If you want, you can suppress us loading the widget and/or modify the user data
If you then later want to trigger loading the widgets, just call ``window.PretixWidget.buildWidgets()``. If you then later want to trigger loading the widgets, just call ``window.PretixWidget.buildWidgets()``.
Waiting for the widget to load or close Waiting for the widget to load
--------------------------------------- ------------------------------
If you want to run custom JavaScript once the widget is fully loaded or when it is closed, you can register callback If you want to run custom JavaScript once the widget is fully loaded, you can register a callback function. Note that
functions. Note that these function might be run multiple times, for example if you have multiple widgets on a page this function might be run multiple times, for example if you have multiple widgets on a page or if the user switches
or if the user switches e.g. from an event list to an event detail view:: e.g. from an event list to an event detail view::
<script type="text/javascript"> <script type="text/javascript">
window.pretixWidgetCallback = function () { window.pretixWidgetCallback = function () {
window.PretixWidget.addLoadListener(function () { window.PretixWidget.addLoadListener(function () {
console.log("Widget has loaded!"); console.log("Widget has loaded!");
}); });
window.PretixWidget.addCloseListener(function () {
console.log("Widget has been closed!");
});
} }
</script> </script>

View File

@@ -13,7 +13,6 @@ recursive-include pretix/plugins/banktransfer/static *
recursive-include pretix/plugins/manualpayment/templates * recursive-include pretix/plugins/manualpayment/templates *
recursive-include pretix/plugins/manualpayment/static * recursive-include pretix/plugins/manualpayment/static *
recursive-include pretix/plugins/paypal/templates * recursive-include pretix/plugins/paypal/templates *
recursive-include pretix/plugins/paypal/static *
recursive-include pretix/plugins/pretixdroid/templates * recursive-include pretix/plugins/pretixdroid/templates *
recursive-include pretix/plugins/pretixdroid/static * recursive-include pretix/plugins/pretixdroid/static *
recursive-include pretix/plugins/sendmail/templates * recursive-include pretix/plugins/sendmail/templates *

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__ = "4.9.0" __version__ = "4.7.0.dev0"

View File

@@ -167,8 +167,6 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:checkinlist-list'), ('GET', 'api-v1:checkinlist-list'),
('POST', 'api-v1:checkinlistpos-redeem'), ('POST', 'api-v1:checkinlistpos-redeem'),
('POST', 'plugins:pretix_posbackend:order.posprintlog'), ('POST', 'plugins:pretix_posbackend:order.posprintlog'),
('POST', 'plugins:pretix_posbackend:order.poslock'),
('DELETE', 'plugins:pretix_posbackend:order.poslock'),
('DELETE', 'api-v1:cartposition-detail'), ('DELETE', 'api-v1:cartposition-detail'),
('GET', 'api-v1:giftcard-list'), ('GET', 'api-v1:giftcard-list'),
('POST', 'api-v1:giftcard-transact'), ('POST', 'api-v1:giftcard-transact'),
@@ -176,11 +174,8 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('POST', 'plugins:pretix_posbackend:posreceipt-list'), ('POST', 'plugins:pretix_posbackend:posreceipt-list'),
('POST', 'plugins:pretix_posbackend:posclosing-list'), ('POST', 'plugins:pretix_posbackend:posclosing-list'),
('POST', 'plugins:pretix_posbackend:posdebugdump-list'), ('POST', 'plugins:pretix_posbackend:posdebugdump-list'),
('POST', 'plugins:pretix_posbackend:posdebuglogentry-list'),
('POST', 'plugins:pretix_posbackend:posdebuglogentry-bulk-create'),
('GET', 'plugins:pretix_posbackend:poscashier-list'), ('GET', 'plugins:pretix_posbackend:poscashier-list'),
('POST', 'plugins:pretix_posbackend:stripeterminal.token'), ('POST', 'plugins:pretix_posbackend:stripeterminal.token'),
('PUT', 'plugins:pretix_posbackend:file.upload'),
('GET', 'api-v1:revokedsecrets-list'), ('GET', 'api-v1:revokedsecrets-list'),
('GET', 'api-v1:event.settings'), ('GET', 'api-v1:event.settings'),
('GET', 'plugins:pretix_seating:event.event'), ('GET', 'plugins:pretix_seating:event.event'),

View File

@@ -60,7 +60,7 @@ class CheckinListSerializer(I18nAwareModelSerializer):
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {} full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data) full_data.update(data)
for item in full_data.get('limit_products', []): for item in full_data.get('limit_products'):
if event != item.event: if event != item.event:
raise ValidationError(_('One or more items do not belong to this event.')) raise ValidationError(_('One or more items do not belong to this event.'))

View File

@@ -251,12 +251,9 @@ class ItemSerializer(I18nAwareModelSerializer):
bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {} bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {}
meta_data = validated_data.pop('meta_data', None) meta_data = validated_data.pop('meta_data', None)
picture = validated_data.pop('picture', None) picture = validated_data.pop('picture', None)
require_membership_types = validated_data.pop('require_membership_types', [])
item = Item.objects.create(**validated_data) item = Item.objects.create(**validated_data)
if picture: if picture:
item.picture.save(os.path.basename(picture.name), picture) item.picture.save(os.path.basename(picture.name), picture)
if require_membership_types:
item.require_membership_types.add(*require_membership_types)
for variation_data in variations_data: for variation_data in variations_data:
require_membership_types = variation_data.pop('require_membership_types', []) require_membership_types = variation_data.pop('require_membership_types', [])

View File

@@ -424,7 +424,88 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
self.fields.pop('pdf_data', None) self.fields.pop('pdf_data', None)
def validate(self, data): def validate(self, data):
raise TypeError("this serializer is readonly") if data.get('attendee_name') and data.get('attendee_name_parts'):
raise ValidationError(
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
)
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
if data.get('country'):
if not pycountry.countries.get(alpha_2=data.get('country').code):
raise ValidationError(
{'country': ['Invalid country code.']}
)
if data.get('state'):
cc = str(data.get('country') or self.instance.country or '')
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
raise ValidationError(
{'state': ['States are not supported in country "{}".'.format(cc)]}
)
if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')):
raise ValidationError(
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
)
return data
def update(self, instance, validated_data):
# Even though all fields that shouldn't be edited are marked as read_only in the serializer
# (hopefully), we'll be extra careful here and be explicit about the model fields we update.
update_fields = [
'attendee_name_parts', 'company', 'street', 'zipcode', 'city', 'country',
'state', 'attendee_email',
]
answers_data = validated_data.pop('answers', None)
name = validated_data.pop('attendee_name', '')
if name and not validated_data.get('attendee_name_parts'):
validated_data['attendee_name_parts'] = {
'_legacy': name
}
for attr, value in validated_data.items():
if attr in update_fields:
setattr(instance, attr, value)
instance.save(update_fields=update_fields)
if answers_data is not None:
qs_seen = set()
answercache = {
a.question_id: a for a in instance.answers.all()
}
for answ_data in answers_data:
options = answ_data.pop('options', [])
if answ_data['question'].pk in qs_seen:
raise ValidationError(f'Question {answ_data["question"]} was sent twice.')
if answ_data['question'].pk in answercache:
a = answercache[answ_data['question'].pk]
if isinstance(answ_data['answer'], File):
a.file.save(answ_data['answer'].name, answ_data['answer'], save=False)
a.answer = 'file://' + a.file.name
elif a.answer.startswith('file://') and answ_data['answer'] == "file:keep":
pass # keep current file
else:
for attr, value in answ_data.items():
setattr(a, attr, value)
a.save()
else:
if isinstance(answ_data['answer'], File):
an = answ_data.pop('answer')
a = instance.answers.create(**answ_data, answer='')
a.file.save(os.path.basename(an.name), an, save=False)
a.answer = 'file://' + a.file.name
a.save()
else:
a = instance.answers.create(**answ_data)
a.options.set(options)
qs_seen.add(a.question_id)
for qid, a in answercache.items():
if qid not in qs_seen:
a.delete()
return instance
class RequireAttentionField(serializers.Field): class RequireAttentionField(serializers.Field):
@@ -512,7 +593,7 @@ class OrderPaymentDateField(serializers.DateField):
class OrderFeeSerializer(I18nAwareModelSerializer): class OrderFeeSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = OrderFee model = OrderFee
fields = ('id', 'fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', 'canceled') fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', 'canceled')
class PaymentURLField(serializers.URLField): class PaymentURLField(serializers.URLField):
@@ -853,8 +934,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
consume_carts = serializers.ListField(child=serializers.CharField(), required=False) consume_carts = serializers.ListField(child=serializers.CharField(), required=False)
force = serializers.BooleanField(default=False, required=False) force = serializers.BooleanField(default=False, required=False)
payment_date = serializers.DateTimeField(required=False, allow_null=True) payment_date = serializers.DateTimeField(required=False, allow_null=True)
send_email = serializers.BooleanField(default=False, required=False, allow_null=True) send_email = serializers.BooleanField(default=False, required=False)
require_approval = serializers.BooleanField(default=False, required=False)
simulate = serializers.BooleanField(default=False, required=False) simulate = serializers.BooleanField(default=False, required=False)
customer = serializers.SlugRelatedField(slug_field='identifier', queryset=Customer.objects.none(), required=False) customer = serializers.SlugRelatedField(slug_field='identifier', queryset=Customer.objects.none(), required=False)
@@ -867,7 +947,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
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', 'payment_info', 'payment_date', 'consume_carts', 'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts',
'force', 'send_email', 'simulate', 'customer', 'custom_followup_at', 'require_approval') 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at')
def validate_payment_provider(self, pp): def validate_payment_provider(self, pp):
if pp is None: if pp is None:
@@ -961,8 +1041,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
force = validated_data.pop('force', False) force = validated_data.pop('force', False)
simulate = validated_data.pop('simulate', False) simulate = validated_data.pop('simulate', False)
self._send_mail = validated_data.pop('send_email', False) self._send_mail = validated_data.pop('send_email', False)
if self._send_mail is None:
self._send_mail = validated_data.get('sales_channel') in self.context['event'].settings.mail_sales_channel_placed_paid
if 'invoice_address' in validated_data: if 'invoice_address' in validated_data:
iadata = validated_data.pop('invoice_address') iadata = validated_data.pop('invoice_address')
@@ -1141,8 +1219,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
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:
order.require_approval = validated_data['require_approval']
if simulate: if simulate:
order = WrappedModel(order) order = WrappedModel(order)
order.last_modified = now() order.last_modified = now()
@@ -1280,18 +1356,14 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
f.order = order._wrapped if simulate else order f.order = order._wrapped if simulate else order
f._calculate_tax() f._calculate_tax()
fees.append(f) fees.append(f)
if simulate: if not simulate:
f.id = 0
else:
f.save() f.save()
else: else:
f = OrderFee(**fee_data) f = OrderFee(**fee_data)
f.order = order._wrapped if simulate else order f.order = order._wrapped if simulate else order
f._calculate_tax() f._calculate_tax()
fees.append(f) fees.append(f)
if simulate: if not simulate:
f.id = 0
else:
f.save() f.save()
order.total += sum([f.value for f in fees]) order.total += sum([f.value for f in fees])

View File

@@ -1,424 +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 logging
import os
import pycountry
from django.core.files import File
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer, CompatibleCountryField,
OrderPositionCreateSerializer,
)
from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition
from pretix.base.services.orders import OrderError
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
logger = logging.getLogger(__name__)
class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerializer):
order = serializers.SlugRelatedField(slug_field='code', queryset=Order.objects.none(), required=True, allow_null=False)
answers = AnswerCreateSerializer(many=True, required=False)
addon_to = serializers.IntegerField(required=False, allow_null=True)
secret = serializers.CharField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
price = serializers.DecimalField(required=False, allow_null=True, decimal_places=2,
max_digits=10)
country = CompatibleCountryField(source='*')
class Meta:
model = OrderPosition
fields = ('order', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'company', 'street', 'zipcode', 'city', 'country', 'state',
'secret', 'addon_to', 'subevent', 'answers', 'seat')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.context:
return
self.fields['order'].queryset = self.context['event'].orders.all()
self.fields['item'].queryset = self.context['event'].items.all()
self.fields['subevent'].queryset = self.context['event'].subevents.all()
self.fields['seat'].queryset = self.context['event'].seats.all()
self.fields['variation'].queryset = ItemVariation.objects.filter(item__event=self.context['event'])
if 'order' in self.context:
del self.fields['order']
def validate(self, data):
data = super().validate(data)
if data.get('addon_to'):
try:
data['addon_to'] = data['order'].positions.get(positionid=data['addon_to'])
except OrderPosition.DoesNotExist:
raise ValidationError({
'addon_to': ['addon_to refers to an unknown position ID for this order.']
})
return data
def create(self, validated_data):
ocm = self.context['ocm']
try:
ocm.add_position(
item=validated_data['item'],
variation=validated_data.get('variation'),
price=validated_data.get('price'),
addon_to=validated_data.get('addon_to'),
subevent=validated_data.get('subevent'),
seat=validated_data.get('seat'),
)
if self.context.get('commit', True):
ocm.commit()
return validated_data['order'].positions.order_by('-positionid').first()
else:
return OrderPosition() # fake to appease DRF
except OrderError as e:
raise ValidationError(str(e))
class OrderPositionInfoPatchSerializer(serializers.ModelSerializer):
answers = AnswerSerializer(many=True)
country = CompatibleCountryField(source='*')
attendee_name = serializers.CharField(required=False)
class Meta:
model = OrderPosition
fields = (
'attendee_name', 'attendee_name_parts', 'company', 'street', 'zipcode', 'city', 'country',
'state', 'attendee_email', 'answers',
)
def validate(self, data):
if data.get('attendee_name') and data.get('attendee_name_parts'):
raise ValidationError(
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
)
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
if data.get('country'):
if not pycountry.countries.get(alpha_2=data.get('country').code):
raise ValidationError(
{'country': ['Invalid country code.']}
)
if data.get('state'):
cc = str(data.get('country') or self.instance.country or '')
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
raise ValidationError(
{'state': ['States are not supported in country "{}".'.format(cc)]}
)
if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')):
raise ValidationError(
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
)
return data
def update(self, instance, validated_data):
answers_data = validated_data.pop('answers', None)
name = validated_data.pop('attendee_name', '')
if name and not validated_data.get('attendee_name_parts'):
validated_data['attendee_name_parts'] = {
'_legacy': name
}
for attr, value in validated_data.items():
if attr in self.fields:
setattr(instance, attr, value)
instance.save(update_fields=list(validated_data.keys()))
if answers_data is not None:
qs_seen = set()
answercache = {
a.question_id: a for a in instance.answers.all()
}
for answ_data in answers_data:
options = answ_data.pop('options', [])
if answ_data['question'].pk in qs_seen:
raise ValidationError(f'Question {answ_data["question"]} was sent twice.')
if answ_data['question'].pk in answercache:
a = answercache[answ_data['question'].pk]
if isinstance(answ_data['answer'], File):
a.file.save(answ_data['answer'].name, answ_data['answer'], save=False)
a.answer = 'file://' + a.file.name
elif a.answer.startswith('file://') and answ_data['answer'] == "file:keep":
pass # keep current file
else:
for attr, value in answ_data.items():
setattr(a, attr, value)
a.save()
else:
if isinstance(answ_data['answer'], File):
an = answ_data.pop('answer')
a = instance.answers.create(**answ_data, answer='')
a.file.save(os.path.basename(an.name), an, save=False)
a.answer = 'file://' + a.file.name
a.save()
else:
a = instance.answers.create(**answ_data)
a.options.set(options)
qs_seen.add(a.question_id)
for qid, a in answercache.items():
if qid not in qs_seen:
a.delete()
return instance
class OrderPositionChangeSerializer(serializers.ModelSerializer):
seat = serializers.CharField(source='seat.seat_guid', allow_null=True, required=False)
class Meta:
model = OrderPosition
fields = (
'item', 'variation', 'subevent', 'seat', 'price', 'tax_rule',
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.context:
return
self.fields['item'].queryset = self.context['event'].items.all()
self.fields['subevent'].queryset = self.context['event'].subevents.all()
self.fields['tax_rule'].queryset = self.context['event'].tax_rules.all()
if kwargs.get('partial'):
for k, v in self.fields.items():
self.fields[k].required = False
def validate_item(self, item):
if item.event != self.context['event']:
raise ValidationError(
'The specified item does not belong to this event.'
)
return item
def validate_subevent(self, subevent):
if self.context['event'].has_subevents:
if not subevent:
raise ValidationError(
'You need to set a subevent.'
)
if subevent.event != self.context['event']:
raise ValidationError(
'The specified subevent does not belong to this event.'
)
elif subevent:
raise ValidationError(
'You cannot set a subevent for this event.'
)
return subevent
def validate(self, data, instance=None):
instance = instance or self.instance
if instance is None:
return data # needs to be done later
if data.get('item', instance.item):
if data.get('item', instance.item).has_variations:
if not data.get('variation', instance.variation):
raise ValidationError({'variation': ['You should specify a variation for this item.']})
else:
if data.get('variation', instance.variation).item != data.get('item', instance.item):
raise ValidationError(
{'variation': ['The specified variation does not belong to the specified item.']}
)
elif data.get('variation', instance.variation):
raise ValidationError(
{'variation': ['You cannot specify a variation for this item.']}
)
return data
def update(self, instance, validated_data):
ocm = self.context['ocm']
current_seat = {'seat_guid': instance.seat.seat_guid} if instance.seat else None
item = validated_data.get('item', instance.item)
variation = validated_data.get('variation', instance.variation)
subevent = validated_data.get('subevent', instance.subevent)
price = validated_data.get('price', instance.price)
seat = validated_data.get('seat', current_seat)
tax_rule = validated_data.get('tax_rule', instance.tax_rule)
change_item = None
if item != instance.item or variation != instance.variation:
change_item = (item, variation)
change_subevent = None
if self.context['event'].has_subevents and subevent != instance.subevent:
change_subevent = (subevent,)
try:
if change_item is not None and change_subevent is not None:
ocm.change_item_and_subevent(instance, *change_item, *change_subevent)
elif change_item is not None:
ocm.change_item(instance, *change_item)
elif change_subevent is not None:
ocm.change_subevent(instance, *change_subevent)
if seat != current_seat or change_subevent:
ocm.change_seat(instance, seat['seat_guid'] if seat else None)
if price != instance.price:
ocm.change_price(instance, price)
if tax_rule != instance.tax_rule:
ocm.change_tax_rule(instance, tax_rule)
if self.context.get('commit', True):
ocm.commit()
instance.refresh_from_db()
except OrderError as e:
raise ValidationError(str(e))
return instance
class PatchPositionSerializer(serializers.Serializer):
position = serializers.PrimaryKeyRelatedField(queryset=OrderPosition.all.none())
def validate_position(self, value):
self.fields['body'].instance = value # hack around DRFs validation order
return value
def validate(self, data):
OrderPositionChangeSerializer(context=self.context, partial=True).validate(data['body'], data['position'])
return data
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['position'].queryset = self.context['order'].positions.all()
self.fields['body'] = OrderPositionChangeSerializer(context=self.context, partial=True)
class SelectPositionSerializer(serializers.Serializer):
position = serializers.PrimaryKeyRelatedField(queryset=OrderPosition.all.none())
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['position'].queryset = self.context['order'].positions.all()
class OrderFeeChangeSerializer(serializers.ModelSerializer):
class Meta:
model = OrderFee
fields = (
'value',
)
def update(self, instance, validated_data):
ocm = self.context['ocm']
value = validated_data.get('value', instance.value)
try:
if value != instance.value:
ocm.change_fee(instance, value)
if self.context.get('commit', True):
ocm.commit()
instance.refresh_from_db()
except OrderError as e:
raise ValidationError(str(e))
return instance
class PatchFeeSerializer(serializers.Serializer):
fee = serializers.PrimaryKeyRelatedField(queryset=OrderFee.all.none())
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['fee'].queryset = self.context['order'].fees.all()
self.fields['body'] = OrderFeeChangeSerializer(context=self.context)
class SelectFeeSerializer(serializers.Serializer):
fee = serializers.PrimaryKeyRelatedField(queryset=OrderFee.all.none())
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.context:
return
self.fields['fee'].queryset = self.context['order'].fees.all()
class OrderChangeOperationSerializer(serializers.Serializer):
send_email = serializers.BooleanField(default=False, required=False)
reissue_invoice = serializers.BooleanField(default=True, required=False)
recalculate_taxes = serializers.ChoiceField(default=None, allow_null=True, required=False, choices=[
('keep_net', 'keep_net'),
('keep_gross', 'keep_gross'),
])
def __init__(self, *args, **kwargs):
super().__init__(self, *args, **kwargs)
self.fields['patch_positions'] = PatchPositionSerializer(
many=True, required=False, context=self.context
)
self.fields['cancel_positions'] = SelectPositionSerializer(
many=True, required=False, context=self.context
)
self.fields['create_positions'] = OrderPositionCreateForExistingOrderSerializer(
many=True, required=False, context=self.context
)
self.fields['split_positions'] = SelectPositionSerializer(
many=True, required=False, context=self.context
)
self.fields['patch_fees'] = PatchFeeSerializer(
many=True, required=False, context=self.context
)
self.fields['cancel_fees'] = SelectFeeSerializer(
many=True, required=False, context=self.context
)
def validate(self, data):
seen_positions = set()
for d in data.get('patch_positions', []):
print(d, seen_positions)
if d['position'] in seen_positions:
raise ValidationError({'patch_positions': ['You have specified the same object twice.']})
seen_positions.add(d['position'])
seen_positions = set()
for d in data.get('cancel_positions', []):
if d['position'] in seen_positions:
raise ValidationError({'cancel_positions': ['You have specified the same object twice.']})
seen_positions.add(d['position'])
seen_positions = set()
for d in data.get('split_positions', []):
if d['position'] in seen_positions:
raise ValidationError({'split_positions': ['You have specified the same object twice.']})
seen_positions.add(d['position'])
seen_fees = set()
for d in data.get('patch_fees', []):
if d['fee'] in seen_fees:
raise ValidationError({'patch_fees': ['You have specified the same object twice.']})
seen_positions.add(d['fee'])
seen_fees = set()
for d in data.get('cancel_fees', []):
if d['fee'] in seen_fees:
raise ValidationError({'cancel_fees': ['You have specified the same object twice.']})
seen_positions.add(d['fee'])
return data

View File

@@ -42,7 +42,6 @@ class InitializationRequestSerializer(serializers.Serializer):
hardware_model = serializers.CharField(max_length=190) hardware_model = serializers.CharField(max_length=190)
software_brand = serializers.CharField(max_length=190) software_brand = serializers.CharField(max_length=190)
software_version = serializers.CharField(max_length=190) software_version = serializers.CharField(max_length=190)
info = serializers.JSONField(required=False, allow_null=True)
class UpdateRequestSerializer(serializers.Serializer): class UpdateRequestSerializer(serializers.Serializer):
@@ -50,7 +49,6 @@ class UpdateRequestSerializer(serializers.Serializer):
hardware_model = serializers.CharField(max_length=190) hardware_model = serializers.CharField(max_length=190)
software_brand = serializers.CharField(max_length=190) software_brand = serializers.CharField(max_length=190)
software_version = serializers.CharField(max_length=190) software_version = serializers.CharField(max_length=190)
info = serializers.JSONField(required=False, allow_null=True)
class GateSerializer(serializers.ModelSerializer): class GateSerializer(serializers.ModelSerializer):
@@ -96,7 +94,6 @@ class InitializeView(APIView):
device.hardware_model = serializer.validated_data.get('hardware_model') device.hardware_model = serializer.validated_data.get('hardware_model')
device.software_brand = serializer.validated_data.get('software_brand') device.software_brand = serializer.validated_data.get('software_brand')
device.software_version = serializer.validated_data.get('software_version') device.software_version = serializer.validated_data.get('software_version')
device.info = serializer.validated_data.get('info')
device.api_token = generate_api_token() device.api_token = generate_api_token()
device.save() device.save()
@@ -117,7 +114,6 @@ class UpdateView(APIView):
device.hardware_model = serializer.validated_data.get('hardware_model') device.hardware_model = serializer.validated_data.get('hardware_model')
device.software_brand = serializer.validated_data.get('software_brand') device.software_brand = serializer.validated_data.get('software_brand')
device.software_version = serializer.validated_data.get('software_version') device.software_version = serializer.validated_data.get('software_version')
device.info = serializer.validated_data.get('info')
device.save() device.save()
device.log_action('pretix.device.updated', data=serializer.validated_data, auth=device) device.log_action('pretix.device.updated', data=serializer.validated_data, auth=device)

View File

@@ -132,7 +132,7 @@ class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
def exporters(self): def exporters(self):
exporters = [] exporters = []
responses = register_data_exporters.send(self.request.event) responses = register_data_exporters.send(self.request.event)
for ex in sorted([response(self.request.event, self.request.organizer) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)): for ex in sorted([response(self.request.event, self.request.organizer) for r, response in responses], key=lambda ex: str(ex.verbose_name)):
ex._serializer = JobRunSerializer(exporter=ex) ex._serializer = JobRunSerializer(exporter=ex)
exporters.append(ex) exporters.append(ex)
return exporters return exporters
@@ -147,11 +147,7 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
@cached_property @cached_property
def exporters(self): def exporters(self):
exporters = [] exporters = []
if isinstance(self.request.auth, (Device, TeamAPIToken)): events = (self.request.auth or self.request.user).get_events_with_permission('can_view_orders', request=self.request).filter(
perm_holder = self.request.auth
else:
perm_holder = self.request.user
events = perm_holder.get_events_with_permission('can_view_orders', request=self.request).filter(
organizer=self.request.organizer organizer=self.request.organizer
) )
responses = register_multievent_data_exporters.send(self.request.organizer) responses = register_multievent_data_exporters.send(self.request.organizer)
@@ -161,12 +157,8 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
return exporters return exporters
def get_serializer_kwargs(self): def get_serializer_kwargs(self):
if isinstance(self.request.auth, (Device, TeamAPIToken)):
perm_holder = self.request.auth
else:
perm_holder = self.request.user
return { return {
'events': perm_holder.get_events_with_permission('can_view_orders', request=self.request).filter( 'events': self.request.auth.get_events_with_permission('can_view_orders', request=self.request).filter(
organizer=self.request.organizer organizer=self.request.organizer
) )
} }

View File

@@ -36,7 +36,7 @@ from django.utils.translation import gettext as _
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 PIL import Image from PIL import Image
from rest_framework import serializers, status, viewsets from rest_framework import mixins, serializers, status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import ( from rest_framework.exceptions import (
APIException, NotFound, PermissionDenied, ValidationError, APIException, NotFound, PermissionDenied, ValidationError,
@@ -53,12 +53,6 @@ from pretix.api.serializers.order import (
PriceCalcSerializer, RevokedTicketSecretSerializer, PriceCalcSerializer, RevokedTicketSecretSerializer,
SimulatedOrderSerializer, SimulatedOrderSerializer,
) )
from pretix.api.serializers.orderchange import (
OrderChangeOperationSerializer, OrderFeeChangeSerializer,
OrderPositionChangeSerializer,
OrderPositionCreateForExistingOrderSerializer,
OrderPositionInfoPatchSerializer,
)
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Checkin, Device, Event, Invoice, CachedCombinedTicket, CachedTicket, Checkin, Device, Event, Invoice,
@@ -150,8 +144,7 @@ with scopes_disabled():
matching_positions = OrderPosition.objects.filter( matching_positions = OrderPosition.objects.filter(
Q(order=OuterRef('pk')) & Q( Q(order=OuterRef('pk')) & Q(
Q(attendee_name_cached__icontains=u) | Q(attendee_email__icontains=u) Q(attendee_name_cached__icontains=u) | Q(attendee_email__icontains=u)
| Q(secret__istartswith=u) | Q(secret__istartswith=u) | Q(voucher__code__icontains=u)
# | Q(voucher__code__icontains=u) # temporarily removed since it caused bad query performance on postgres
) )
).values('id') ).values('id')
@@ -345,7 +338,6 @@ class OrderViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def mark_canceled(self, request, **kwargs): def mark_canceled(self, request, **kwargs):
send_mail = request.data.get('send_email', True) send_mail = request.data.get('send_email', True)
comment = request.data.get('comment', None)
cancellation_fee = request.data.get('cancellation_fee', None) cancellation_fee = request.data.get('cancellation_fee', None)
if cancellation_fee: if cancellation_fee:
try: try:
@@ -368,7 +360,6 @@ class OrderViewSet(viewsets.ModelViewSet):
device=request.auth if isinstance(request.auth, Device) else None, device=request.auth if isinstance(request.auth, Device) else None,
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None, oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
send_mail=send_mail, send_mail=send_mail,
email_comment=comment,
cancellation_fee=cancellation_fee cancellation_fee=cancellation_fee
) )
except OrderError as e: except OrderError as e:
@@ -653,13 +644,9 @@ class OrderViewSet(viewsets.ModelViewSet):
if send_mail: if send_mail:
free_flow = ( free_flow = (
payment and order.total == Decimal('0.00') and order.status == Order.STATUS_PAID and payment and order.total == Decimal('0.00') and order.status == Order.STATUS_PAID and
not order.require_approval and payment.provider in ("free", "boxoffice") not order.require_approval and payment.provider == "free"
) )
if order.require_approval: if free_flow:
email_template = request.event.settings.mail_text_order_placed_require_approval
log_entry = 'pretix.event.order.email.order_placed_require_approval'
email_attendees = False
elif free_flow:
email_template = request.event.settings.mail_text_order_free email_template = request.event.settings.mail_text_order_free
log_entry = 'pretix.event.order.email.order_free' log_entry = 'pretix.event.order.email.order_free'
email_attendees = request.event.settings.mail_send_order_free_attendee email_attendees = request.event.settings.mail_send_order_free_attendee
@@ -791,79 +778,6 @@ class OrderViewSet(viewsets.ModelViewSet):
with transaction.atomic(): with transaction.atomic():
self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth) self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
@action(detail=True, methods=['POST'])
def change(self, request, **kwargs):
order = self.get_object()
serializer = OrderChangeOperationSerializer(
context={'order': order, **self.get_serializer_context()},
data=request.data,
)
serializer.is_valid(raise_exception=True)
try:
ocm = OrderChangeManager(
order=order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=request.auth,
notify=serializer.validated_data.get('send_email', False),
reissue_invoice=serializer.validated_data.get('reissue_invoice', True),
)
canceled_positions = set()
for r in serializer.validated_data.get('cancel_positions', []):
ocm.cancel(r['position'])
canceled_positions.add(r['position'])
for r in serializer.validated_data.get('patch_positions', []):
if r['position'] in canceled_positions:
continue
pos_serializer = OrderPositionChangeSerializer(
context={'ocm': ocm, 'commit': False, 'event': request.event, **self.get_serializer_context()},
partial=True,
)
pos_serializer.update(r['position'], r['body'])
for r in serializer.validated_data.get('split_positions', []):
if r['position'] in canceled_positions:
continue
ocm.split(r['position'])
for r in serializer.validated_data.get('create_positions', []):
pos_serializer = OrderPositionCreateForExistingOrderSerializer(
context={'ocm': ocm, 'commit': False, 'event': request.event, **self.get_serializer_context()},
)
pos_serializer.create(r)
canceled_fees = set()
for r in serializer.validated_data.get('cancel_fees', []):
ocm.cancel_fee(r['fee'])
canceled_fees.add(r['fee'])
for r in serializer.validated_data.get('patch_fees', []):
if r['fee'] in canceled_fees:
continue
pos_serializer = OrderFeeChangeSerializer(
context={'ocm': ocm, 'commit': False, 'event': request.event, **self.get_serializer_context()},
)
pos_serializer.update(r['fee'], r['body'])
if serializer.validated_data.get('recalculate_taxes') == 'keep_net':
ocm.recalculate_taxes(keep='net')
elif serializer.validated_data.get('recalculate_taxes') == 'keep_gross':
ocm.recalculate_taxes(keep='gross')
ocm.commit()
except OrderError as e:
raise ValidationError(str(e))
order.refresh_from_db()
serializer = OrderSerializer(
instance=order,
context=self.get_serializer_context(),
)
return Response(serializer.data)
with scopes_disabled(): with scopes_disabled():
class OrderPositionFilter(FilterSet): class OrderPositionFilter(FilterSet):
@@ -905,7 +819,7 @@ with scopes_disabled():
} }
class OrderPositionViewSet(viewsets.ModelViewSet): class OrderPositionViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPositionSerializer serializer_class = OrderPositionSerializer
queryset = OrderPosition.all.none() queryset = OrderPosition.all.none()
filter_backends = (DjangoFilterBackend, OrderingFilter) filter_backends = (DjangoFilterBackend, OrderingFilter)
@@ -1142,25 +1056,6 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
) )
return resp return resp
@action(detail=True, methods=['POST'])
def regenerate_secrets(self, request, **kwargs):
instance = self.get_object()
try:
ocm = OrderChangeManager(
instance.order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth,
notify=False,
reissue_invoice=False,
)
ocm.regenerate_secret(instance)
ocm.commit()
except OrderError as e:
raise ValidationError(str(e))
except Quota.QuotaExceededException as e:
raise ValidationError(str(e))
return self.retrieve(request, [], **kwargs)
def perform_destroy(self, instance): def perform_destroy(self, instance):
try: try:
ocm = OrderChangeManager( ocm = OrderChangeManager(
@@ -1176,63 +1071,6 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
except Quota.QuotaExceededException as e: except Quota.QuotaExceededException as e:
raise ValidationError(str(e)) raise ValidationError(str(e))
def create(self, request, *args, **kwargs):
with transaction.atomic():
serializer = OrderPositionCreateForExistingOrderSerializer(
data=request.data,
context=self.get_serializer_context(),
)
serializer.is_valid(raise_exception=True)
order = serializer.validated_data['order']
ocm = OrderChangeManager(
order=order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=request.auth,
notify=False,
reissue_invoice=False,
)
serializer.context['ocm'] = ocm
serializer.save()
# Fields that can be easily patched after the position was added
old_data = OrderPositionInfoPatchSerializer(instance=serializer.instance, context=self.get_serializer_context()).data
serializer = OrderPositionInfoPatchSerializer(
instance=serializer.instance,
context=self.get_serializer_context(),
partial=True,
data=request.data
)
serializer.is_valid(raise_exception=True)
serializer.save()
new_data = serializer.data
if old_data != new_data:
log_data = self.request.data
if 'answers' in log_data:
for a in new_data['answers']:
log_data[f'question_{a["question"]}'] = a["answer"]
log_data.pop('answers', None)
serializer.instance.order.log_action(
'pretix.event.order.modified',
user=self.request.user,
auth=self.request.auth,
data={
'data': [
dict(
position=serializer.instance.pk,
**log_data
)
]
}
)
tickets.invalidate_cache.apply_async(
kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk})
order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order)
return Response(
OrderPositionSerializer(serializer.instance, context=self.get_serializer_context()).data,
status=status.HTTP_201_CREATED,
)
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
partial = kwargs.get('partial', False) partial = kwargs.get('partial', False)
if not partial: if not partial:
@@ -1240,36 +1078,11 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
{"detail": "Method \"PUT\" not allowed."}, {"detail": "Method \"PUT\" not allowed."},
status=status.HTTP_405_METHOD_NOT_ALLOWED, status=status.HTTP_405_METHOD_NOT_ALLOWED,
) )
return super().update(request, *args, **kwargs)
def perform_update(self, serializer):
with transaction.atomic(): with transaction.atomic():
instance = self.get_object() old_data = self.get_serializer_class()(instance=serializer.instance, context=self.get_serializer_context()).data
ocm = OrderChangeManager(
order=instance.order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=request.auth,
notify=False,
reissue_invoice=False,
)
# Field that need to go through OrderChangeManager
serializer = OrderPositionChangeSerializer(
instance=instance,
context={'ocm': ocm, **self.get_serializer_context()},
partial=True,
data=request.data
)
serializer.is_valid(raise_exception=True)
serializer.save()
# Fields that can be easily patched
old_data = OrderPositionInfoPatchSerializer(instance=instance, context=self.get_serializer_context()).data
serializer = OrderPositionInfoPatchSerializer(
instance=instance,
context=self.get_serializer_context(),
partial=True,
data=request.data
)
serializer.is_valid(raise_exception=True)
serializer.save() serializer.save()
new_data = serializer.data new_data = serializer.data
@@ -1292,10 +1105,9 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
] ]
} }
) )
tickets.invalidate_cache.apply_async(kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk})
order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order)
return Response(self.get_serializer_class()(instance=serializer.instance, context=self.get_serializer_context()).data) tickets.invalidate_cache.apply_async(kwargs={'event': serializer.instance.order.event.pk, 'order': serializer.instance.order.pk})
order_modified.send(sender=serializer.instance.order.event, order=serializer.instance.order)
class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet): class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):

View File

@@ -94,9 +94,6 @@ class BaseAuthBackend:
This method will be called after the user filled in the login form. ``request`` will contain This method will be called after the user filled in the login form. ``request`` will contain
the current request and ``form_data`` the input for the form fields defined in ``login_form_fields``. the current request and ``form_data`` the input for the form fields defined in ``login_form_fields``.
You are expected to either return a ``User`` object (if login was successful) or ``None``. You are expected to either return a ``User`` object (if login was successful) or ``None``.
You are expected to either return a ``User`` object (if login was successful) or ``None``. You should
obtain this user object using ``User.objects.get_or_create_for_backend``.
""" """
return return
@@ -107,9 +104,7 @@ class BaseAuthBackend:
reverse proxy, you can directly return a ``User`` object that will be logged in. reverse proxy, you can directly return a ``User`` object that will be logged in.
``request`` will contain the current request. ``request`` will contain the current request.
You are expected to either return a ``User`` object (if login was successful) or ``None``.
You are expected to either return a ``User`` object (if login was successful) or ``None``. You should
obtain this user object using ``User.objects.get_or_create_for_backend``.
""" """
return return

View File

@@ -177,7 +177,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
op.variation, op.variation,
op.subevent, op.subevent,
op.attendee_name, op.attendee_name,
op.addon_to_id, (op.pk if op.addon_to_id else None),
(op.pk if op.has_addons else None) (op.pk if op.has_addons else None)
) )
)] )]
@@ -310,11 +310,7 @@ def get_email_context(**kwargs):
val = [val] val = [val]
for v in val: for v in val:
if all(rp in kwargs for rp in v.required_context): if all(rp in kwargs for rp in v.required_context):
try: ctx[v.identifier] = v.render(kwargs)
ctx[v.identifier] = v.render(kwargs)
except:
ctx[v.identifier] = '(error)'
logger.exception(f'Failed to process email placeholder {v.identifier}.')
return ctx return ctx

View File

@@ -33,6 +33,7 @@
# 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 io import io
import re
import tempfile import tempfile
from collections import OrderedDict, namedtuple from collections import OrderedDict, namedtuple
from decimal import Decimal from decimal import Decimal
@@ -45,13 +46,26 @@ from django.conf import settings
from django.db.models import QuerySet from django.db.models import QuerySet
from django.utils.formats import localize from django.utils.formats import localize
from django.utils.translation import gettext, gettext_lazy as _ from django.utils.translation import gettext, gettext_lazy as _
from openpyxl import Workbook
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE, KNOWN_TYPES, Cell
from pretix.base.models import Event from pretix.base.models import Event
from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for plugins using excel_safe
SafeWorkbook, remove_invalid_excel_chars as excel_safe,
)
__ = excel_safe # just so the compatbility import above is "used" and doesn't get removed by linter
def excel_safe(val):
if isinstance(val, Cell):
return val
if not isinstance(val, KNOWN_TYPES):
val = str(val)
if isinstance(val, bytes):
val = val.decode("utf-8", errors="ignore")
if isinstance(val, str):
val = re.sub(ILLEGAL_CHARACTERS_RE, '', val)
return val
class BaseExporter: class BaseExporter:
@@ -214,7 +228,7 @@ class ListExporter(BaseExporter):
pass pass
def _render_xlsx(self, form_data, output_file=None): def _render_xlsx(self, form_data, output_file=None):
wb = SafeWorkbook(write_only=True) wb = Workbook(write_only=True)
ws = wb.create_sheet() ws = wb.create_sheet()
self.prepare_xlsx_sheet(ws) self.prepare_xlsx_sheet(ws)
try: try:
@@ -228,7 +242,7 @@ class ListExporter(BaseExporter):
total = line.total total = line.total
continue continue
ws.append([ ws.append([
val for val in line excel_safe(val) for val in line
]) ])
if total: if total:
counter += 1 counter += 1
@@ -333,7 +347,7 @@ class MultiSheetListExporter(ListExporter):
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8") return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
def _render_xlsx(self, form_data, output_file=None): def _render_xlsx(self, form_data, output_file=None):
wb = SafeWorkbook(write_only=True) wb = Workbook(write_only=True)
n_sheets = len(self.sheets) n_sheets = len(self.sheets)
for i_sheet, (s, l) in enumerate(self.sheets): for i_sheet, (s, l) in enumerate(self.sheets):
ws = wb.create_sheet(str(l)) ws = wb.create_sheet(str(l))
@@ -347,7 +361,8 @@ class MultiSheetListExporter(ListExporter):
total = line.total total = line.total
continue continue
ws.append([ ws.append([
val for val in line excel_safe(val)
for val in line
]) ])
if total: if total:
counter += 1 counter += 1

View File

@@ -21,7 +21,6 @@
# #
from .answers import * # noqa from .answers import * # noqa
from .dekodi import * # noqa from .dekodi import * # noqa
from .events import * # noqa
from .invoices import * # noqa from .invoices import * # noqa
from .json import * # noqa from .json import * # noqa
from .mail import * # noqa from .mail import * # noqa

View File

@@ -1,100 +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/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Tobias Kunze
#
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from ...control.forms.filter import get_all_payment_providers
from ..exporter import ListExporter
from ..signals import register_multievent_data_exporters
class EventDataExporter(ListExporter):
identifier = 'eventdata'
verbose_name = _('Event data')
@cached_property
def providers(self):
return dict(get_all_payment_providers())
def iterate_list(self, form_data):
header = [
_("Event name"),
_("Short form"),
_("Shop is live"),
_("Event currency"),
_("Event start time"),
_("Event end time"),
_("Admission time"),
_("Start of presale"),
_("End of presale"),
_("Location"),
_("Latitude"),
_("Longitude"),
_("Internal comment"),
]
props = list(self.organizer.meta_properties.all())
for p in props:
header.append(p.name)
yield header
for e in self.events.all():
m = e.meta_data
yield [
str(e.name),
e.slug,
_('Yes') if e.live else _('No'),
e.currency,
date_format(e.date_from, 'SHORT_DATETIME_FORMAT'),
date_format(e.date_to, 'SHORT_DATETIME_FORMAT') if e.date_to else '',
date_format(e.date_admission, 'SHORT_DATETIME_FORMAT') if e.date_admission else '',
date_format(e.presale_start, 'SHORT_DATETIME_FORMAT') if e.presale_start else '',
date_format(e.presale_end, 'SHORT_DATETIME_FORMAT') if e.presale_end else '',
str(e.location),
e.geo_lat or '',
e.geo_lon or '',
e.comment,
] + [
m.get(p.name, '') for p in props
]
def get_filename(self):
return '{}_events'.format(self.events.first().organizer.slug)
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_eventdata")
def register_multievent_eventdata_exporter(sender, **kwargs):
return EventDataExporter

View File

@@ -573,7 +573,6 @@ class OrderListExporter(MultiSheetListExporter):
pgettext('address', 'State'), pgettext('address', 'State'),
_('Voucher'), _('Voucher'),
_('Pseudonymization ID'), _('Pseudonymization ID'),
_('Ticket secret'),
_('Seat ID'), _('Seat ID'),
_('Seat name'), _('Seat name'),
_('Seat zone'), _('Seat zone'),
@@ -670,7 +669,6 @@ class OrderListExporter(MultiSheetListExporter):
op.state or '', op.state or '',
op.voucher.code if op.voucher else '', op.voucher.code if op.voucher else '',
op.pseudonymization_id, op.pseudonymization_id,
op.secret,
] ]
if op.seat: if op.seat:

View File

@@ -38,7 +38,6 @@ import i18nfield.forms
from django import forms from django import forms
from django.forms.models import ModelFormMetaclass from django.forms.models import ModelFormMetaclass
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.translation import gettext_lazy as _
from formtools.wizard.views import SessionWizardView from formtools.wizard.views import SessionWizardView
from hierarkey.forms import HierarkeyForm from hierarkey.forms import HierarkeyForm
@@ -113,13 +112,10 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
if isinstance(f, (RelativeDateTimeField, RelativeDateField)): if isinstance(f, (RelativeDateTimeField, RelativeDateField)):
f.set_event(self.obj) f.set_event(self.obj)
def _unmask_secret_fields(self): def save(self):
for k, v in self.cleaned_data.items(): for k, v in self.cleaned_data.items():
if isinstance(self.fields.get(k), SecretKeySettingsField) and self.cleaned_data.get(k) == SECRET_REDACTED: if isinstance(self.fields.get(k), SecretKeySettingsField) and self.cleaned_data.get(k) == SECRET_REDACTED:
self.cleaned_data[k] = self.initial[k] self.cleaned_data[k] = self.initial[k]
def save(self):
self._unmask_secret_fields()
return super().save() return super().save()
def clean(self): def clean(self):
@@ -132,12 +128,6 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
# at all, it will be considered a changed value and stored. We do not want that, as it makes it very hard to add # at all, it will be considered a changed value and stored. We do not want that, as it makes it very hard to add
# languages to an organizer/event later on. So we trick it and make sure nothing gets changed in that situation. # languages to an organizer/event later on. So we trick it and make sure nothing gets changed in that situation.
for name, field in self.fields.items(): for name, field in self.fields.items():
if isinstance(field, SecretKeySettingsField) and d.get(name) == SECRET_REDACTED and not self.initial.get(name):
self.add_error(
name,
_('Due to technical reasons you cannot set inputs, that need to be masked (e.g. passwords), to %(value)s.') % {'value': SECRET_REDACTED}
)
if isinstance(field, i18nfield.forms.I18nFormField): if isinstance(field, i18nfield.forms.I18nFormField):
value = d.get(name) value = d.get(name)
if not value: if not value:

View File

@@ -41,16 +41,16 @@ from io import BytesIO
import dateutil.parser import dateutil.parser
import pycountry import pycountry
import pytz import pytz
from babel import Locale
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.validators import ( from django.core.validators import MaxValueValidator, MinValueValidator
MaxValueValidator, MinValueValidator, RegexValidator,
)
from django.db.models import QuerySet from django.db.models import QuerySet
from django.forms import Select, widgets from django.forms import Select, widgets
from django.utils import translation
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@@ -85,9 +85,7 @@ from pretix.base.templatetags.rich_text import rich_text
from pretix.control.forms import ( from pretix.control.forms import (
ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField, ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField,
) )
from pretix.helpers.countries import ( from pretix.helpers.countries import CachedCountries
CachedCountries, get_phone_prefixes_sorted_and_localized,
)
from pretix.helpers.escapejson import escapejson_attr from pretix.helpers.escapejson import escapejson_attr
from pretix.helpers.i18n import get_format_without_seconds from pretix.helpers.i18n import get_format_without_seconds
from pretix.presale.signals import question_form_fields from pretix.presale.signals import question_form_fields
@@ -189,15 +187,6 @@ class NamePartsFormField(forms.MultiValueField):
defaults = { defaults = {
'widget': self.widget, 'widget': self.widget,
'max_length': kwargs.pop('max_length', None), 'max_length': kwargs.pop('max_length', None),
'validators': [
RegexValidator(
# The following characters should never appear in a name anywhere of
# the world. However, they commonly appear in inputs generated by spam
# bots.
r'^[^$€/%§{}<>~]*$',
message=_('Please do not use special characters in names.')
)
]
} }
self.scheme_name = kwargs.pop('scheme') self.scheme_name = kwargs.pop('scheme')
self.titles = kwargs.pop('titles') self.titles = kwargs.pop('titles')
@@ -218,7 +207,6 @@ class NamePartsFormField(forms.MultiValueField):
if fname == 'title' and self.scheme_titles: if fname == 'title' and self.scheme_titles:
d = dict(defaults) d = dict(defaults)
d.pop('max_length', None) d.pop('max_length', None)
d.pop('validators', None)
field = forms.ChoiceField( field = forms.ChoiceField(
**d, **d,
choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]] choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]]
@@ -227,7 +215,6 @@ class NamePartsFormField(forms.MultiValueField):
elif fname == 'salutation': elif fname == 'salutation':
d = dict(defaults) d = dict(defaults)
d.pop('max_length', None) d.pop('max_length', None)
d.pop('validators', None)
field = forms.ChoiceField( field = forms.ChoiceField(
**d, **d,
choices=[('', '---')] + PERSON_NAME_SALUTATIONS choices=[('', '---')] + PERSON_NAME_SALUTATIONS
@@ -264,14 +251,17 @@ class WrappedPhonePrefixSelect(Select):
def __init__(self, initial=None): def __init__(self, initial=None):
choices = [("", "---------")] choices = [("", "---------")]
language = get_babel_locale() # changed from default implementation that used the django locale
if initial: locale = Locale(translation.to_locale(language))
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items(): for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
if initial in values: prefix = "+%d" % prefix
self.initial = "+%d" % prefix if initial and initial in values:
break self.initial = prefix
choices += get_phone_prefixes_sorted_and_localized() for country_code in values:
super().__init__(choices=choices, attrs={'aria-label': pgettext_lazy('phonenumber', 'International area code')}) country_name = locale.territories.get(country_code)
if country_name:
choices.append((prefix, "{} {}".format(country_name, prefix)))
super().__init__(choices=sorted(choices, key=lambda item: item[1]), attrs={'aria-label': pgettext_lazy('phonenumber', 'International area code')})
def render(self, name, value, *args, **kwargs): def render(self, name, value, *args, **kwargs):
return super().render(name, value or self.initial, *args, **kwargs) return super().render(name, value or self.initial, *args, **kwargs)
@@ -315,12 +305,7 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
silently deleting data. silently deleting data.
""" """
if value: if value:
if isinstance(value, str): if type(value) == PhoneNumber:
try:
value = PhoneNumber.from_string(value)
except:
pass
if isinstance(value, PhoneNumber):
if value.country_code and value.national_number: if value.country_code and value.national_number:
return [ return [
"+%d" % value.country_code, "+%d" % value.country_code,
@@ -707,7 +692,7 @@ class BaseQuestionsForm(forms.Form):
label=label, required=required, label=label, required=required,
min_value=q.valid_number_min or Decimal('0.00'), min_value=q.valid_number_min or Decimal('0.00'),
max_value=q.valid_number_max, max_value=q.valid_number_max,
help_text=help_text, help_text=q.help_text,
initial=initial.answer if initial else None, initial=initial.answer if initial else None,
) )
elif q.type == Question.TYPE_STRING: elif q.type == Question.TYPE_STRING:

View File

@@ -42,24 +42,6 @@ from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
def replace_arabic_numbers(inp):
if not isinstance(inp, str):
return inp
table = {
1632: 48, # 0
1633: 49, # 1
1634: 50, # 2
1635: 51, # 3
1636: 52, # 4
1637: 53, # 5
1638: 54, # 6
1639: 55, # 7
1640: 56, # 8
1641: 57, # 9
}
return inp.translate(table)
class DatePickerWidget(forms.DateInput): class DatePickerWidget(forms.DateInput):
def __init__(self, attrs=None, date_format=None): def __init__(self, attrs=None, date_format=None):
attrs = attrs or {} attrs = attrs or {}
@@ -80,10 +62,6 @@ class DatePickerWidget(forms.DateInput):
forms.DateInput.__init__(self, date_attrs, date_format) forms.DateInput.__init__(self, date_attrs, date_format)
def value_from_datadict(self, data, files, name):
v = super().value_from_datadict(data, files, name)
return replace_arabic_numbers(v)
class TimePickerWidget(forms.TimeInput): class TimePickerWidget(forms.TimeInput):
def __init__(self, attrs=None, time_format=None): def __init__(self, attrs=None, time_format=None):
@@ -105,10 +83,6 @@ class TimePickerWidget(forms.TimeInput):
forms.TimeInput.__init__(self, time_attrs, time_format) forms.TimeInput.__init__(self, time_attrs, time_format)
def value_from_datadict(self, data, files, name):
v = super().value_from_datadict(data, files, name)
return replace_arabic_numbers(v)
class UploadedFileWidget(forms.ClearableFileInput): class UploadedFileWidget(forms.ClearableFileInput):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -205,10 +179,6 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
# Skip one hierarchy level # Skip one hierarchy level
forms.MultiWidget.__init__(self, widgets, attrs) forms.MultiWidget.__init__(self, widgets, attrs)
def value_from_datadict(self, data, files, name):
v = super().value_from_datadict(data, files, name)
return [replace_arabic_numbers(i) for i in v]
class BusinessBooleanRadio(forms.RadioSelect): class BusinessBooleanRadio(forms.RadioSelect):
def __init__(self, require_business=False, attrs=None): def __init__(self, require_business=False, attrs=None):

View File

@@ -104,4 +104,4 @@ class Command(BaseCommand):
print(f"Error in order {o.full_code}: status={o.status}, sum(positions)+sum(fees)={o.position_total + o.fee_total}, " print(f"Error in order {o.full_code}: status={o.status}, sum(positions)+sum(fees)={o.position_total + o.fee_total}, "
f"order.total={o.total}, sum(transactions)={o.tx_total}, expected={o.correct_total}, pos_cnt={o.position_cnt}, tx_pos_cnt={o.tx_cnt}") f"order.total={o.total}, sum(transactions)={o.tx_total}, expected={o.correct_total}, pos_cnt={o.position_cnt}, tx_pos_cnt={o.tx_cnt}")
self.stderr.write(self.style.SUCCESS('Check completed.')) self.stderr.write(self.style.SUCCESS(f'Check completed.'))

View File

@@ -19,13 +19,11 @@
# 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
import sys import sys
from django.apps import apps from django.apps import apps
from django.core.management import call_command from django.core.management import call_command
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import connection
from django_scopes import scope, scopes_disabled from django_scopes import scope, scopes_disabled
@@ -35,13 +33,6 @@ class Command(BaseCommand):
parser.parse_args = lambda x: parser.parse_known_args(x)[0] parser.parse_args = lambda x: parser.parse_known_args(x)[0]
return parser return parser
def add_arguments(self, parser):
parser.add_argument(
'--print-sql',
action='store_true',
help='Print all SQL queries.',
)
def handle(self, *args, **options): def handle(self, *args, **options):
try: try:
from django_extensions.management.commands import shell_plus # noqa from django_extensions.management.commands import shell_plus # noqa
@@ -50,11 +41,6 @@ class Command(BaseCommand):
cmd = 'shell' cmd = 'shell'
del options['skip_checks'] del options['skip_checks']
if options['print_sql']:
connection.force_debug_cursor = True
logger = logging.getLogger("django.db.backends")
logger.setLevel(logging.DEBUG)
parser = self.create_parser(sys.argv[0], sys.argv[1]) parser = self.create_parser(sys.argv[0], sys.argv[1])
flags = parser.parse_known_args(sys.argv[2:])[1] flags = parser.parse_known_args(sys.argv[2:])[1]
if "--override" in flags: if "--override" in flags:

View File

@@ -1,22 +0,0 @@
# Generated by Django 3.2.4 on 2022-02-14 16:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0207_auto_20220119_1427'),
]
operations = [
migrations.AddField(
model_name='user',
name='auth_backend_identifier',
field=models.CharField(db_index=True, max_length=190, null=True),
),
migrations.AlterUniqueTogether(
name='user',
unique_together={('auth_backend', 'auth_backend_identifier')},
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.12 on 2022-03-22 11:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0208_auto_20220214_1632'),
]
operations = [
migrations.AddField(
model_name='device',
name='info',
field=models.JSONField(null=True),
),
]

View File

@@ -44,7 +44,7 @@ from django.contrib.auth.models import (
) )
from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.tokens import default_token_generator
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import IntegrityError, models, transaction from django.db import models
from django.db.models import Q from django.db.models import Q
from django.utils.crypto import get_random_string, salted_hmac from django.utils.crypto import get_random_string, salted_hmac
from django.utils.timezone import now from django.utils.timezone import now
@@ -61,10 +61,6 @@ from pretix.helpers.urls import build_absolute_uri
from .base import LoggingMixin from .base import LoggingMixin
class EmailAddressTakenError(IntegrityError):
pass
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
""" """
This is the user manager for our custom user model. See the User This is the user manager for our custom user model. See the User
@@ -87,116 +83,6 @@ class UserManager(BaseUserManager):
user.save() user.save()
return user return user
def get_or_create_for_backend(self, backend, identifier, email, set_always, set_on_creation):
"""
This method should be used by third-party authentication backends to log in a user.
It either returns an already existing user or creates a new user.
In pretix 4.7 and earlier, email addresses were the only property to identify a user with.
Starting with pretix 4.8, backends SHOULD instead use a unique, immutable identifier
based on their backend data store to allow for changing email addresses.
This method transparently handles the conversion of old user accounts and adds the
backend identifier to their database record.
This method will never return users managed by a different authentication backend.
If you try to create an account with an email address already blocked by a different
authentication backend, :py:class:`EmailAddressTakenError` will be raised. In this case,
you should display a message to the user.
:param backend: The `identifier` attribute of the authentication backend
:param identifier: The unique, immutable identifier of this user, max. 190 characters
:param email: The user's email address
:param set_always: A dictionary of fields to update on the user model on every login
:param set_on_creation: A dictionary of fields to set on the user model if it's newly created
:return: A `User` instance.
"""
if identifier is None:
raise ValueError('You need to supply a custom, unique identifier for this user.')
if email is None:
raise ValueError('You need to supply an email address for this user.')
if 'auth_backend_identifier' in set_always or 'auth_backend_identifier' in set_on_creation or \
'auth_backend' in set_always or 'auth_backend' in set_on_creation:
raise ValueError('You may not update auth_backend/auth_backend_identifier.')
if len(identifier) > 190:
raise ValueError('The user identifier must not be more than 190 characters.')
# Always update the email address
set_always.update({'email': email})
# First, check if we find the user based on it's backend-specific authenticator
try:
u = self.get(
auth_backend=backend,
auth_backend_identifier=identifier,
)
dirty = False
for k, v in set_always.items():
if getattr(u, k) != v:
setattr(u, k, v)
dirty = True
if dirty:
try:
with transaction.atomic():
u.save(update_fields=set_always.keys())
except IntegrityError:
# This might only raise IntegrityError if the email address is used
# by someone else
raise EmailAddressTakenError()
return u
except self.model.DoesNotExist:
pass
# Second, check if we find the user based on their email address and this backend
try:
u = self.get(
auth_backend=backend,
auth_backend_identifier__isnull=True,
email=email,
)
u.auth_backend_identifier = identifier
for k, v in set_always.items():
setattr(u, k, v)
try:
with transaction.atomic():
u.save(update_fields=['auth_backend_identifier'] + list(set_always.keys()))
return u
except IntegrityError:
# This might only raise IntegrityError if this code is being executed twice
# and runs into a race condition, this mechanism is taken from Django's
# get_or_create
try:
return self.get(
auth_backend=backend,
auth_backend_identifier=identifier,
)
except self.model.DoesNotExist:
pass
raise
except self.model.DoesNotExist:
pass
# Third, create a new user
u = User(
auth_backend=backend,
auth_backend_identifier=identifier,
**set_on_creation,
**set_always,
)
try:
u.save(force_insert=True)
return u
except IntegrityError:
# This might either be a race condition or the email address is taken
# by a different backend
try:
return self.get(
auth_backend=backend,
auth_backend_identifier=identifier,
)
except self.model.DoesNotExist:
raise EmailAddressTakenError()
def generate_notifications_token(): def generate_notifications_token():
return get_random_string(length=32) return get_random_string(length=32)
@@ -231,10 +117,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
:type needs_password_change: bool :type needs_password_change: bool
:param timezone: The user's preferred timezone. :param timezone: The user's preferred timezone.
:type timezone: str :type timezone: str
:param auth_backend: The identifier of the authentication backend plugin responsible for managing this user.
:type auth_backend: str
:param auth_backend_identifier: The native identifier of the user provided by a non-native authentication backend.
:type auth_backend_identifier: str
""" """
USERNAME_FIELD = 'email' USERNAME_FIELD = 'email'
@@ -270,7 +152,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
) )
notifications_token = models.CharField(max_length=255, default=generate_notifications_token) notifications_token = models.CharField(max_length=255, default=generate_notifications_token)
auth_backend = models.CharField(max_length=255, default='native') auth_backend = models.CharField(max_length=255, default='native')
auth_backend_identifier = models.CharField(max_length=190, db_index=True, null=True, blank=True)
session_token = models.CharField(max_length=32, default=generate_session_token) session_token = models.CharField(max_length=32, default=generate_session_token)
objects = UserManager() objects = UserManager()
@@ -283,7 +164,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
verbose_name = _("User") verbose_name = _("User")
verbose_name_plural = _("Users") verbose_name_plural = _("Users")
ordering = ('email',) ordering = ('email',)
unique_together = (('auth_backend', 'auth_backend_identifier'),)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.email = self.email.lower() self.email = self.email.lower()
@@ -498,23 +378,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
| Q(id__in=self.teams.filter(**kwargs).values_list('limit_events__id', flat=True)) | Q(id__in=self.teams.filter(**kwargs).values_list('limit_events__id', flat=True))
) )
@scopes_disabled()
def get_organizers_with_any_permission(self, request=None):
"""
Returns a queryset of organizers the user has any permissions to.
:param request: The current request (optional). Required to detect staff sessions properly.
:return: Iterable of Organizers
"""
from .event import Organizer
if request and self.has_active_staff_session(request.session.session_key):
return Organizer.objects.all()
return Organizer.objects.filter(
id__in=self.teams.values_list('organizer', flat=True)
)
@scopes_disabled() @scopes_disabled()
def get_organizers_with_permission(self, permission, request=None): def get_organizers_with_permission(self, permission, request=None):
""" """

View File

@@ -188,7 +188,6 @@ class CheckinList(LoggedModel):
# * in pretix.helpers.jsonlogic_boolalg # * in pretix.helpers.jsonlogic_boolalg
# * in checkinrules.js # * in checkinrules.js
# * in libpretixsync # * in libpretixsync
# * in pretixscan-ios (in the future)
top_level_operators = { top_level_operators = {
'<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and' '<', '<=', '>', '>=', '==', '!=', 'inList', 'isBefore', 'isAfter', 'or', 'and'
} }
@@ -196,8 +195,7 @@ class CheckinList(LoggedModel):
'buildTime', 'objectList', 'lookup', 'var', 'buildTime', 'objectList', 'lookup', 'var',
} }
allowed_vars = { allowed_vars = {
'product', 'variation', 'now', 'now_isoweekday', 'entries_number', 'entries_today', 'entries_days', 'product', 'variation', 'now', 'entries_number', 'entries_today', 'entries_days'
'minutes_since_last_entry', 'minutes_since_first_entry',
} }
if not rules or not isinstance(rules, dict): if not rules or not isinstance(rules, dict):
return rules return rules
@@ -223,7 +221,7 @@ class CheckinList(LoggedModel):
return rules return rules
if operator in ('or', 'and') and seen_nonbool: if operator in ('or', 'and') and seen_nonbool:
raise ValidationError('You cannot use OR/AND logic on a level below a comparison operator.') raise ValidationError(f'You cannot use OR/AND logic on a level below a comparison operator.')
for v in values: for v in values:
cls.validate_rules(v, seen_nonbool=seen_nonbool or operator not in ('or', 'and'), depth=depth + 1) cls.validate_rules(v, seen_nonbool=seen_nonbool or operator not in ('or', 'and'), depth=depth + 1)

View File

@@ -172,7 +172,6 @@ class Customer(LoggedModel):
return salted_hmac(key_salt, payload).hexdigest() return salted_hmac(key_salt, payload).hexdigest()
def get_email_context(self): def get_email_context(self):
from pretix.base.email import get_name_parts_localized
ctx = { ctx = {
'name': self.name, 'name': self.name,
'organizer': self.organizer.name, 'organizer': self.organizer.name,
@@ -181,13 +180,7 @@ class Customer(LoggedModel):
for f, l, w in name_scheme['fields']: for f, l, w in name_scheme['fields']:
if f == 'full_name': if f == 'full_name':
continue continue
ctx['name_%s' % f] = get_name_parts_localized(self.name_parts, f) ctx['name_%s' % f] = self.name_parts.get(f, '')
if "concatenation_for_salutation" in name_scheme:
ctx['name_for_salutation'] = name_scheme["concatenation_for_salutation"](self.name_parts)
else:
ctx['name_for_salutation'] = name_scheme["concatenation"](self.name_parts)
return ctx return ctx
@property @property

View File

@@ -156,9 +156,6 @@ class Device(LoggedModel):
null=True, null=True,
blank=False blank=False
) )
info = models.JSONField(
null=True, blank=True,
)
objects = ScopedManager(organizer='organizer') objects = ScopedManager(organizer='organizer')

View File

@@ -1179,21 +1179,21 @@ class Event(EventMixin, LoggedModel):
if not p.name.startswith('.') and getattr(p, 'visible', True) if not p.name.startswith('.') and getattr(p, 'visible', True)
} }
def set_active_plugins(self, modules, allow_restricted=frozenset()): def set_active_plugins(self, modules, allow_restricted=False):
plugins_active = self.get_plugins() plugins_active = self.get_plugins()
plugins_available = self.get_available_plugins() plugins_available = self.get_available_plugins()
enable = [m for m in modules if m not in plugins_active and m in plugins_available] enable = [m for m in modules if m not in plugins_active and m in plugins_available]
for module in enable: for module in enable:
if getattr(plugins_available[module].app, 'restricted', False) and module not in allow_restricted: if getattr(plugins_available[module].app, 'restricted', False) and not allow_restricted:
modules.remove(module) modules.remove(module)
elif hasattr(plugins_available[module].app, 'installed'): elif hasattr(plugins_available[module].app, 'installed'):
getattr(plugins_available[module].app, 'installed')(self) getattr(plugins_available[module].app, 'installed')(self)
self.plugins = ",".join(modules) self.plugins = ",".join(modules)
def enable_plugin(self, module, allow_restricted=frozenset()): def enable_plugin(self, module, allow_restricted=False):
plugins_active = self.get_plugins() plugins_active = self.get_plugins()
from pretix.presale.style import regenerate_css from pretix.presale.style import regenerate_css

View File

@@ -52,10 +52,7 @@ class MultiStringField(TextField):
if isinstance(value, (list, tuple)): if isinstance(value, (list, tuple)):
return DELIMITER + DELIMITER.join(value) + DELIMITER return DELIMITER + DELIMITER.join(value) + DELIMITER
elif value is None: elif value is None:
if self.null: return ""
return None
else:
return ""
raise TypeError("Invalid data type passed.") raise TypeError("Invalid data type passed.")
def get_prep_lookup(self, lookup_type, value): # NOQA def get_prep_lookup(self, lookup_type, value): # NOQA
@@ -81,8 +78,6 @@ class MultiStringField(TextField):
return MultiStringContains return MultiStringContains
elif lookup_name == 'icontains': elif lookup_name == 'icontains':
return MultiStringIContains return MultiStringIContains
elif lookup_name == 'isnull':
return builtin_lookups.IsNull
raise NotImplementedError( raise NotImplementedError(
"Lookup '{}' doesn't work with MultiStringField".format(lookup_name), "Lookup '{}' doesn't work with MultiStringField".format(lookup_name),
) )

View File

@@ -44,7 +44,7 @@ import dateutil.parser
import pytz import pytz
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, RegexValidator from django.core.validators import RegexValidator
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from django.utils import formats from django.utils import formats
@@ -479,14 +479,12 @@ class Item(LoggedModel):
min_per_order = models.IntegerField( min_per_order = models.IntegerField(
verbose_name=_('Minimum amount per order'), verbose_name=_('Minimum amount per order'),
null=True, blank=True, null=True, blank=True,
validators=[MinValueValidator(0)],
help_text=_('This product can only be bought if it is added to the cart at least this many times. If you keep ' help_text=_('This product can only be bought if it is added to the cart at least this many times. If you keep '
'the field empty or set it to 0, there is no special limit for this product.') 'the field empty or set it to 0, there is no special limit for this product.')
) )
max_per_order = models.IntegerField( max_per_order = models.IntegerField(
verbose_name=_('Maximum amount per order'), verbose_name=_('Maximum amount per order'),
null=True, blank=True, null=True, blank=True,
validators=[MinValueValidator(0)],
help_text=_('This product can only be bought at most this many times within one order. If you keep the field ' help_text=_('This product can only be bought at most this many times within one order. If you keep the field '
'empty or set it to 0, there is no special limit for this product. The limit for the maximum ' 'empty or set it to 0, there is no special limit for this product. The limit for the maximum '
'number of items in the whole order applies regardless.') 'number of items in the whole order applies regardless.')
@@ -1699,7 +1697,7 @@ class Quota(LoggedModel):
if event != item.event: if event != item.event:
raise ValidationError(_('One or more items do not belong to this event.')) raise ValidationError(_('One or more items do not belong to this event.'))
if item.has_variations: if item.has_variations:
if not variations or not any(var.item == item for var in variations): if not any(var.item == item for var in variations):
raise ValidationError(_('One or more items has variations but none of these are in the variations list.')) raise ValidationError(_('One or more items has variations but none of these are in the variations list.'))
@staticmethod @staticmethod

View File

@@ -638,13 +638,12 @@ class Order(LockModel, LoggedModel):
return False return False
if self.user_cancel_deadline and now() > self.user_cancel_deadline: if self.user_cancel_deadline and now() > self.user_cancel_deadline:
return False return False
if self.status == Order.STATUS_PENDING:
if self.status == Order.STATUS_PAID or self.payment_refund_sum > Decimal('0.00'): return self.event.settings.cancel_allow_user
elif self.status == Order.STATUS_PAID:
if self.total == Decimal('0.00'): if self.total == Decimal('0.00'):
return self.event.settings.cancel_allow_user return self.event.settings.cancel_allow_user
return self.event.settings.cancel_allow_user_paid return self.event.settings.cancel_allow_user_paid
elif self.status == Order.STATUS_PENDING:
return self.event.settings.cancel_allow_user
return False return False
def propose_auto_refunds(self, amount: Decimal, payments: list=None): def propose_auto_refunds(self, amount: Decimal, payments: list=None):
@@ -977,7 +976,7 @@ class Order(LockModel, LoggedModel):
SendMailException, TolerantDict, mail, render_mail, SendMailException, TolerantDict, mail, render_mail,
) )
if not self.email and not (position and position.attendee_email): if not self.email:
return return
for k, v in self.event.meta_data.items(): for k, v in self.event.meta_data.items():
@@ -1331,10 +1330,6 @@ class AbstractPosition(models.Model):
else: else:
return {} return {}
@property
def item_and_variation(self):
return self.item, self.variation
@meta_info_data.setter @meta_info_data.setter
def meta_info_data(self, d): def meta_info_data(self, d):
self.meta_info = json.dumps(d) self.meta_info = json.dumps(d)
@@ -1729,10 +1724,10 @@ class OrderPayment(models.Model):
email_context = get_email_context(event=self.order.event, order=self.order, position=position) email_context = get_email_context(event=self.order.event, order=self.order, position=position)
email_subject = _('Event registration confirmed: %(code)s') % {'code': self.order.code} email_subject = _('Event registration confirmed: %(code)s') % {'code': self.order.code}
try: try:
position.send_mail( self.order.send_mail(
email_subject, email_template, email_context, email_subject, email_template, email_context,
'pretix.event.order.email.order_paid', user, 'pretix.event.order.email.order_paid', user,
invoices=[], invoices=[], position=position,
attach_tickets=True, attach_tickets=True,
attach_ical=self.order.event.settings.mail_attach_ical attach_ical=self.order.event.settings.mail_attach_ical
) )

View File

@@ -26,7 +26,7 @@ import jsonschema
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import Exists, F, OuterRef, Q, Subquery, Value from django.db.models import Exists, F, OuterRef, Q, Value
from django.db.models.functions import Power from django.db.models.functions import Power
from django.utils.deconstruct import deconstructible from django.utils.deconstruct import deconstructible
from django.utils.timezone import now from django.utils.timezone import now
@@ -281,26 +281,10 @@ class Seat(models.Model):
q = Q(has_order=True) | Q(has_voucher=True) q = Q(has_order=True) | Q(has_voucher=True)
if ignore_cart is not True: if ignore_cart is not True:
q |= Q(has_cart=True) q |= Q(has_cart=True)
# The following looks like it makes no sense. Why wouldn't we just use ``Value(self.x)``, we already now
# the value? The reason is that x and y are floating point values generated from our JSON files. As it turns
# out, PostgreSQL MIGHT store floating point values with a different precision based on the underlying system
# architecture. So if we generate e.g. 670.247128887222289 from the JSON file and store it to the database,
# PostgreSQL will store it as 670.247128887222289 internally. However if we query it again, we only get
# 670.247128887222 back. But if we do calculations with a field in PostgreSQL itself, it uses the full
# precision for the calculation.
# We don't actually care about the results with this precision, but we care that the results from this
# function are exactly the same as from event.free_seats(), so we do this subquery trick to deal with
# PostgreSQL's internal values in both cases.
# In the long run, we probably just want to round the numbers on insert...
# See also https://www.postgresql.org/docs/11/runtime-config-client.html#GUC-EXTRA-FLOAT-DIGITS
self_x = Subquery(Seat.objects.filter(pk=self.pk).values('x'))
self_y = Subquery(Seat.objects.filter(pk=self.pk).values('y'))
qs_closeby_taken = qs_annotated.annotate( qs_closeby_taken = qs_annotated.annotate(
distance=( distance=(
Power(F('x') - self_x, Value(2), output_field=models.FloatField()) + Power(F('x') - Value(self.x), Value(2), output_field=models.FloatField()) +
Power(F('y') - self_y, Value(2), output_field=models.FloatField()) Power(F('y') - Value(self.y), Value(2), output_field=models.FloatField())
) )
).exclude(pk=self.pk).filter( ).exclude(pk=self.pk).filter(
q, q,

View File

@@ -955,8 +955,6 @@ class BoxOfficeProvider(BasePaymentProvider):
return { return {
"pos_id": payment.info_data.get('pos_id', None), "pos_id": payment.info_data.get('pos_id', None),
"receipt_id": payment.info_data.get('receipt_id', None), "receipt_id": payment.info_data.get('receipt_id', None),
"payment_type": payment.info_data.get('payment_type', None),
"payment_data": payment.info_data.get('payment_data', {}),
} }
def payment_control_render(self, request, payment) -> str: def payment_control_render(self, request, payment) -> str:

View File

@@ -37,7 +37,6 @@ import hashlib
import itertools import itertools
import logging import logging
import os import os
import re
import subprocess import subprocess
import tempfile import tempfile
import uuid import uuid
@@ -49,14 +48,12 @@ from arabic_reshaper import ArabicReshaper
from bidi.algorithm import get_display from bidi.algorithm import get_display
from django.conf import settings from django.conf import settings
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.db.models import Max, Min
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.functional import SimpleLazyObject from django.utils.functional import SimpleLazyObject
from django.utils.html import conditional_escape from django.utils.html import conditional_escape
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from i18nfield.strings import LazyI18nString
from PyPDF2 import PdfFileReader from PyPDF2 import PdfFileReader
from pytz import timezone from pytz import timezone
from reportlab.graphics import renderPDF from reportlab.graphics import renderPDF
@@ -205,11 +202,6 @@ DEFAULT_VARIABLES = OrderedDict((
"editor_sample": 'foo@bar.com', "editor_sample": 'foo@bar.com',
"evaluate": lambda op, order, ev: op.attendee_email or (op.addon_to.attendee_email if op.addon_to else '') "evaluate": lambda op, order, ev: op.attendee_email or (op.addon_to.attendee_email if op.addon_to else '')
}), }),
("pseudonymization_id", {
"label": _("Pseudonymization ID (lead scanning)"),
"editor_sample": "GG89JUJDTA",
"evaluate": lambda orderposition, order, event: orderposition.pseudonymization_id,
}),
("event_name", { ("event_name", {
"label": _("Event name"), "label": _("Event name"),
"editor_sample": _("Sample event name"), "editor_sample": _("Sample event name"),
@@ -395,41 +387,30 @@ DEFAULT_VARIABLES = OrderedDict((
("seat", { ("seat", {
"label": _("Seat: Full name"), "label": _("Seat: Full name"),
"editor_sample": _("Ground floor, Row 3, Seat 4"), "editor_sample": _("Ground floor, Row 3, Seat 4"),
"evaluate": lambda op, order, ev: str(get_seat(op) if get_seat(op) else "evaluate": lambda op, order, ev: str(op.seat if op.seat else
_('General admission') if ev.seating_plan_id is not None else "") _('General admission') if ev.seating_plan_id is not None else "")
}), }),
("seat_zone", { ("seat_zone", {
"label": _("Seat: zone"), "label": _("Seat: zone"),
"editor_sample": _("Ground floor"), "editor_sample": _("Ground floor"),
"evaluate": lambda op, order, ev: str(get_seat(op).zone_name if get_seat(op) else "evaluate": lambda op, order, ev: str(op.seat.zone_name if op.seat else
_('General admission') if ev.seating_plan_id is not None else "") _('General admission') if ev.seating_plan_id is not None else "")
}), }),
("seat_row", { ("seat_row", {
"label": _("Seat: row"), "label": _("Seat: row"),
"editor_sample": "3", "editor_sample": "3",
"evaluate": lambda op, order, ev: str(get_seat(op).row_name if get_seat(op) else "") "evaluate": lambda op, order, ev: str(op.seat.row_name if op.seat else "")
}), }),
("seat_number", { ("seat_number", {
"label": _("Seat: seat number"), "label": _("Seat: seat number"),
"editor_sample": 4, "editor_sample": 4,
"evaluate": lambda op, order, ev: str(get_seat(op).seat_number if get_seat(op) else "") "evaluate": lambda op, order, ev: str(op.seat.seat_number if op.seat else "")
}), }),
("first_scan", { ("first_scan", {
"label": _("Date and time of first scan"), "label": _("Date and time of first scan"),
"editor_sample": _("2017-05-31 19:00"), "editor_sample": _("2017-05-31 19:00"),
"evaluate": lambda op, order, ev: get_first_scan(op) "evaluate": lambda op, order, ev: get_first_scan(op)
}), }),
("giftcard_issuance_date", {
"label": _("Gift card: Issuance date"),
"editor_sample": _("2017-05-31"),
"evaluate": lambda op, order, ev: get_giftcard_issuance(op, ev)
}),
("giftcard_expiry_date", {
"label": _("Gift card: Expiration date"),
"editor_sample": _("2017-05-31"),
"evaluate": lambda op, order, ev: get_giftcard_expiry(op, ev)
}),
)) ))
DEFAULT_IMAGES = OrderedDict([]) DEFAULT_IMAGES = OrderedDict([])
@@ -504,17 +485,10 @@ def variables_from_questions(sender, *args, **kwargs):
for q in sender.questions.all(): for q in sender.questions.all():
if q.type == Question.TYPE_FILE: if q.type == Question.TYPE_FILE:
continue continue
d['question_{}'.format(q.identifier)] = {
'label': _('Question: {question}').format(question=q.question),
'editor_sample': _('<Answer: {question}>').format(question=q.question),
'evaluate': partial(get_answer, question_id=q.pk),
'migrate_from': 'question_{}'.format(q.pk)
}
d['question_{}'.format(q.pk)] = { d['question_{}'.format(q.pk)] = {
'label': _('Question: {question}').format(question=q.question), 'label': _('Question: {question}').format(question=q.question),
'editor_sample': _('<Answer: {question}>').format(question=q.question), 'editor_sample': _('<Answer: {question}>').format(question=q.question),
'evaluate': partial(get_answer, question_id=q.pk), 'evaluate': partial(get_answer, question_id=q.pk)
'hidden': True,
} }
return d return d
@@ -572,24 +546,6 @@ def get_variables(event):
return v return v
def get_giftcard_expiry(op: OrderPosition, ev):
if not op.item.issue_giftcard:
return "" # performance optimization
m = op.issued_gift_cards.aggregate(m=Min('expires'))['m']
if not m:
return ""
return date_format(m.astimezone(ev.timezone), "SHORT_DATE_FORMAT")
def get_giftcard_issuance(op: OrderPosition, ev):
if not op.item.issue_giftcard:
return "" # performance optimization
m = op.issued_gift_cards.aggregate(m=Max('issuance'))['m']
if not m:
return ""
return date_format(m.astimezone(ev.timezone), "SHORT_DATE_FORMAT")
def get_first_scan(op: OrderPosition): def get_first_scan(op: OrderPosition):
scans = list(op.checkins.all()) scans = list(op.checkins.all())
@@ -601,14 +557,6 @@ def get_first_scan(op: OrderPosition):
return "" return ""
def get_seat(op: OrderPosition):
if op.seat_id:
return op.seat
if op.addon_to_id:
return op.addon_to.seat
return None
reshaper = SimpleLazyObject(lambda: ArabicReshaper(configuration={ reshaper = SimpleLazyObject(lambda: ArabicReshaper(configuration={
'delete_harakat': True, 'delete_harakat': True,
'support_ligatures': False, 'support_ligatures': False,
@@ -668,14 +616,12 @@ class Renderer:
preserveAspectRatio=True, anchor='n', preserveAspectRatio=True, anchor='n',
mask='auto') mask='auto')
def _draw_barcodearea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict): def _draw_barcodearea(self, canvas: Canvas, op: OrderPosition, o: dict):
content = o.get('content', 'secret') content = o.get('content', 'secret')
if content == 'secret': if content == 'secret':
# do not use get_text_content because it uses a shortened version of secret
# and does not deal with our default value here properly
content = op.secret content = op.secret
else: elif content == 'pseudonymization_id':
content = self._get_text_content(op, order, o) content = op.pseudonymization_id
level = 'H' level = 'H'
if len(content) > 32: if len(content) > 32:
@@ -702,51 +648,20 @@ class Renderer:
return self._get_text_content(op, order, o, True) return self._get_text_content(op, order, o, True)
ev = self._get_ev(op, order) ev = self._get_ev(op, order)
if not o['content']: if not o['content']:
return '(error)' return '(error)'
if o['content'] == 'other':
if o['content'] == 'other' or o['content'] == 'other_i18n': return o['text']
if o['content'] == 'other_i18n':
text = str(LazyI18nString(o['text_i18n']))
else:
text = o['text']
def replace(x):
print(x.group(1))
if x.group(1).startswith('itemmeta:'):
return op.item.meta_data.get(x.group(1)[9:]) or ''
elif x.group(1).startswith('meta:'):
return ev.meta_data.get(x.group(1)[5:]) or ''
elif x.group(1) not in self.variables:
return x.group(0)
if x.group(1) == 'secret':
# Do not use shortened version
return op.secret
try:
return self.variables[x.group(1)]['evaluate'](op, order, ev)
except:
logger.exception('Failed to process variable.')
return '(error)'
# We do not use str.format like in emails so we (a) can evaluate lazily and (b) can re-implement this
# 1:1 on other platforms that render PDFs through our API (libpretixprint)
return re.sub(r'\{([a-zA-Z0-9:_]+)\}', replace, text)
elif o['content'].startswith('itemmeta:'): elif o['content'].startswith('itemmeta:'):
return op.item.meta_data.get(o['content'][9:]) or '' return op.item.meta_data.get(o['content'][9:]) or ''
elif o['content'].startswith('meta:'): elif o['content'].startswith('meta:'):
return ev.meta_data.get(o['content'][5:]) or '' return ev.meta_data.get(o['content'][5:]) or ''
elif o['content'] in self.variables: elif o['content'] in self.variables:
try: try:
return self.variables[o['content']]['evaluate'](op, order, ev) return self.variables[o['content']]['evaluate'](op, order, ev)
except: except:
logger.exception('Failed to process variable.') logger.exception('Failed to process variable.')
return '(error)' return '(error)'
return '' return ''
def _draw_imagearea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict): def _draw_imagearea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
@@ -839,30 +754,20 @@ class Renderer:
p.drawOn(canvas, 0, -h - ad[1]) p.drawOn(canvas, 0, -h - ad[1])
canvas.restoreState() canvas.restoreState()
def draw_page(self, canvas: Canvas, order: Order, op: OrderPosition, show_page=True, only_page=None): def draw_page(self, canvas: Canvas, order: Order, op: OrderPosition, show_page=True):
page_count = self.bg_pdf.getNumPages() for o in self.layout:
if o['type'] == "barcodearea":
if not only_page and not show_page: self._draw_barcodearea(canvas, op, o)
raise ValueError("only_page=None and show_page=False cannot be combined") elif o['type'] == "imagearea":
self._draw_imagearea(canvas, op, order, o)
for page in range(page_count): elif o['type'] == "textarea":
if only_page and only_page != page + 1: self._draw_textarea(canvas, op, order, o)
continue elif o['type'] == "poweredby":
for o in self.layout: self._draw_poweredby(canvas, op, o)
if o.get('page', 1) != page + 1: if self.bg_pdf:
continue canvas.setPageSize((self.bg_pdf.getPage(0).mediaBox[2], self.bg_pdf.getPage(0).mediaBox[3]))
if o['type'] == "barcodearea": if show_page:
self._draw_barcodearea(canvas, op, order, o) canvas.showPage()
elif o['type'] == "imagearea":
self._draw_imagearea(canvas, op, order, o)
elif o['type'] == "textarea":
self._draw_textarea(canvas, op, order, o)
elif o['type'] == "poweredby":
self._draw_poweredby(canvas, op, o)
if self.bg_pdf:
canvas.setPageSize((self.bg_pdf.getPage(page).mediaBox[2], self.bg_pdf.getPage(page).mediaBox[3]))
if show_page:
canvas.showPage()
def render_background(self, buffer, title=_('Ticket')): def render_background(self, buffer, title=_('Ticket')):
if settings.PDFTK: if settings.PDFTK:
@@ -875,7 +780,7 @@ class Renderer:
subprocess.run([ subprocess.run([
settings.PDFTK, settings.PDFTK,
os.path.join(d, 'front.pdf'), os.path.join(d, 'front.pdf'),
'multibackground', 'background',
os.path.join(d, 'back.pdf'), os.path.join(d, 'back.pdf'),
'output', 'output',
os.path.join(d, 'out.pdf'), os.path.join(d, 'out.pdf'),
@@ -889,8 +794,8 @@ class Renderer:
new_pdf = PdfFileReader(buffer) new_pdf = PdfFileReader(buffer)
output = PdfFileWriter() output = PdfFileWriter()
for i, page in enumerate(new_pdf.pages): for page in new_pdf.pages:
bg_page = copy.copy(self.bg_pdf.getPage(i)) bg_page = copy.copy(self.bg_pdf.getPage(0))
bg_page.mergePage(page) bg_page.mergePage(page)
output.addPage(bg_page) output.addPage(bg_page)

View File

@@ -247,7 +247,7 @@ def assign_ticket_secret(event, position, force_invalidate_if_revokation_list_us
**kwargs **kwargs
) )
changed = position.secret != secret changed = position.secret != secret
if position.secret and changed and gen.use_revocation_list and position.pk: if position.secret and changed and gen.use_revocation_list:
position.revoked_secrets.create(event=event, secret=position.secret) position.revoked_secrets.create(event=event, secret=position.secret)
position.secret = secret position.secret = secret
if save and changed: if save and changed:

View File

@@ -214,8 +214,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
refund_amount = o.payment_refund_sum refund_amount = o.payment_refund_sum
try: try:
if auto_refund or manual_refund: if auto_refund:
_try_auto_refund(o.pk, auto_refund=auto_refund, manual_refund=manual_refund, allow_partial=True, _try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True,
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard, source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions, giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
comment=gettext('Event canceled')) comment=gettext('Event canceled'))
@@ -272,8 +272,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
ocm.commit() ocm.commit()
refund_amount = o.payment_refund_sum - o.total refund_amount = o.payment_refund_sum - o.total
if auto_refund or manual_refund: if auto_refund:
_try_auto_refund(o.pk, auto_refund=auto_refund, manual_refund=manual_refund, allow_partial=True, _try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True,
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard, source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions, giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
comment=gettext('Event canceled')) comment=gettext('Event canceled'))

View File

@@ -41,8 +41,8 @@ import pytz
from django.core.files import File from django.core.files import File
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.db.models import ( from django.db.models import (
BooleanField, Count, ExpressionWrapper, F, IntegerField, Max, Min, BooleanField, Count, ExpressionWrapper, F, IntegerField, OuterRef, Q,
OuterRef, Q, Subquery, Value, Subquery, Value,
) )
from django.db.models.functions import Coalesce, TruncDate from django.db.models.functions import Coalesce, TruncDate
from django.dispatch import receiver from django.dispatch import receiver
@@ -60,7 +60,7 @@ from pretix.helpers.jsonlogic import Logic
from pretix.helpers.jsonlogic_boolalg import convert_to_dnf from pretix.helpers.jsonlogic_boolalg import convert_to_dnf
from pretix.helpers.jsonlogic_query import ( from pretix.helpers.jsonlogic_query import (
Equal, GreaterEqualThan, GreaterThan, InList, LowerEqualThan, LowerThan, Equal, GreaterEqualThan, GreaterThan, InList, LowerEqualThan, LowerThan,
MinutesSince, tolerance, tolerance,
) )
@@ -210,60 +210,19 @@ def _logic_explain(rules, ev, rule_data):
elif var == 'product' or var == 'variation': elif var == 'product' or var == 'variation':
var_weights[vname] = (1000, 0) var_weights[vname] = (1000, 0)
var_texts[vname] = _('Ticket type not allowed') var_texts[vname] = _('Ticket type not allowed')
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'):
w = { w = {
'minutes_since_first_entry': 80,
'minutes_since_last_entry': 90,
'entries_days': 100, 'entries_days': 100,
'entries_number': 120, 'entries_number': 120,
'entries_today': 140, 'entries_today': 140,
'now_isoweekday': 210,
}
operator_weights = {
'==': 2,
'<': 1,
'<=': 1,
'>': 1,
'>=': 1,
'!=': 3,
} }
l = { l = {
'minutes_since_last_entry': _('time since last entry'),
'minutes_since_first_entry': _('time since first entry'),
'entries_days': _('number of days with an entry'), 'entries_days': _('number of days with an entry'),
'entries_number': _('number of entries'), 'entries_number': _('number of entries'),
'entries_today': _('number of entries today'), 'entries_today': _('number of entries today'),
'now_isoweekday': _('week day'),
} }
compare_to = rhs[0] compare_to = rhs[0]
penalty = 0 var_weights[vname] = (w[var], abs(compare_to - rule_data[var]))
if var in ('minutes_since_last_entry', 'minutes_since_first_entry'):
is_comparison_to_minus_one = (
(operator == '<' and compare_to <= 0) or
(operator == '<=' and compare_to < 0) or
(operator == '>=' and compare_to < 0) or
(operator == '>' and compare_to <= 0) or
(operator == '==' and compare_to == -1) or
(operator == '!=' and compare_to == -1)
)
if is_comparison_to_minus_one:
# These are "technical" comparisons without real meaning, we don't want to show them.
penalty = 1000
var_weights[vname] = (w[var] + operator_weights.get(operator, 0) + penalty, abs(compare_to - rule_data[var]))
if var == 'now_isoweekday':
compare_to = {
1: _('Monday'),
2: _('Tuesday'),
3: _('Wednesday'),
4: _('Thursday'),
5: _('Friday'),
6: _('Saturday'),
7: _('Sunday'),
}.get(compare_to, compare_to)
if operator == '==': if operator == '==':
var_texts[vname] = _('{variable} is not {value}').format(variable=l[var], value=compare_to) var_texts[vname] = _('{variable} is not {value}').format(variable=l[var], value=compare_to)
elif operator in ('<', '<='): elif operator in ('<', '<='):
@@ -272,7 +231,6 @@ def _logic_explain(rules, ev, rule_data):
var_texts[vname] = _('Minimum {variable} exceeded').format(variable=l[var]) var_texts[vname] = _('Minimum {variable} exceeded').format(variable=l[var])
elif operator == '!=': elif operator == '!=':
var_texts[vname] = _('{variable} is {value}').format(variable=l[var], value=compare_to) var_texts[vname] = _('{variable} is {value}').format(variable=l[var], value=compare_to)
else: else:
raise ValueError(f'Unknown variable {var}') raise ValueError(f'Unknown variable {var}')
@@ -331,11 +289,6 @@ class LazyRuleVars:
def now(self): def now(self):
return self._dt return self._dt
@property
def now_isoweekday(self):
tz = self._clist.event.timezone
return self._dt.astimezone(tz).isoweekday()
@property @property
def product(self): def product(self):
return self._position.item_id return self._position.item_id
@@ -362,30 +315,6 @@ class LazyRuleVars:
day=TruncDate('datetime', tzinfo=tz) day=TruncDate('datetime', tzinfo=tz)
).values('day').distinct().count() ).values('day').distinct().count()
@cached_property
def minutes_since_last_entry(self):
tz = self._clist.event.timezone
with override(tz):
last_entry = self._position.checkins.filter(list=self._clist, type=Checkin.TYPE_ENTRY).order_by('datetime').last()
if last_entry is None:
# Returning "None" would be "correct", but the handling of "None" in JSON logic is inconsistent
# between platforms (None<1 is true on some, but not all), we rather choose something that is at least
# consistent.
return -1
return (now() - last_entry.datetime).total_seconds() // 60
@cached_property
def minutes_since_first_entry(self):
tz = self._clist.event.timezone
with override(tz):
last_entry = self._position.checkins.filter(list=self._clist, type=Checkin.TYPE_ENTRY).order_by('datetime').first()
if last_entry is None:
# Returning "None" would be "correct", but the handling of "None" in JSON logic is inconsistent
# between platforms (None<1 is true on some, but not all), we rather choose something that is at least
# consistent.
return -1
return (now() - last_entry.datetime).total_seconds() // 60
class SQLLogic: class SQLLogic:
""" """
@@ -444,22 +373,22 @@ class SQLLogic:
).astimezone(pytz.UTC)) ).astimezone(pytz.UTC))
elif values[0] == 'date_from': elif values[0] == 'date_from':
return Coalesce( return Coalesce(
F('subevent__date_from'), F(f'subevent__date_from'),
F('order__event__date_from'), F(f'order__event__date_from'),
) )
elif values[0] == 'date_to': elif values[0] == 'date_to':
return Coalesce( return Coalesce(
F('subevent__date_to'), F(f'subevent__date_to'),
F('subevent__date_from'), F(f'subevent__date_from'),
F('order__event__date_to'), F(f'order__event__date_to'),
F('order__event__date_from'), F(f'order__event__date_from'),
) )
elif values[0] == 'date_admission': elif values[0] == 'date_admission':
return Coalesce( return Coalesce(
F('subevent__date_admission'), F(f'subevent__date_admission'),
F('subevent__date_from'), F(f'subevent__date_from'),
F('order__event__date_admission'), F(f'order__event__date_admission'),
F('order__event__date_from'), F(f'order__event__date_from'),
) )
else: else:
raise ValueError(f'Unknown time type {values[0]}') raise ValueError(f'Unknown time type {values[0]}')
@@ -470,8 +399,6 @@ class SQLLogic:
elif operator == 'var': elif operator == 'var':
if values[0] == 'now': if values[0] == 'now':
return Value(now().astimezone(pytz.UTC)) return Value(now().astimezone(pytz.UTC))
elif values[0] == 'now_isoweekday':
return Value(now().astimezone(self.list.event.timezone).isoweekday())
elif values[0] == 'product': elif values[0] == 'product':
return F('item_id') return F('item_id')
elif values[0] == 'variation': elif values[0] == 'variation':
@@ -523,38 +450,6 @@ class SQLLogic:
Value(0), Value(0),
output_field=IntegerField() output_field=IntegerField()
) )
elif values[0] == 'minutes_since_last_entry':
sq_last_entry = Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
type=Checkin.TYPE_ENTRY,
list_id=self.list.pk,
).values('position_id').order_by().annotate(
m=Max('datetime')
).values('m')
)
return Coalesce(
MinutesSince(sq_last_entry),
Value(-1),
output_field=IntegerField()
)
elif values[0] == 'minutes_since_first_entry':
sq_last_entry = Subquery(
Checkin.objects.filter(
position_id=OuterRef('pk'),
type=Checkin.TYPE_ENTRY,
list_id=self.list.pk,
).values('position_id').order_by().annotate(
m=Min('datetime')
).values('m')
)
return Coalesce(
MinutesSince(sq_last_entry),
Value(-1),
output_field=IntegerField()
)
else: else:
raise ValueError(f'Unknown operator {operator}') raise ValueError(f'Unknown operator {operator}')

View File

@@ -56,8 +56,6 @@ def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str,
with language(event.settings.locale, event.settings.region), override(event.settings.timezone): with language(event.settings.locale, event.settings.region), override(event.settings.timezone):
responses = register_data_exporters.send(event) responses = register_data_exporters.send(event)
for receiver, response in responses: for receiver, response in responses:
if not response:
continue
ex = response(event, event.organizer, set_progress) ex = response(event, event.organizer, set_progress)
if ex.identifier == provider: if ex.identifier == provider:
d = ex.render(form_data) d = ex.render(form_data)

View File

@@ -217,7 +217,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
for bcc_mail in settings_holder.settings.mail_bcc.split(','): for bcc_mail in settings_holder.settings.mail_bcc.split(','):
bcc.append(bcc_mail.strip()) bcc.append(bcc_mail.strip())
if settings_holder.settings.mail_from in (settings.DEFAULT_FROM_EMAIL, settings.MAIL_FROM_ORGANIZERS) \ if settings_holder.settings.mail_from not in (settings.DEFAULT_FROM_EMAIL, settings.MAIL_FROM_ORGANIZERS) \
and settings_holder.settings.contact_mail and not headers.get('Reply-To'): and settings_holder.settings.contact_mail and not headers.get('Reply-To'):
headers['Reply-To'] = settings_holder.settings.contact_mail headers['Reply-To'] = settings_holder.settings.contact_mail
@@ -579,7 +579,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
} }
) )
raise e raise e
if log_target: if logger:
log_target.log_action( log_target.log_action(
'pretix.email.error', 'pretix.email.error',
data={ data={

View File

@@ -53,7 +53,7 @@ from django.db.transaction import get_connection
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import gettext as _, gettext_lazy from django.utils.translation import gettext as _
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from pretix.api.models import OAuthApplication from pretix.api.models import OAuthApplication
@@ -384,7 +384,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None, def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None,
cancellation_fee=None, keep_fees=None, cancel_invoice=True, comment=None): cancellation_fee=None, keep_fees=None, cancel_invoice=True):
""" """
Mark this order as canceled Mark this order as canceled
:param order: The order to change :param order: The order to change
@@ -481,7 +481,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device, order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device,
data={'cancellation_fee': cancellation_fee, 'comment': comment}) data={'cancellation_fee': cancellation_fee})
order.cancellation_requests.all().delete() order.cancellation_requests.all().delete()
order.create_transactions() order.create_transactions()
@@ -489,7 +489,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
if send_mail: if send_mail:
email_template = order.event.settings.mail_text_order_canceled email_template = order.event.settings.mail_text_order_canceled
with language(order.locale, order.event.settings.region): with language(order.locale, order.event.settings.region):
email_context = get_email_context(event=order.event, order=order, comment=comment or "") email_context = get_email_context(event=order.event, order=order)
email_subject = _('Order canceled: %(code)s') % {'code': order.code} email_subject = _('Order canceled: %(code)s') % {'code': order.code}
try: try:
order.send_mail( order.send_mail(
@@ -934,7 +934,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider, email_template, log_entry: str, def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider, email_template, log_entry: str,
invoice, payment: OrderPayment, is_free=False): invoice, payment: OrderPayment, is_free=False):
email_context = get_email_context(event=event, order=order, payment=payment if pprov else None) email_context = get_email_context(event=event, order=order, payment=payment if pprov else None)
email_subject = gettext_lazy('Your order: {code}') email_subject = _('Your order: %(code)s') % {'code': order.code}
try: try:
order.send_mail( order.send_mail(
email_subject, email_template, email_context, email_subject, email_template, email_context,
@@ -952,14 +952,15 @@ def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider,
def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, log_entry: str, is_free=False): def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, log_entry: str, is_free=False):
email_context = get_email_context(event=event, order=order, position=position) email_context = get_email_context(event=event, order=order, position=position)
email_subject = gettext_lazy('Your event registration: {code}') email_subject = _('Your event registration: %(code)s') % {'code': order.code}
try: try:
position.send_mail( order.send_mail(
email_subject, email_template, email_context, email_subject, email_template, email_context,
log_entry, log_entry,
invoices=[], invoices=[],
attach_tickets=True, attach_tickets=True,
position=position,
attach_ical=event.settings.mail_attach_ical and (not event.settings.mail_attach_ical_paid_only or is_free), attach_ical=event.settings.mail_attach_ical and (not event.settings.mail_attach_ical_paid_only or is_free),
attach_other_files=[a for a in [ attach_other_files=[a for a in [
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):] event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
@@ -1467,7 +1468,7 @@ class OrderChangeManager:
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'): if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
self._invoice_dirty = True self._invoice_dirty = True
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None, def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None,
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None): subevent: SubEvent = None, seat: Seat = None, membership: Membership = None):
if isinstance(seat, str): if isinstance(seat, str):
if not seat: if not seat:
@@ -1492,8 +1493,6 @@ class OrderChangeManager:
if price is None: if price is None:
raise OrderError(self.error_messages['product_invalid']) raise OrderError(self.error_messages['product_invalid'])
if item.variations.exists() and not variation:
raise OrderError(self.error_messages['product_without_variation'])
if not addon_to and item.category and item.category.is_addon: if not addon_to and item.category and item.category.is_addon:
raise OrderError(self.error_messages['addon_to_required']) raise OrderError(self.error_messages['addon_to_required'])
if addon_to: if addon_to:
@@ -1529,8 +1528,6 @@ class OrderChangeManager:
self._invoice_dirty = True self._invoice_dirty = True
self._operations.append(self.SplitOperation(position)) self._operations.append(self.SplitOperation(position))
for a in position.addons.all():
self._operations.append(self.SplitOperation(a))
def set_addons(self, addons): def set_addons(self, addons):
if self._operations: if self._operations:
@@ -1597,22 +1594,21 @@ class OrderChangeManager:
op = opcache[a['addon_to']] op = opcache[a['addon_to']]
item = _items_cache[a['item']] item = _items_cache[a['item']]
subevent = op.subevent # for now, we might lift this requirement later
variation = _variations_cache[a['variation']] if a['variation'] is not None else None variation = _variations_cache[a['variation']] if a['variation'] is not None else None
if item.category_id not in available_categories[op.pk]: if item.category_id not in available_categories[op.pk]:
raise OrderError(error_messages['addon_invalid_base']) raise OrderError(error_messages['addon_invalid_base'])
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold. # Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
quotas = list(item.quotas.filter(subevent=subevent) quotas = list(item.quotas.filter(subevent=op.subevent)
if variation is None else variation.quotas.filter(subevent=subevent)) if variation is None else variation.quotas.filter(subevent=op.subevent))
if not quotas: if not quotas:
raise OrderError(error_messages['unavailable']) raise OrderError(error_messages['unavailable'])
if (a['item'], a['variation']) in input_addons[op.id]: if (a['item'], a['variation']) in input_addons[op.id]:
raise OrderError(error_messages['addon_duplicate_item']) raise OrderError(error_messages['addon_duplicate_item'])
if item.require_voucher or item.hide_without_voucher or (variation and variation.hide_without_voucher): if item.require_voucher or op.item.hide_without_voucher or (op.variation and op.variation.hide_without_voucher):
raise OrderError(error_messages['voucher_required']) raise OrderError(error_messages['voucher_required'])
if not item.is_available() or (variation and not variation.is_available()): if not item.is_available() or (variation and not variation.is_available()):
@@ -1622,11 +1618,11 @@ class OrderChangeManager:
variation and self.order.sales_channel not in variation.sales_channels): variation and self.order.sales_channel not in variation.sales_channels):
raise OrderError(error_messages['unavailable']) raise OrderError(error_messages['unavailable'])
if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available(): if op.subevent and item.pk in op.subevent.item_overrides and not op.subevent.item_overrides[op.item.pk].is_available():
raise OrderError(error_messages['not_for_sale']) raise OrderError(error_messages['not_for_sale'])
if subevent and variation and variation.pk in subevent.var_overrides and \ if op.subevent and variation and variation.pk in op.subevent.var_overrides and \
not subevent.var_overrides[variation.pk].is_available(): not op.subevent.var_overrides[variation.pk].is_available():
raise OrderError(error_messages['not_for_sale']) raise OrderError(error_messages['not_for_sale'])
if item.has_variations and not variation: if item.has_variations and not variation:
@@ -1635,10 +1631,10 @@ class OrderChangeManager:
if variation and variation.item_id != item.pk: if variation and variation.item_id != item.pk:
raise OrderError(error_messages['not_for_sale']) raise OrderError(error_messages['not_for_sale'])
if subevent and subevent.presale_start and now() < subevent.presale_start: if op.subevent and op.subevent.presale_start and now() < op.subevent.presale_start:
raise OrderError(error_messages['not_started']) raise OrderError(error_messages['not_started'])
if (subevent and subevent.presale_has_ended) or self.event.presale_has_ended: if (op.subevent and op.subevent.presale_has_ended) or self.event.presale_has_ended:
raise OrderError(error_messages['ended']) raise OrderError(error_messages['ended'])
if item.require_bundling: if item.require_bundling:
@@ -2387,8 +2383,7 @@ def perform_order(self, event: Event, payment_provider: str, positions: List[str
_unset = object() _unset = object()
def _try_auto_refund(order, auto_refund=True, manual_refund=False, allow_partial=False, def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=OrderRefund.REFUND_SOURCE_BUYER,
source=OrderRefund.REFUND_SOURCE_BUYER,
refund_as_giftcard=False, giftcard_expires=_unset, giftcard_conditions=None, comment=None): refund_as_giftcard=False, giftcard_expires=_unset, giftcard_conditions=None, comment=None):
notify_admin = False notify_admin = False
error = False error = False
@@ -2398,9 +2393,9 @@ def _try_auto_refund(order, auto_refund=True, manual_refund=False, allow_partial
if refund_amount <= Decimal('0.00'): if refund_amount <= Decimal('0.00'):
return return
can_auto_refund_sum = 0
if refund_as_giftcard: if refund_as_giftcard:
proposals = {}
can_auto_refund = True
can_auto_refund_sum = refund_amount can_auto_refund_sum = refund_amount
with transaction.atomic(): with transaction.atomic():
giftcard = order.event.organizer.issued_gift_cards.create( giftcard = order.event.organizer.issued_gift_cards.create(
@@ -2440,41 +2435,42 @@ def _try_auto_refund(order, auto_refund=True, manual_refund=False, allow_partial
if r.state != OrderRefund.REFUND_STATE_DONE: if r.state != OrderRefund.REFUND_STATE_DONE:
notify_admin = True notify_admin = True
elif auto_refund: else:
proposals = order.propose_auto_refunds(refund_amount) proposals = order.propose_auto_refunds(refund_amount)
can_auto_refund_sum = sum(proposals.values()) can_auto_refund_sum = sum(proposals.values())
if (allow_partial and can_auto_refund_sum) or can_auto_refund_sum == refund_amount: can_auto_refund = (allow_partial and can_auto_refund_sum) or can_auto_refund_sum == refund_amount
for p, value in proposals.items(): if can_auto_refund:
for p, value in proposals.items():
with transaction.atomic():
r = order.refunds.create(
payment=p,
source=source,
state=OrderRefund.REFUND_STATE_CREATED,
amount=value,
comment=comment,
provider=p.provider
)
order.log_action('pretix.event.order.refund.created', {
'local_id': r.local_id,
'provider': r.provider,
})
try:
r.payment_provider.execute_refund(r)
except PaymentException as e:
with transaction.atomic(): with transaction.atomic():
r = order.refunds.create( r.state = OrderRefund.REFUND_STATE_FAILED
payment=p, r.save()
source=source, order.log_action('pretix.event.order.refund.failed', {
state=OrderRefund.REFUND_STATE_CREATED,
amount=value,
comment=comment,
provider=p.provider
)
order.log_action('pretix.event.order.refund.created', {
'local_id': r.local_id, 'local_id': r.local_id,
'provider': r.provider, 'provider': r.provider,
'error': str(e)
}) })
error = True
try: notify_admin = True
r.payment_provider.execute_refund(r) else:
except PaymentException as e: if r.state not in (OrderRefund.REFUND_STATE_TRANSIT, OrderRefund.REFUND_STATE_DONE):
with transaction.atomic():
r.state = OrderRefund.REFUND_STATE_FAILED
r.save()
order.log_action('pretix.event.order.refund.failed', {
'local_id': r.local_id,
'provider': r.provider,
'error': str(e)
})
error = True
notify_admin = True notify_admin = True
else:
if r.state not in (OrderRefund.REFUND_STATE_TRANSIT, OrderRefund.REFUND_STATE_DONE):
notify_admin = True
if refund_amount - can_auto_refund_sum > Decimal('0.00'): if refund_amount - can_auto_refund_sum > Decimal('0.00'):
if manual_refund: if manual_refund:
@@ -2506,15 +2502,15 @@ def _try_auto_refund(order, auto_refund=True, manual_refund=False, allow_partial
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
@scopes_disabled() @scopes_disabled()
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None, def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False, device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False, comment=None,
email_comment=None, refund_comment=None, cancel_invoice=True): cancel_invoice=True):
try: try:
try: try:
ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application, ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application,
cancellation_fee, cancel_invoice=cancel_invoice, comment=email_comment) cancellation_fee, cancel_invoice=cancel_invoice)
if try_auto_refund: if try_auto_refund:
_try_auto_refund(order, refund_as_giftcard=refund_as_giftcard, _try_auto_refund(order, refund_as_giftcard=refund_as_giftcard,
comment=refund_comment) comment=comment)
return ret return ret
except LockTimeoutException: except LockTimeoutException:
self.retry() self.retry()

View File

@@ -45,7 +45,7 @@ def validate_plan_change(event, subevent, plan):
seat=OuterRef('pk'), seat=OuterRef('pk'),
canceled=False, canceled=False,
).exclude( ).exclude(
order__status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED) order__status=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)
)) ))
).annotate(has_v=Count('vouchers')).filter( ).annotate(has_v=Count('vouchers')).filter(
subevent=subevent, subevent=subevent,
@@ -69,7 +69,7 @@ def generate_seats(event, subevent, plan, mapping, blocked_guids=None):
seat=OuterRef('pk'), seat=OuterRef('pk'),
canceled=False, canceled=False,
).exclude( ).exclude(
order__status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED) order__status=Order.STATUS_CANCELED
)), )),
has_v=Count('vouchers') has_v=Count('vouchers')
).filter(subevent=subevent).order_by(): ).filter(subevent=subevent).order_by():
@@ -134,7 +134,7 @@ def generate_seats(event, subevent, plan, mapping, blocked_guids=None):
Seat.objects.bulk_create(create_seats) Seat.objects.bulk_create(create_seats)
CartPosition.objects.filter(seat__in=[s.pk for s in current_seats.values()]).delete() CartPosition.objects.filter(seat__in=[s.pk for s in current_seats.values()]).delete()
OrderPosition.all.filter( OrderPosition.all.filter(
Q(canceled=True) | Q(order__status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)), Q(canceled=True) | Q(order__status=Order.STATUS_CANCELED),
seat__in=[s.pk for s in current_seats.values()], seat__in=[s.pk for s in current_seats.values()],
).update(seat=None) ).update(seat=None)
Seat.objects.filter(pk__in=[s.pk for s in current_seats.values()]).delete() Seat.objects.filter(pk__in=[s.pk for s in current_seats.values()]).delete()

View File

@@ -86,29 +86,11 @@ def primary_font_kwargs():
from pretix.presale.style import get_fonts from pretix.presale.style import get_fonts
choices = [('Open Sans', 'Open Sans')] choices = [('Open Sans', 'Open Sans')]
choices += sorted([ choices += [
(a, {"title": a, "data": v}) for a, v in get_fonts().items() if not v.get('pdf_only', False) (a, {"title": a, "data": v}) for a, v in get_fonts().items()
], key=lambda a: a[0])
return {
'choices': choices,
}
def restricted_plugin_kwargs():
from pretix.base.plugins import get_all_plugins
plugins_available = [
(p.module, p.name) for p in get_all_plugins(None)
if (
not p.name.startswith('.') and
getattr(p, 'restricted', False) and
not hasattr(p, 'is_available') # this means you should not really use restricted and is_available
)
] ]
return { return {
'widget': forms.CheckboxSelectMultiple, 'choices': choices,
'label': _("Allow usage of restricted plugins"),
'choices': plugins_available,
} }
@@ -127,13 +109,6 @@ class LazyI18nStringList(UserList):
DEFAULTS = { DEFAULTS = {
'allowed_restricted_plugins': {
'default': [],
'type': list,
'form_class': forms.MultipleChoiceField,
'serializer_class': serializers.MultipleChoiceField,
'form_kwargs': lambda: restricted_plugin_kwargs(),
},
'customer_accounts': { 'customer_accounts': {
'default': 'False', 'default': 'False',
'type': bool, 'type': bool,
@@ -555,11 +530,9 @@ DEFAULTS = {
'serializer_class': serializers.IntegerField, 'serializer_class': serializers.IntegerField,
'serializer_kwargs': dict( 'serializer_kwargs': dict(
min_value=0, min_value=0,
max_value=60 * 24 * 7,
), ),
'form_kwargs': dict( 'form_kwargs': dict(
min_value=0, min_value=0,
max_value=60 * 24 * 7,
label=_("Reservation period"), label=_("Reservation period"),
required=True, required=True,
help_text=_("The number of minutes the items in a user's cart are reserved for this user."), help_text=_("The number of minutes the items in a user's cart are reserved for this user."),
@@ -1197,20 +1170,7 @@ DEFAULTS = {
help_text=_("If you ask for a phone number, explain why you do so and what you will use the phone number for.") help_text=_("If you ask for a phone number, explain why you do so and what you will use the phone number for.")
) )
}, },
'show_checkin_number_user': {
'default': 'False',
'type': bool,
'serializer_class': serializers.BooleanField,
'form_class': forms.BooleanField,
'form_kwargs': dict(
label=_("Show number of check-ins to customer"),
help_text=_('With this option enabled, your customers will be able how many times they entered '
'the event. This is usually not necessary, but might be useful in combination with tickets '
'that are usable a specific number of times, so customers can see how many times they have '
'already been used. Exits or failed scans will not be counted, and the user will not see '
'the different check-in lists.'),
)
},
'ticket_download': { 'ticket_download': {
'default': 'False', 'default': 'False',
'type': bool, 'type': bool,
@@ -1916,8 +1876,6 @@ Your {event} team"""))
your order {code} for {event} has been canceled. your order {code} for {event} has been canceled.
{comment}
You can view the details of your order at You can view the details of your order at
{url} {url}

View File

@@ -34,6 +34,7 @@
import json import json
import os import os
from datetime import timedelta
from typing import List, Tuple from typing import List, Tuple
from django.db import transaction from django.db import transaction
@@ -69,11 +70,11 @@ def shred_constraints(event: Event):
max_fromto=Greatest(Max('date_to'), Max('date_from')) max_fromto=Greatest(Max('date_to'), Max('date_from'))
) )
max_date = max_date['max_fromto'] or max_date['max_to'] or max_date['max_from'] max_date = max_date['max_fromto'] or max_date['max_to'] or max_date['max_from']
if max_date is not None and max_date >= now(): if max_date is not None and max_date > now() - timedelta(days=30):
return _('Your event needs to be over to use this feature.') return _('Your event needs to be over for at least 30 days to use this feature.')
else: else:
if (event.date_to or event.date_from) >= now(): if (event.date_to or event.date_from) > now() - timedelta(days=30):
return _('Your event needs to be over to use this feature.') return _('Your event needs to be over for at least 30 days to use this feature.')
if event.live: if event.live:
return _('Your ticket shop needs to be offline to use this feature.') return _('Your ticket shop needs to be offline to use this feature.')
return None return None

View File

@@ -399,10 +399,7 @@ order_modified = EventPluginSignal()
Arguments: ``order`` Arguments: ``order``
This signal is sent out every time an order's information is modified. The order object is given This signal is sent out every time an order's information is modified. The order object is given
as the first argument. In contrast to ``order_changed``, this signal is sent out if information as the first argument.
of an order or any of it's position is changed that concerns user input, such as attendee names,
invoice addresses or question answers. If the order changes in a material way, such as changed
products, prices, or tax rates, ``order_changed`` is used instead.
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.
""" """
@@ -412,10 +409,7 @@ order_changed = EventPluginSignal()
Arguments: ``order`` Arguments: ``order``
This signal is sent out every time an order's content is changed. The order object is given This signal is sent out every time an order's content is changed. The order object is given
as the first argument. In contrast to ``modified``, this signal is sent out if the order or as the first argument.
any of its positions changes in a material way, such as changed products, prices, or tax rates,
``order_changed`` is used instead. If "only" user input is changed, such as attendee names,
invoice addresses or question answers, ``order_modified`` is used instead.
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.
""" """

View File

@@ -90,16 +90,13 @@
{% for groupkey, positions in cart %} {% for groupkey, positions in cart %}
<tr> <tr>
<td> <td>
{% if not groupkey.4 %} {# is not addon #} {% if not groupkey.4 %} {# is addon #}
{{ positions|length }}x {{ positions|length }}x
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if groupkey.4 %} {# is addon #} {% if groupkey.4 %} {# is addon #}
+ +
{% if positions|length > 1 %}
{{ positions|length }}x
{% endif %}
{% endif %} {% endif %}
{{ groupkey.0.name }}{% if groupkey.1 %} {{ groupkey.1.value }}{% endif %} {{ groupkey.0.name }}{% if groupkey.1 %} {{ groupkey.1.value }}{% endif %}
{% if groupkey.2 %} {# subevent #} {% if groupkey.2 %} {# subevent #}

View File

@@ -58,7 +58,7 @@ class BaseQuestionsViewMixin:
def _positions_for_questions(self): def _positions_for_questions(self):
raise NotImplementedError() raise NotImplementedError()
def get_question_override_sets(self, position, index): def get_question_override_sets(self, position):
return [] return []
def question_form_kwargs(self, cr): def question_form_kwargs(self, cr):
@@ -72,7 +72,7 @@ class BaseQuestionsViewMixin:
submitted at once. submitted at once.
""" """
formlist = [] formlist = []
for idx, cr in enumerate(self._positions_for_questions): for cr in self._positions_for_questions:
cartpos = cr if isinstance(cr, CartPosition) else None cartpos = cr if isinstance(cr, CartPosition) else None
orderpos = cr if isinstance(cr, OrderPosition) else None orderpos = cr if isinstance(cr, OrderPosition) else None
@@ -96,7 +96,7 @@ class BaseQuestionsViewMixin:
)) ))
) )
override_sets = self.get_question_override_sets(cr, idx) override_sets = self.get_question_override_sets(cr)
for overrides in override_sets: for overrides in override_sets:
for question_name, question_field in form.fields.items(): for question_name, question_field in form.fields.items():
if hasattr(question_field, 'question'): if hasattr(question_field, 'question'):

View File

@@ -205,8 +205,6 @@ class AsyncFormView(AsyncMixin, FormView):
Also, all form keyword arguments except ``instance`` need to be serializable. Also, all form keyword arguments except ``instance`` need to be serializable.
""" """
known_errortypes = ['ValidationError'] known_errortypes = ['ValidationError']
expected_exceptions = (ValidationError,)
task_base = ProfiledEventTask
def __init_subclass__(cls): def __init_subclass__(cls):
def async_execute(self, *, request_path, query_string, form_kwargs, locale, tz, organizer=None, event=None, user=None, session_key=None): def async_execute(self, *, request_path, query_string, form_kwargs, locale, tz, organizer=None, event=None, user=None, session_key=None):
@@ -224,7 +222,7 @@ class AsyncFormView(AsyncMixin, FormView):
elif organizer: elif organizer:
view_instance.request.organizer = organizer view_instance.request.organizer = organizer
if user: if user:
view_instance.request.user = User.objects.get(pk=user) if isinstance(user, int) else user view_instance.request.user = User.objects.get(pk=user)
if session_key: if session_key:
engine = import_module(settings.SESSION_ENGINE) engine = import_module(settings.SESSION_ENGINE)
self.SessionStore = engine.SessionStore self.SessionStore = engine.SessionStore
@@ -233,7 +231,7 @@ class AsyncFormView(AsyncMixin, FormView):
with translation.override(locale), timezone.override(pytz.timezone(tz)): with translation.override(locale), timezone.override(pytz.timezone(tz)):
form_class = view_instance.get_form_class() form_class = view_instance.get_form_class()
if form_kwargs.get('instance'): if form_kwargs.get('instance'):
form_kwargs['instance'] = cls.model.objects.get(pk=form_kwargs['instance']) cls.model.objects.get(pk=form_kwargs['instance'])
form_kwargs = view_instance.get_async_form_kwargs(form_kwargs, organizer, event) form_kwargs = view_instance.get_async_form_kwargs(form_kwargs, organizer, event)
form = form_class(**form_kwargs) form = form_class(**form_kwargs)
@@ -241,10 +239,10 @@ class AsyncFormView(AsyncMixin, FormView):
return view_instance.async_form_valid(self, form) return view_instance.async_form_valid(self, form)
cls.async_execute = app.task( cls.async_execute = app.task(
base=cls.task_base, base=ProfiledEventTask,
bind=True, bind=True,
name=cls.__module__ + '.' + cls.__name__ + '.async_execute', name=cls.__module__ + '.' + cls.__name__ + '.async_execute',
throws=cls.expected_exceptions throws=(ValidationError,)
)(async_execute) )(async_execute)
def async_form_valid(self, task, form): def async_form_valid(self, task, form):

View File

@@ -523,7 +523,6 @@ class EventSettingsForm(SettingsForm):
'last_order_modification_date', 'last_order_modification_date',
'allow_modifications_after_checkin', 'allow_modifications_after_checkin',
'checkout_show_copy_answers_button', 'checkout_show_copy_answers_button',
'show_checkin_number_user',
'primary_color', 'primary_color',
'theme_color_success', 'theme_color_success',
'theme_color_danger', 'theme_color_danger',
@@ -1075,7 +1074,7 @@ class MailSettingsForm(SettingsForm):
'mail_text_order_free': ['event', 'order'], 'mail_text_order_free': ['event', 'order'],
'mail_text_order_free_attendee': ['event', 'order', 'position'], 'mail_text_order_free_attendee': ['event', 'order', 'position'],
'mail_text_order_changed': ['event', 'order'], 'mail_text_order_changed': ['event', 'order'],
'mail_text_order_canceled': ['event', 'order', 'comment'], 'mail_text_order_canceled': ['event', 'order'],
'mail_text_order_expire_warning': ['event', 'order'], 'mail_text_order_expire_warning': ['event', 'order'],
'mail_text_order_custom_mail': ['event', 'order'], 'mail_text_order_custom_mail': ['event', 'order'],
'mail_text_download_reminder': ['event', 'order'], 'mail_text_download_reminder': ['event', 'order'],

View File

@@ -255,6 +255,7 @@ class OrderFilterForm(FilterForm):
| Q(pk__in=matching_invoices) | Q(pk__in=matching_invoices)
| Q(pk__in=matching_positions) | Q(pk__in=matching_positions)
| Q(pk__in=matching_invoice_addresses) | Q(pk__in=matching_invoice_addresses)
| Q(pk__in=matching_invoices)
) )
for recv, q in order_search_filter_q.send(sender=getattr(self, 'event', None), query=u): for recv, q in order_search_filter_q.send(sender=getattr(self, 'event', None), query=u):
mainq = mainq | q mainq = mainq | q
@@ -1859,11 +1860,11 @@ class VoucherFilterForm(FilterForm):
for i in self.event.items.prefetch_related('variations').all(): for i in self.event.items.prefetch_related('variations').all():
variations = list(i.variations.all()) variations = list(i.variations.all())
if variations: if variations:
choices.append((str(i.pk), _('{product} Any variation').format(product=str(i)))) choices.append((str(i.pk), _('{product} Any variation').format(product=i.name)))
for v in variations: for v in variations:
choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (str(i), v.value))) choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (i.name, v.value)))
else: else:
choices.append((str(i.pk), str(i))) choices.append((str(i.pk), i.name))
for q in self.event.quotas.all(): for q in self.event.quotas.all():
choices.append(('q-%d' % q.pk, _('Any product in quota "{quota}"').format(quota=q))) choices.append(('q-%d' % q.pk, _('Any product in quota "{quota}"').format(quota=q)))
self.fields['itemvar'].choices = choices self.fields['itemvar'].choices = choices
@@ -1880,7 +1881,7 @@ class VoucherFilterForm(FilterForm):
if s == '<>': if s == '<>':
qs = qs.filter(Q(tag__isnull=True) | Q(tag='')) qs = qs.filter(Q(tag__isnull=True) | Q(tag=''))
elif s[0] == '"' and s[-1] == '"': elif s[0] == '"' and s[-1] == '"':
qs = qs.filter(tag__exact=s[1:-1]) qs = qs.filter(tag__iexact=s[1:-1])
else: else:
qs = qs.filter(tag__icontains=s) qs = qs.filter(tag__icontains=s)
@@ -2120,7 +2121,7 @@ class CheckinFilterForm(FilterForm):
self.event = kwargs.pop('event') self.event = kwargs.pop('event')
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['device'].queryset = self.event.organizer.devices.all().order_by('device_id') self.fields['device'].queryset = self.event.organizer.devices.all()
self.fields['gate'].queryset = self.event.organizer.gates.all() self.fields['gate'].queryset = self.event.organizer.gates.all()
self.fields['checkin_list'].queryset = self.event.checkin_lists.all() self.fields['checkin_list'].queryset = self.event.checkin_lists.all()
@@ -2141,11 +2142,11 @@ class CheckinFilterForm(FilterForm):
for i in self.event.items.prefetch_related('variations').all(): for i in self.event.items.prefetch_related('variations').all():
variations = list(i.variations.all()) variations = list(i.variations.all())
if variations: if variations:
choices.append((str(i.pk), _('{product} Any variation').format(product=str(i)))) choices.append((str(i.pk), _('{product} Any variation').format(product=i.name)))
for v in variations: for v in variations:
choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (str(i), v.value))) choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (i.name, v.value)))
else: else:
choices.append((str(i.pk), str(i))) choices.append((str(i.pk), i.name))
self.fields['itemvar'].choices = choices self.fields['itemvar'].choices = choices
def filter_qs(self, qs): def filter_qs(self, qs):

View File

@@ -434,9 +434,6 @@ class ItemCreateForm(I18nModelForm):
if self.cleaned_data.get('copy_from'): if self.cleaned_data.get('copy_from'):
for question in self.cleaned_data['copy_from'].questions.all(): for question in self.cleaned_data['copy_from'].questions.all():
question.items.add(instance) question.items.add(instance)
question.log_action('pretix.event.question.changed', user=self.user, data={
'item_added': self.instance.pk
})
for a in self.cleaned_data['copy_from'].addons.all(): for a in self.cleaned_data['copy_from'].addons.all():
instance.addons.create(addon_category=a.addon_category, min_count=a.min_count, max_count=a.max_count, instance.addons.create(addon_category=a.addon_category, min_count=a.min_count, max_count=a.max_count,
price_included=a.price_included, position=a.position, price_included=a.price_included, position=a.position,
@@ -566,7 +563,7 @@ class ItemUpdateForm(I18nModelForm):
if d['tax_rule'] and d['tax_rule'].rate > 0: if d['tax_rule'] and d['tax_rule'].rate > 0:
self.add_error( self.add_error(
'tax_rule', 'tax_rule',
_("Gift card products should use a tax rule with a rate of 0 percent since sales tax will be applied when the gift card is redeemed.") _("Gift card products should not be associated with non-zero tax rates since sales tax will be applied when the gift card is redeemed.")
) )
if d['admission']: if d['admission']:
self.add_error( self.add_error(
@@ -630,9 +627,7 @@ class ItemUpdateForm(I18nModelForm):
'class': 'scrolling-multiple-choice' 'class': 'scrolling-multiple-choice'
}), }),
'generate_tickets': TicketNullBooleanSelect(), 'generate_tickets': TicketNullBooleanSelect(),
'show_quota_left': ShowQuotaNullBooleanSelect(), 'show_quota_left': ShowQuotaNullBooleanSelect()
'max_per_order': forms.widgets.NumberInput(attrs={'min': 0}),
'min_per_order': forms.widgets.NumberInput(attrs={'min': 0}),
} }

View File

@@ -167,12 +167,6 @@ class CancelForm(ForceQuotaConfirmationForm):
initial=True, initial=True,
required=False required=False
) )
comment = forms.CharField(
label=_('Comment (will be sent to the user)'),
help_text=_('Will be included in the notification email when the respective placeholder is present in the '
'configured email text.'),
required=False,
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -488,9 +482,6 @@ class OrderPositionChangeForm(forms.Form):
self.fields['tax_rule'].queryset = instance.event.tax_rules.all() self.fields['tax_rule'].queryset = instance.event.tax_rules.all()
self.fields['tax_rule'].label_from_instance = self.taxrule_label_from_instance self.fields['tax_rule'].label_from_instance = self.taxrule_label_from_instance
if instance.addon_to_id:
del self.fields['operation_split']
if not instance.seat and not ( if not instance.seat and not (
instance.item.seat_category_mappings.filter(subevent=instance.subevent).exists() instance.item.seat_category_mappings.filter(subevent=instance.subevent).exists()
): ):
@@ -621,7 +612,7 @@ class OrderMailForm(forms.Form):
) )
attach_tickets = forms.BooleanField( attach_tickets = forms.BooleanField(
label=_("Attach tickets"), label=_("Attach tickets"),
help_text=_("Will be ignored if tickets exceed a given size limit to ensure email deliverability."), help_text=_("Will be ignored if all tickets in this order exceed a given size limit to ensure email deliverability."),
required=False required=False
) )
attach_invoices = forms.ModelMultipleChoiceField( attach_invoices = forms.ModelMultipleChoiceField(
@@ -755,17 +746,16 @@ class EventCancelForm(forms.Form):
auto_refund = forms.BooleanField( auto_refund = forms.BooleanField(
label=_('Automatically refund money if possible'), label=_('Automatically refund money if possible'),
initial=True, initial=True,
required=False, required=False
help_text=_('Only available for payment method that support automatic refunds.')
) )
manual_refund = forms.BooleanField( manual_refund = forms.BooleanField(
label=_('Create refund in the manual refund to-do list'), label=_('Create manual refund if the payment method does not support automatic refunds'),
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_auto_refund'}),
initial=True, initial=True,
required=False, required=False,
help_text=_('Manual refunds will be created which will be listed in the manual refund to-do list. ' help_text=_('If checked, all payments with a payment method not supporting automatic refunds will be on your '
'When combined with the automatic refund functionally, only payments with a payment method not ' 'manual refund to-do list. Do not check if you want to refund some of the orders by offsetting '
'supporting automatic refunds will be on your manual refund to-do list. Do not check if you want ' 'with different orders or issuing gift cards.')
'to refund some of the orders by offsetting with different orders or issuing gift cards.')
) )
refund_as_giftcard = forms.BooleanField( refund_as_giftcard = forms.BooleanField(
label=_('Refund order value to a gift card instead instead of the original payment method'), label=_('Refund order value to a gift card instead instead of the original payment method'),
@@ -850,7 +840,7 @@ class EventCancelForm(forms.Form):
label=_("Subject"), label=_("Subject"),
required=True, required=True,
widget_kwargs={'attrs': {'data-display-dependency': '#id_send'}}, widget_kwargs={'attrs': {'data-display-dependency': '#id_send'}},
initial=LazyI18nString.from_gettext(gettext_noop('Canceled: {event}')), initial=_('Canceled: {event}'),
widget=I18nTextInput, widget=I18nTextInput,
locales=self.event.settings.get('locales'), locales=self.event.settings.get('locales'),
) )
@@ -876,7 +866,7 @@ class EventCancelForm(forms.Form):
self.fields['send_waitinglist_subject'] = I18nFormField( self.fields['send_waitinglist_subject'] = I18nFormField(
label=_("Subject"), label=_("Subject"),
required=True, required=True,
initial=LazyI18nString.from_gettext(gettext_noop('Canceled: {event}')), initial=_('Canceled: {event}'),
widget=I18nTextInput, widget=I18nTextInput,
widget_kwargs={'attrs': {'data-display-dependency': '#id_send_waitinglist'}}, widget_kwargs={'attrs': {'data-display-dependency': '#id_send_waitinglist'}},
locales=self.event.settings.get('locales'), locales=self.event.settings.get('locales'),

View File

@@ -39,7 +39,6 @@ from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Q from django.db.models import Q
from django.forms.utils import ErrorDict
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _, pgettext_lazy from django.utils.translation import gettext_lazy as _, pgettext_lazy
@@ -269,69 +268,6 @@ class DeviceForm(forms.ModelForm):
} }
class DeviceBulkEditForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
organizer = kwargs.pop('organizer')
self.mixed_values = kwargs.pop('mixed_values')
self.queryset = kwargs.pop('queryset')
super().__init__(*args, **kwargs)
self.fields['limit_events'].queryset = organizer.events.all().order_by(
'-has_subevents', '-date_from'
)
self.fields['gate'].queryset = organizer.gates.all()
def clean(self):
d = super().clean()
if self.prefix + '__events' in self.data.getlist('_bulk') and not d['all_events'] and not d['limit_events']:
raise ValidationError(_('Your device will not have access to anything, please select some events.'))
return d
class Meta:
model = Device
fields = ['all_events', 'limit_events', 'security_profile', 'gate']
widgets = {
'limit_events': forms.CheckboxSelectMultiple(attrs={
'data-inverse-dependency': '#id_all_events',
'class': 'scrolling-multiple-choice scrolling-multiple-choice-large',
}),
}
field_classes = {
'limit_events': SafeEventMultipleChoiceField
}
def save(self, commit=True):
objs = list(self.queryset)
fields = set()
check_map = {
'all_events': '__events',
'limit_events': '__events',
}
for k in self.fields:
cb_val = self.prefix + check_map.get(k, k)
if cb_val not in self.data.getlist('_bulk'):
continue
fields.add(k)
for obj in objs:
if k == 'limit_events':
getattr(obj, k).set(self.cleaned_data[k])
else:
setattr(obj, k, self.cleaned_data[k])
if fields:
Device.objects.bulk_update(objs, [f for f in fields if f != 'limit_events'], 200)
def full_clean(self):
if len(self.data) == 0:
# form wasn't submitted
self._errors = ErrorDict()
return
super().full_clean()
class OrganizerSettingsForm(SettingsForm): class OrganizerSettingsForm(SettingsForm):
timezone = forms.ChoiceField( timezone = forms.ChoiceField(
choices=((a, a) for a in common_timezones), choices=((a, a) for a in common_timezones),
@@ -350,7 +286,6 @@ class OrganizerSettingsForm(SettingsForm):
required=False, required=False,
) )
auto_fields = [ auto_fields = [
'allowed_restricted_plugins',
'customer_accounts', 'customer_accounts',
'customer_accounts_link_by_email', 'customer_accounts_link_by_email',
'invoice_regenerate_allowed', 'invoice_regenerate_allowed',
@@ -404,12 +339,7 @@ class OrganizerSettingsForm(SettingsForm):
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
is_admin = kwargs.pop('is_admin', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if not is_admin:
del self.fields['allowed_restricted_plugins']
self.fields['name_scheme'].choices = ( self.fields['name_scheme'].choices = (
(k, _('Ask for {fields}, display like {example}').format( (k, _('Ask for {fields}, display like {example}').format(
fields=' + '.join(str(vv[1]) for vv in v['fields']), fields=' + '.join(str(vv[1]) for vv in v['fields']),
@@ -492,7 +422,6 @@ class MailSettingsForm(SettingsForm):
if f == 'full_name': if f == 'full_name':
continue continue
placeholders['name_%s' % f] = name_scheme['sample'][f] placeholders['name_%s' % f] = name_scheme['sample'][f]
placeholders['name_for_salutation'] = _("Mr Doe")
return placeholders return placeholders
def _set_field_placeholders(self, fn, base_parameters): def _set_field_placeholders(self, fn, base_parameters):

View File

@@ -385,12 +385,6 @@ class VoucherBulkForm(VoucherForm):
if vouchers.exists(): if vouchers.exists():
raise ValidationError(_('A voucher with one of these codes already exists.')) raise ValidationError(_('A voucher with one of these codes already exists.'))
codes_seen = set()
for c in data['codes']:
if c in codes_seen:
raise ValidationError(_('The voucher code {code} appears in your list twice.').format(code=c))
codes_seen.add(c)
if data.get('send') and not all([data.get('send_subject'), data.get('send_message'), data.get('send_recipients')]): if data.get('send') and not all([data.get('send_subject'), data.get('send_message'), data.get('send_recipients')]):
raise ValidationError(_('If vouchers should be sent by email, subject, message and recipients need to be specified.')) raise ValidationError(_('If vouchers should be sent by email, subject, message and recipients need to be specified.'))

View File

@@ -341,12 +341,13 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.paid': _('The order has been marked as paid.'), 'pretix.event.order.paid': _('The order has been marked as paid.'),
'pretix.event.order.cancellationrequest.deleted': _('The cancellation request has been deleted.'), 'pretix.event.order.cancellationrequest.deleted': _('The cancellation request has been deleted.'),
'pretix.event.order.refunded': _('The order has been refunded.'), 'pretix.event.order.refunded': _('The order has been refunded.'),
'pretix.event.order.canceled': _('The order has been canceled.'),
'pretix.event.order.reactivated': _('The order has been reactivated.'), 'pretix.event.order.reactivated': _('The order has been reactivated.'),
'pretix.event.order.deleted': _('The test mode order {code} has been deleted.'), 'pretix.event.order.deleted': _('The test mode order {code} has been deleted.'),
'pretix.event.order.placed': _('The order has been created.'), 'pretix.event.order.placed': _('The order has been created.'),
'pretix.event.order.placed.require_approval': _('The order requires approval before it can continue to be processed.'), 'pretix.event.order.placed.require_approval': _('The order requires approval before it can continue to be processed.'),
'pretix.event.order.approved': _('The order has been approved.'), 'pretix.event.order.approved': _('The order has been approved.'),
'pretix.event.order.denied': _('The order has been denied (comment: "{comment}").'), 'pretix.event.order.denied': _('The order has been denied.'),
'pretix.event.order.contact.changed': _('The email address has been changed from "{old_email}" ' 'pretix.event.order.contact.changed': _('The email address has been changed from "{old_email}" '
'to "{new_email}".'), 'to "{new_email}".'),
'pretix.event.order.contact.confirmed': _('The email address has been confirmed to be working (the user clicked on a link ' 'pretix.event.order.contact.confirmed': _('The email address has been confirmed to be working (the user clicked on a link '
@@ -422,7 +423,6 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.voucher.added': _('The voucher has been created.'), 'pretix.voucher.added': _('The voucher has been created.'),
'pretix.voucher.sent': _('The voucher has been sent to {recipient}.'), 'pretix.voucher.sent': _('The voucher has been sent to {recipient}.'),
'pretix.voucher.added.waitinglist': _('The voucher has been created and sent to a person on the waiting list.'), 'pretix.voucher.added.waitinglist': _('The voucher has been created and sent to a person on the waiting list.'),
'pretix.voucher.expired.waitinglist': _('The voucher has been set to expire because the recipient removed themselves from the waiting list.'),
'pretix.voucher.changed': _('The voucher has been changed.'), 'pretix.voucher.changed': _('The voucher has been changed.'),
'pretix.voucher.deleted': _('The voucher has been deleted.'), 'pretix.voucher.deleted': _('The voucher has been deleted.'),
'pretix.voucher.redeemed': _('The voucher has been redeemed in order {order_code}.'), 'pretix.voucher.redeemed': _('The voucher has been redeemed in order {order_code}.'),
@@ -531,27 +531,6 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
bleach.clean(logentry.parsed_data.get('msg'), tags=[], strip=True) bleach.clean(logentry.parsed_data.get('msg'), tags=[], strip=True)
) )
if logentry.action_type == 'pretix.event.order.canceled':
comment = logentry.parsed_data.get('comment')
if comment:
return _('The order has been canceled (comment: "{comment}").').format(comment=comment)
else:
return _('The order has been canceled.')
if logentry.action_type in ('pretix.control.views.checkin.reverted', 'pretix.event.checkin.reverted'):
if 'list' in data:
try:
checkin_list = sender.checkin_lists.get(pk=data.get('list')).name
except CheckinList.DoesNotExist:
checkin_list = _("(unknown)")
else:
checkin_list = _("(unknown)")
return _('The check-in of position #{posid} on list "{list}" has been reverted.').format(
posid=data.get('positionid'),
list=checkin_list,
)
if sender and logentry.action_type.startswith('pretix.event.checkin'): if sender and logentry.action_type.startswith('pretix.event.checkin'):
return _display_checkin(sender, logentry) return _display_checkin(sender, logentry)
@@ -580,6 +559,20 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
list=checkin_list list=checkin_list
) )
if logentry.action_type in ('pretix.control.views.checkin.reverted', 'pretix.event.checkin.reverted'):
if 'list' in data:
try:
checkin_list = sender.checkin_lists.get(pk=data.get('list')).name
except CheckinList.DoesNotExist:
checkin_list = _("(unknown)")
else:
checkin_list = _("(unknown)")
return _('The check-in of position #{posid} on list "{list}" has been reverted.').format(
posid=data.get('positionid'),
list=checkin_list,
)
if logentry.action_type == 'pretix.team.member.added': if logentry.action_type == 'pretix.team.member.added':
return _('{user} has been added to the team.').format(user=data.get('email')) return _('{user} has been added to the team.').format(user=data.get('email'))

View File

@@ -142,18 +142,6 @@ class PermissionMiddleware:
return redirect(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'] == '-':
# This is a hack that just takes the user to ANY event. It's useful to link to features in support
# or documentation.
ev = request.user.get_events_with_any_permission().order_by('-date_from').first()
if not ev:
raise Http404(_("The selected event was not found or you "
"have no permission to administrate it."))
k = dict(url.kwargs)
k['organizer'] = ev.organizer.slug
k['event'] = ev.slug
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(
slug=url.kwargs['event'], slug=url.kwargs['event'],
@@ -169,17 +157,6 @@ class PermissionMiddleware:
else: else:
request.eventpermset = request.user.get_event_permission_set(request.organizer, request.event) request.eventpermset = request.user.get_event_permission_set(request.organizer, request.event)
elif 'organizer' in url.kwargs: elif 'organizer' in url.kwargs:
if url.kwargs['organizer'] == '-':
# This is a hack that just takes the user to ANY organizer. It's useful to link to features in support
# or documentation.
org = request.user.get_organizers_with_any_permission().first()
if not org:
raise Http404(_("The selected organizer was not found or you "
"have no permission to administrate it."))
k = dict(url.kwargs)
k['organizer'] = org.slug
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'],
).first() ).first()

View File

@@ -57,11 +57,7 @@
{% endif %} {% endif %}
{% elif payment_info.payment_type == "izettle" %} {% elif payment_info.payment_type == "izettle" %}
<dt>{% trans "Payment provider" %}</dt> <dt>{% trans "Payment provider" %}</dt>
<dd>Zettle</dd> <dd>iZettle</dd>
{% if payment_info.payment_data.reference %}
<dt>{% trans "Payment reference" %}</dt>
<dd>{{ payment_info.payment_data.reference }}</dd>
{% endif %}
<dt>{% trans "Payment Application" %}</dt> <dt>{% trans "Payment Application" %}</dt>
<dd>{{ payment_info.payment_data.applicationName }}</dd> <dd>{{ payment_info.payment_data.applicationName }}</dd>
<dt>{% trans "Card Entry Mode" %}</dt> <dt>{% trans "Card Entry Mode" %}</dt>

View File

@@ -93,17 +93,6 @@
</div> </div>
</div> </div>
<div class="alert alert-info" v-if="missingItems.length">
<p>
{% trans "Your rule always filters by product or variation, but the following products or variations are not contained in any of your rule parts so people with these tickets will not get in:" %}
</p>
<ul>
<li v-for="h in missingItems">{{ "{" }}{h}{{ "}" }}</li>
</ul>
<p>
{% trans "Please double-check if this was intentional." %}
</p>
</div>
</div> </div>
<div class="disabled-withoutjs sr-only"> <div class="disabled-withoutjs sr-only">
{{ form.rules }} {{ form.rules }}
@@ -116,7 +105,6 @@
</button> </button>
</div> </div>
</form> </form>
{{ items|json_script:"items" }}
{% compress js %} {% compress js %}
<script type="text/javascript" src="{% static "vuejs/vue.js" %}"></script> <script type="text/javascript" src="{% static "vuejs/vue.js" %}"></script>
@@ -130,7 +118,6 @@
<script type="text/javascript" src="{% static "d3/d3-transition.v2.js" %}"></script> <script type="text/javascript" src="{% static "d3/d3-transition.v2.js" %}"></script>
<script type="text/javascript" src="{% static "d3/d3-drag.v2.js" %}"></script> <script type="text/javascript" src="{% static "d3/d3-drag.v2.js" %}"></script>
<script type="text/javascript" src="{% static "d3/d3-zoom.v2.js" %}"></script> <script type="text/javascript" src="{% static "d3/d3-zoom.v2.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/checkinrules/jsonlogic-boolalg.js" %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/datetimefield.vue' %}"></script> <script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/datetimefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/timefield.vue' %}"></script> <script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/timefield.vue' %}"></script>
<script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/lookup-select2.vue' %}"></script> <script type="text/vue" src="{% static 'pretixcontrol/js/ui/checkinrules/lookup-select2.vue' %}"></script>

View File

@@ -56,7 +56,7 @@
{% endfor %} {% endfor %}
</div> </div>
<p class=""> <p class="">
<a href="{% url "control:events" %}?ordering=date_from&status=date_past" class=""> <a href="{% url "control:events" %}?ordering=date_from&status=-date_to" class="">
{% trans "View all recent events" %} {% trans "View all recent events" %}
</a> </a>
</p> </p>

View File

@@ -1,36 +0,0 @@
{% load i18n %}
{% if show_meta %}
{% if plugin.author %}
<p class="meta text-muted">
{% blocktrans trimmed with a=plugin.author %}
by <em>{{ a }}</em>
{% endblocktrans %}</p>
{% endif %}
{% endif %}
<p>{{ plugin.description|safe }}</p>
{% if plugin.restricted and plugin.module not in request.event.settings.allowed_restricted_plugins %}
<p class="text-muted">
<span class="fa fa-info-circle" aria-hidden="true"></span>
{% trans "This plugin needs to be enabled by a system administrator for your account." %}
</p>
{% endif %}
{% if plugin.app.compatibility_errors %}
<div class="alert alert-warning">
{% trans "This plugin cannot be enabled for the following reasons:" %}
<ul>
{% for e in plugin.app.compatibility_errors %}
<li>{{ e }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if plugin.app.compatibility_warnings %}
<div class="alert alert-warning">
{% trans "This plugin reports the following problems:" %}
<ul>
{% for e in plugin.app.compatibility_warnings %}
<li>{{ e }}</li>
{% endfor %}
</ul>
</div>
{% endif %}

View File

@@ -1,15 +1,8 @@
{% extends "pretixcontrol/event/settings_base.html" %} {% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %} {% load i18n %}
{% load static %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% block inside %} {% block inside %}
<h1>{% trans "Available plugins" %}</h1> <h1>{% trans "Installed plugins" %}</h1>
<p>
{% blocktrans trimmed %}
On this page, you can choose plugins you want to enable for your event. Plugins might bring additional
software functionality, connect your event to third-party services, or apply other forms of customizations.
{% endblocktrans %}
</p>
<form action="" method="post" class="form-horizontal form-plugins"> <form action="" method="post" class="form-horizontal form-plugins">
{% csrf_token %} {% csrf_token %}
{% if "success" in request.GET %} {% if "success" in request.GET %}
@@ -18,71 +11,71 @@
</div> </div>
{% endif %} {% endif %}
<div class="tabbed-form"> <div class="tabbed-form">
{% for cat, catlabel, plist, has_pictures in plugins %} {% for cat, catlabel, plist in plugins %}
<fieldset> <fieldset>
<legend>{{ catlabel }}</legend> <legend>{{ catlabel }}</legend>
<div class="plugin-list"> <div class="table-responsive">
{% for plugin in plist %} <table class="table">
<div class="plugin-container {% if plugin.featured %}featured-plugin{% endif %}"> {% for plugin in plist %}
{% if plugin.featured %} <tr class="{% if plugin.app.compatibility_errors %}warning{% elif plugin.module in plugins_active %}success{% else %}default{% endif %}">
<div class="panel panel-default"> <td>
<div class="panel-body"> <strong>{{ plugin.name }}</strong>
{% endif %} {% if plugin.author %}
<div class="plugin-text"> <p class="meta text-muted">
{% if plugin.featured or plugin.experimental %} {% blocktrans trimmed with v=plugin.version a=plugin.author %}
<p class="text-muted"> Version {{ v }} by <em>{{ a }}</em>
{% if plugin.featured %} {% endblocktrans %}</p>
<span class="fa fa-thumbs-up" aria-hidden="true"></span>
{% trans "Top recommendation" %}
{% endif %}
{% if plugin.experimental %}
<span class="fa fa-flask" aria-hidden="true"></span>
{% trans "Experimental feature" %}
{% endif %}
</p>
{% endif %}
{% if plugin.picture %}
<p><img src="{% static plugin.picture %}" class="plugin-picture"></p>
{% endif %}
<h4>
{{ plugin.name }}
{% if show_meta %}
<span class="text-muted text-sm">{{ plugin.version }}</span>
{% endif %}
{% if plugin.module in plugins_active %}
<span class="label label-success">
<span class="fa fa-check" aria-hidden="true"></span>
{% trans "Active" %}
</span>
{% endif %}
</h4>
{% include "pretixcontrol/event/fragment_plugin_description.html" with plugin=plugin %}
</div>
{% if plugin.app.compatibility_errors %}
<div class="plugin-action">
<span class="text-muted">{% trans "Incompatible" %}</span>
</div>
{% elif plugin.restricted and plugin.module not in request.event.settings.allowed_restricted_plugins %}
<div class="plugin-action">
<span class="text-muted">{% trans "Not available" %}</span>
</div>
{% elif plugin.module in plugins_active %}
<div class="plugin-action flip">
<button class="btn btn-default{% if plugin.featured %} btn-lg{% endif %}" name="plugin:{{ plugin.module }}"
value="disable">{% trans "Disable" %}</button>
</div>
{% else %} {% else %}
<div class="plugin-action flip"> <p class="meta text-muted">
<button class="btn btn-primary{% if plugin.featured %} btn-lg{% endif %}" name="plugin:{{ plugin.module }}" {% blocktrans trimmed with v=plugin.version a=plugin.author %}
value="enable">{% trans "Enable" %}</button> Version {{ v }}
{% endblocktrans %}</p>
{% endif %}
<p>{{ plugin.description }}</p>
{% if plugin.restricted and not request.user.is_staff %}
<span class="text-muted">
{% trans "This plugin needs to be enabled by a system administrator for your event." %}
</span>
{% endif %}
{% if plugin.app.compatibility_errors %}
<div class="alert alert-warning">
{% trans "This plugin cannot be enabled for the following reasons:" %}
<ul>
{% for e in plugin.app.compatibility_errors %}
<li>{{ e }}</li>
{% endfor %}
</ul>
</div> </div>
{% endif %} {% endif %}
{% if plugin.featured %} {% if plugin.app.compatibility_warnings %}
</div> <div class="alert alert-warning">
</div> {% trans "This plugin reports the following problems:" %}
{% endif %} <ul>
</div> {% for e in plugin.app.compatibility_warnings %}
{% endfor %} <li>{{ e }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</td>
<td class="text-right flip" width="20%">
{% if plugin.app.compatibility_errors %}
<button class="btn disabled btn-block btn-default"
disabled="disabled">{% trans "Incompatible" %}</button>
{% elif plugin.restricted and not staff_session %}
<button class="btn disabled btn-block btn-default"
disabled="disabled">{% trans "Not available" %}</button>
{% elif plugin.module in plugins_active %}
<button class="btn btn-default btn-block" name="plugin:{{ plugin.module }}"
value="disable">{% trans "Disable" %}</button>
{% else %}
<button class="btn btn-default btn-block" name="plugin:{{ plugin.module }}"
value="enable">{% trans "Enable" %}</button>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div> </div>
</fieldset> </fieldset>
{% endfor %} {% endfor %}

View File

@@ -221,7 +221,6 @@
{% bootstrap_field sform.show_items_outside_presale_period layout="control" %} {% bootstrap_field sform.show_items_outside_presale_period layout="control" %}
{% bootstrap_field sform.last_order_modification_date layout="control" %} {% bootstrap_field sform.last_order_modification_date layout="control" %}
{% bootstrap_field sform.allow_modifications_after_checkin layout="control" %} {% bootstrap_field sform.allow_modifications_after_checkin layout="control" %}
{% bootstrap_field sform.show_checkin_number_user layout="control" %}
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{% trans "Display" %}</legend> <legend>{% trans "Display" %}</legend>

View File

@@ -20,11 +20,6 @@
<div class="form-group"> <div class="form-group">
<label class="col-md-3 control-label">{% trans "Product type" %}</label> <label class="col-md-3 control-label">{% trans "Product type" %}</label>
<div class="col-md-9"> <div class="col-md-9">
{% for e in form.errors.admission %}
<div class="alert alert-danger has-error">
{{ e }}
</div>
{% endfor %}
<div class="big-radio radio"> <div class="big-radio radio">
<label> <label>
<input type="radio" value="on" name="{{ form.admission.html_name }}" {% if form.admission.value %}checked{% endif %}> <input type="radio" value="on" name="{{ form.admission.html_name }}" {% if form.admission.value %}checked{% endif %}>

View File

@@ -1,7 +1,6 @@
{% extends "pretixcontrol/event/base.html" %} {% extends "pretixcontrol/event/base.html" %}
{% load i18n %} {% load i18n %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% load money %}
{% block title %} {% block title %}
{% trans "Cancel order" %} {% trans "Cancel order" %}
{% endblock %} {% endblock %}
@@ -23,22 +22,13 @@
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="status" value="c"/> <input type="hidden" name="status" value="c"/>
{% bootstrap_form_errors form %} {% bootstrap_form_errors form %}
{% if form.cancellation_fee %}
{% if fee %}
{% with fee|money:request.event.currency as f %}
<p>{% blocktrans trimmed with fee="<strong>"|add:f|add:"</strong>"|safe %}
The configured cancellation fee for a self-service cancellation would be {{ fee }} for this
order, but for a cancellation performed by you, you need to set the cancellation fee here:
{% endblocktrans %}</p>
{% endwith %}
{% endif %}
{% bootstrap_field form.cancellation_fee layout='' %}
{% endif %}
{% bootstrap_field form.send_email layout='' %} {% bootstrap_field form.send_email layout='' %}
{% bootstrap_field form.comment layout='' %}
{% if form.cancel_invoice %} {% if form.cancel_invoice %}
{% bootstrap_field form.cancel_invoice layout='' %} {% bootstrap_field form.cancel_invoice layout='' %}
{% endif %} {% endif %}
{% if form.cancellation_fee %}
{% bootstrap_field form.cancellation_fee layout='' %}
{% endif %}
<div class="row checkout-button-row"> <div class="row checkout-button-row">
<div class="col-md-4"> <div class="col-md-4">
<a class="btn btn-block btn-default btn-lg" <a class="btn btn-block btn-default btn-lg"

View File

@@ -202,12 +202,10 @@
</div> </div>
{% bootstrap_field position.form.operation_cancel layout='inline' %} {% bootstrap_field position.form.operation_cancel layout='inline' %}
{% if position.form.operation_split %} {% bootstrap_field position.form.operation_split layout='inline' %}
{% bootstrap_field position.form.operation_split layout='inline' %}
{% endif %}
{% if position.addons.exists %} {% if position.addons.exists %}
<em class="text-danger"> <em class="text-danger">
{% trans "Removing or splitting this position will also remove or split all add-ons to this position." %} {% trans "Removing this position will also remove all add-ons to this position." %}
</em> </em>
{% endif %} {% endif %}
</div> </div>

View File

@@ -8,7 +8,6 @@
{% block content %} {% block content %}
<h1> <h1>
{% trans "Refund order" %} {% trans "Refund order" %}
<small class="text-muted">{{ full_refund|money:request.event.currency }}</small>
<a class="btn btn-link btn-lg" <a class="btn btn-link 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 %}">
{% blocktrans trimmed with order=order.code %} {% blocktrans trimmed with order=order.code %}

View File

@@ -11,8 +11,7 @@
{% endif %} {% endif %}
</h1> </h1>
{% for e in exporters %} {% for e in exporters %}
<details class="panel panel-default" <details class="panel panel-default" {% if "identifier" in request.GET or "exporter" in request.POST %}open{% endif %}>
{% if request.GET.identifier == e.identifier or request.POST.exporter == e.identifier %}open{% endif %}>
<summary class="panel-heading"> <summary class="panel-heading">
<h3 class="panel-title"> <h3 class="panel-title">
{{ e.verbose_name }} {{ e.verbose_name }}

View File

@@ -1,46 +0,0 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block inner %}
<h1>
{% trans "Change multiple devices" %}
<small>
{% blocktrans trimmed with number=devices.count %}
{{ number }} selected
{% endblocktrans %}
</small>
</h1>
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form_errors form %}
<div class="hidden">
{% for d in devices %}
<input type="hidden" name="device" value="{{ d.pk }}">
{% endfor %}
</div>
<fieldset>
<legend>{% trans "General" %}</legend>
<div class="bulk-edit-field-group">
<label class="field-toggle">
<input type="checkbox" name="_bulk" value="{{ form.prefix }}__events" {% if form.prefix|add:"__events" in bulk_selected %}checked{% endif %}>
{% trans "change" context "form_bulk" %}
</label>
<div class="field-content">
{% bootstrap_field form.all_events layout="control" %}
{% bootstrap_field form.limit_events layout="control" %}
</div>
</div>
</fieldset>
<p>&nbsp;</p>
<fieldset>
<legend>{% trans "Advanced settings" %}</legend>
{% bootstrap_field form.security_profile layout="bulkedit" %}
{% bootstrap_field form.gate layout="bulkedit" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -21,7 +21,7 @@
</p> </p>
<a href="{% url "control:organizer.device.add" organizer=request.organizer.slug %}" <a href="{% url "control:organizer.device.add" organizer=request.organizer.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Connect a device" %}</a> class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Connect a device" %}</a>
</div> </div>
{% else %} {% else %}
<div class="panel panel-default"> <div class="panel panel-default">
@@ -53,139 +53,101 @@
</div> </div>
<p> <p>
<a href="{% url "control:organizer.device.add" organizer=request.organizer.slug %}" <a href="{% url "control:organizer.device.add" organizer=request.organizer.slug %}"
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Connect a device" %}</a> class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Connect a device" %}</a>
</p> </p>
<form action="{% url "control:organizer.device.bulk_edit" organizer=request.organizer.slug %}" method="post"> <div class="table-responsive">
{% csrf_token %} <table class="table table-condensed table-hover">
<div class="hidden"> <thead>
{{ filter_form.as_p }} <tr>
</div> <th>{% trans "Device ID" %}
<div class="table-responsive"> <a href="?{% url_replace request 'ordering' '-device_id' %}"><i class="fa fa-caret-down"></i></a>
<table class="table table-condensed table-hover table-quotas"> <a href="?{% url_replace request 'ordering' 'device_id' %}"><i class="fa fa-caret-up"></i></a></th>
<thead> </th>
<tr> <th>{% trans "Name" %}
<th> <a href="?{% url_replace request 'ordering' '-name' %}"><i class="fa fa-caret-down"></i></a>
<label aria-label="{% trans "select all rows for batch-operation" %}" <a href="?{% url_replace request 'ordering' 'name' %}"><i class="fa fa-caret-up"></i></a></th>
class="batch-select-label"><input type="checkbox" data-toggle-table/></label> </th>
</th> <th>{% trans "Hardware model" %}</th>
<th>{% trans "Device ID" %} <th>{% trans "Software" %}</th>
<a href="?{% url_replace request 'ordering' '-device_id' %}"><i <th>{% trans "Setup date" %}
class="fa fa-caret-down"></i></a> <a href="?{% url_replace request 'ordering' '-initialized' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'device_id' %}"><i <a href="?{% url_replace request 'ordering' 'initialized' %}"><i class="fa fa-caret-up"></i></a></th>
class="fa fa-caret-up"></i></a> </th>
</th> <th>{% trans "Events" %}</th>
<th>{% trans "Name" %} <th></th>
<a href="?{% url_replace request 'ordering' '-name' %}"><i class="fa fa-caret-down"></i></a> </tr>
<a href="?{% url_replace request 'ordering' 'name' %}"><i class="fa fa-caret-up"></i></a> </thead>
</th> <tbody>
<th>{% trans "Hardware model" %}</th> {% for d in devices %}
<th>{% trans "Software" %}</th> <tr {% if d.revoked %}class="text-muted"{% endif %}>
<th>{% trans "Setup date" %} <td>
<a href="?{% url_replace request 'ordering' '-initialized' %}"><i {{ d.device_id }}
class="fa fa-caret-down"></i></a> </td>
<a href="?{% url_replace request 'ordering' 'initialized' %}"><i class="fa fa-caret-up"></i></a> <td>
</th> {% if d.revoked %}<del>{% endif %}
<th>{% trans "Events" %}</th> {{ d.name }}
<th></th> {% if d.revoked %}</del>{% endif %}
</tr> {% if d.gate %}
{% if page_obj.paginator.num_pages > 1 %}
<tr class="table-select-all warning hidden">
<td>
<input type="checkbox" name="__ALL" id="__all"
data-results-total="{{ page_obj.paginator.count }}">
</td>
<td colspan="7">
<label for="__all">
{% trans "Select all results on other pages as well" %}
</label>
</td>
</tr>
{% endif %}
</thead>
<tbody>
{% for d in devices %}
<tr {% if d.revoked %}class="text-muted"{% endif %}>
<td>
<label aria-label="{% trans "select row for batch-operation" %}"
class="batch-select-label"><input type="checkbox" name="device"
class="batch-select-checkbox"
value="{{ d.pk }}"/></label>
</td>
<td>
{{ d.device_id }}
</td>
<td>
{% if d.revoked %}
<del>{% endif %}
{{ d.name }}
{% if d.revoked %}</del>{% endif %}
{% if d.gate %}
<br>
<small class="text-muted">{{ d.gate.name }}</small>
{% endif %}
<br> <br>
<small class="text-muted">{{ d.unique_serial }}</small> <small class="text-muted">{{ d.gate.name }}</small>
</td> {% endif %}
<td> <br>
{{ d.hardware_brand|default_if_none:"" }} {{ d.hardware_model|default_if_none:"" }} <small class="text-muted">{{ d.unique_serial }}</small>
</td> </td>
<td> <td>
{{ d.software_brand|default_if_none:"" }} {{ d.software_version|default_if_none:"" }} {{ d.hardware_brand|default_if_none:"" }} {{ d.hardware_model|default_if_none:"" }}
</td> </td>
<td> <td>
{% if d.initialized %} {{ d.software_brand|default_if_none:"" }} {{ d.software_version|default_if_none:"" }}
{{ d.initialized|date:"SHORT_DATETIME_FORMAT" }} </td>
{% else %} <td>
<em>{% trans "Not yet initialized" %}</em> {% if d.initialized %}
{% endif %} {{ d.initialized|date:"SHORT_DATETIME_FORMAT" }}
{% if d.revoked %} {% else %}
<span class="label label-danger">{% trans "Revoked" %}</span> <em>{% trans "Not yet initialized" %}</em>
{% endif %} {% endif %}
</td> {% if d.revoked %}
<td> <span class="label label-danger">{% trans "Revoked" %}</span>
{% if d.all_events %} {% endif %}
{% trans "All" %} </td>
{% else %} <td>
<ul> {% if d.all_events %}
{% for e in d.limit_events.all %} {% trans "All" %}
<li> {% else %}
<a href="{% url "control:event.index" organizer=request.organizer.slug event=e.slug %}"> <ul>
{{ e }} {% for e in d.limit_events.all %}
</a> <li>
</li> <a href="{% url "control:event.index" organizer=request.organizer.slug event=e.slug %}">
{% endfor %} {{ e }}
</ul> </a>
{% endif %} </li>
</td> {% endfor %}
<td class="text-right flip"> </ul>
{% if not d.initialized %} {% endif %}
<a href="{% url "control:organizer.device.connect" organizer=request.organizer.slug device=d.id %}" </td>
class="btn btn-primary btn-sm"><i class="fa fa-link"></i> <td class="text-right flip">
{% trans "Connect" %}</a> {% if not d.initialized %}
{% elif d.api_token %} <a href="{% url "control:organizer.device.connect" organizer=request.organizer.slug device=d.id %}"
<a href="{% url "control:organizer.device.revoke" organizer=request.organizer.slug device=d.id %}" class="btn btn-primary btn-sm"><i class="fa fa-link"></i>
class="btn btn-default btn-sm"> {% trans "Connect" %}</a>
{% trans "Revoke access" %}</a> {% elif d.api_token %}
{% endif %} <a href="{% url "control:organizer.device.revoke" organizer=request.organizer.slug device=d.id %}"
<a href="{% url "control:organizer.device.logs" organizer=request.organizer.slug device=d.id %}" class="btn btn-default btn-sm">
class="btn btn-default btn-sm"> {% trans "Revoke access" %}</a>
<span class="fa fa-list-alt"></span> {% endif %}
{% trans "Logs" %} <a href="{% url "control:organizer.device.logs" organizer=request.organizer.slug device=d.id %}"
</a> class="btn btn-default btn-sm">
<a href="{% url "control:organizer.device.edit" organizer=request.organizer.slug device=d.id %}" <span class="fa fa-list-alt"></span>
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a> {% trans "Logs" %}
</td> </a>
</tr> <a href="{% url "control:organizer.device.edit" organizer=request.organizer.slug device=d.id %}"
{% endfor %} class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
</tbody> </td>
</table> </tr>
</div> {% endfor %}
<div class="batch-select-actions"> </tbody>
<button type="submit" class="btn btn-primary btn-save" name="action" value="edit"> </table>
<i class="fa fa-edit"></i>{% trans "Edit selected" %} </div>
</button>
</div>
</form>
{% include "pretixcontrol/pagination.html" %} {% include "pretixcontrol/pagination.html" %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@@ -36,9 +36,6 @@
{% bootstrap_field sform.contact_mail layout="control" %} {% bootstrap_field sform.contact_mail layout="control" %}
{% bootstrap_field sform.organizer_info_text layout="control" %} {% bootstrap_field sform.organizer_info_text layout="control" %}
{% bootstrap_field sform.event_team_provisioning layout="control" %} {% bootstrap_field sform.event_team_provisioning layout="control" %}
{% if sform.allowed_restricted_plugins %}
{% bootstrap_field sform.allowed_restricted_plugins layout="control" %}
{% endif %}
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{% trans "Organizer page" %}</legend> <legend>{% trans "Organizer page" %}</legend>

View File

@@ -23,7 +23,7 @@
</script> </script>
<div class="row"> <div class="row">
<div class="col-md-9"> <div class="col-md-9">
<div class="panel panel-default panel-pdf-editor"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<div class="pull-right flip"> <div class="pull-right flip">
<div class="btn-group"> <div class="btn-group">
@@ -48,8 +48,6 @@
{% trans "Editor" %} {% trans "Editor" %}
</div> </div>
<div class="panel-body"> <div class="panel-body">
<ul class="nav nav-pills" id="page_nav">
</ul>
<div id="editor-canvas-area"> <div id="editor-canvas-area">
<canvas id="pdf-canvas" <canvas id="pdf-canvas"
data-pdf-url="{{ pdf }}" data-pdf-url="{{ pdf }}"
@@ -195,7 +193,7 @@
<span class="btn btn-default fileinput-button background-button"> <span class="btn btn-default fileinput-button background-button">
<i class="fa fa-upload"></i> <i class="fa fa-upload"></i>
<span>{% trans "Upload custom background" %}</span> <span>{% trans "Upload custom background" %}</span>
<input id="fileupload" type="file" name="background" accept="application/pdf"> <input id="fileupload" type="file" name="background">
</span> </span>
</div> </div>
<div class="col-sm-12 help-inline"> <div class="col-sm-12 help-inline">
@@ -206,14 +204,6 @@
{% endblocktrans %} {% endblocktrans %}
</p> </p>
</div> </div>
<div class="col-sm-12">
<p>
<a class="btn btn-default background-download-button" href="{{ pdf }}" target="_blank">
<i class="fa fa-download"></i>
<span>{% trans "Download current background" %}</span>
</a>
</p>
</div>
</div> </div>
<div class="row control-group pdf-info"> <div class="row control-group pdf-info">
<div class="col-sm-12"> <div class="col-sm-12">
@@ -367,14 +357,12 @@
</select> </select>
</div> </div>
</div> </div>
<div class="row control-group text textcontent"> <div class="row control-group text">
<div class="col-sm-12"> <div class="col-sm-12">
<label>{% trans "Content" %}</label><br> <label>{% trans "Text content" %}</label><br>
<select class="input-block-level form-control" id="toolbox-content"> <select class="input-block-level form-control" id="toolbox-content">
{% for varname, var in variables.items %} {% for varname, var in variables.items %}
{% if not var.hidden %} <option data-sample="{{ var.editor_sample }}" value="{{ varname }}">{{ var.label }}</option>
<option data-sample="{{ var.editor_sample }}" {% if var.migrate_from %}data-old-value="{{ var.migrate_from }}"{% endif %} value="{{ varname }}">{{ var.label }}</option>
{% endif %}
{% endfor %} {% endfor %}
{% for p in request.organizer.meta_properties.all %} {% for p in request.organizer.meta_properties.all %}
<option value="meta:{{ p.name }}"> <option value="meta:{{ p.name }}">
@@ -386,19 +374,10 @@
{% trans "Item attribute:" %} {{ p.name }} {% trans "Item attribute:" %} {{ p.name }}
</option> </option>
{% endfor %} {% endfor %}
<option value="other_i18n">{% trans "Other… (multilingual)" %}</option>
<option value="other">{% trans "Other…" %}</option> <option value="other">{% trans "Other…" %}</option>
</select> </select>
<textarea type="text" value="" class="input-block-level form-control" <textarea type="text" value="" class="input-block-level form-control"
id="toolbox-content-other"></textarea> 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>
@@ -422,20 +401,13 @@
<span class="fa fa-qrcode"></span> <span class="fa fa-qrcode"></span>
{% trans "QR code for Lead Scanning" %} {% trans "QR code for Lead Scanning" %}
</button> </button>
<button class="btn btn-default btn-block" id="editor-add-qrcode-other"
data-content="secret"
disabled>
<span class="fa fa-qrcode"></span>
{% trans "Other QR code" %}
</button>
<button class="btn btn-default btn-block" id="editor-add-poweredby" <button class="btn btn-default btn-block" id="editor-add-poweredby"
data-content="dark" data-content="dark"
disabled> disabled>
<span class="fa fa-image"></span> <span class="fa fa-image"></span>
{% trans "pretix Logo" %} {% trans "pretix Logo" %}
</button> </button>
<button class="btn btn-default btn-block" id="editor-add-image" disabled <button class="btn btn-default btn-block" id="editor-add-image" disabled>
data-toggle="tooltip" title="{% trans "You can use this to add user-uploaded pictures from questions or pictures generated by plugins. If you want to embed a logo or other images, use a custom background instead." %}">
<span class="fa fa-image"></span> <span class="fa fa-image"></span>
{% trans "Dynamic image" %} {% trans "Dynamic image" %}
</button> </button>

View File

@@ -1,68 +0,0 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load static %}
{% load compress %}
{% block title %}{% trans "PDF Editor" %}{% endblock %}
{% block custom_header %}
{{ block.super }}
{% compress css %}
<link type="text/css" rel="stylesheet" href="{% static "pretixcontrol/scss/pdfeditor.css" %}">
{% endcompress %}
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" %}">
{% endblock %}
{% block content %}
<h1>
{% trans "PDF Editor" %}
<small>{% trans "Available placeholders" %}</small>
</h1>
<p>
{% blocktrans trimmed %}
You can use placeholders in custom texts on tickets to enrich your text with individual data. Which
placeholders are available depends on your event settings, activated plugins, the selected product,
as well as user input.
This page lists all placeholders technically available for your event, however most of them can also
be empty in some cases depending on configuration.
{% endblocktrans %}
</p>
<div class="table-responsive">
<table class="table table-hover table-condensed">
<thead>
<tr>
<th>{% trans "Placeholder" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Formatting example" %}</th>
</tr>
</thead>
<tbody>
{% for varname, var in variables.items %}
{% if not var.hidden %}
<tr>
<td><code>{{ "{" }}{{ varname }}{{ "}" }}</code></td>
<td>{{ var.label }}</td>
<td>{{ var.editor_sample }}</td>
</tr>
{% endif %}
{% endfor %}
{% for p in request.organizer.meta_properties.all %}
<tr>
<td><code>{{ "{" }}meta:{{ p.name }}{{ "}" }}</code></td>
<td>
{% trans "Event attribute:" %} {{ p.name }}
</td>
<td></td>
</tr>
{% endfor %}
{% for p in request.event.item_meta_properties.all %}
<tr>
<td><code>{{ "{" }}itemmeta:{{ p.name }}{{ "}" }}</code></td>
<td>
{% trans "Item attribute:" %} {{ p.name }}
</td>
<td></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -188,7 +188,7 @@
<button type="submit" class="btn btn-danger btn-save" name="action" value="delete"> <button type="submit" class="btn btn-danger btn-save" name="action" value="delete">
<i class="fa fa-trash"></i>{% trans "Delete selected" %} <i class="fa fa-trash"></i>{% trans "Delete selected" %}
</button> </button>
<button type="submit" class="btn btn-primary btn-save" name="action" value="edit" <button type="submit" class="btn btn-primary btn-save" name="action" value="disable"
formaction="{% url "control:event.subevents.bulkedit" organizer=request.event.organizer.slug event=request.event.slug %}"> formaction="{% url "control:event.subevents.bulkedit" organizer=request.event.organizer.slug event=request.event.slug %}">
<i class="fa fa-edit"></i>{% trans "Edit selected" %} <i class="fa fa-edit"></i>{% trans "Edit selected" %}
</button> </button>

View File

@@ -38,14 +38,6 @@
<input name="text" value="{{ backend }}" class="form-control" disabled> <input name="text" value="{{ backend }}" class="form-control" disabled>
</div> </div>
</div> </div>
{% if user.auth_backend_identifier %}
<div class="form-group">
<label class="col-md-3 control-label">{% trans "External identifier" %}</label>
<div class="col-md-9">
<input name="text" value="{{ user.auth_backend_identifier }}" class="form-control" disabled>
</div>
</div>
{% endif %}
{% bootstrap_field form.email layout='control' %} {% bootstrap_field form.email layout='control' %}
{% if form.new_pw %} {% if form.new_pw %}
{% bootstrap_field form.new_pw layout='control' %} {% bootstrap_field form.new_pw layout='control' %}

View File

@@ -39,15 +39,17 @@
<legend>{% trans "Voucher details" %}</legend> <legend>{% trans "Voucher details" %}</legend>
{% bootstrap_field form.code layout="control" %} {% bootstrap_field form.code layout="control" %}
{% if voucher.pk %} {% if voucher.pk %}
<div class="form-group"> {% if not request.event.has_subevents or voucher.subevent %}
<label class="col-md-3 control-label" for="id_url">{% trans "Voucher link" %}</label> <div class="form-group">
<div class="col-md-9"> <label class="col-md-3 control-label" for="id_url">{% trans "Voucher link" %}</label>
<input type="text" name="url" <div class="col-md-9">
value="{% abseventurl request.event "presale:event.redeem" %}?voucher={{ voucher.code|urlencode }}{% if voucher.subevent_id %}&subevent={{ voucher.subevent_id }}{% endif %}" <input type="text" name="url"
class="form-control" value="{% abseventurl request.event "presale:event.redeem" %}?voucher={{ voucher.code|urlencode }}{% if voucher.subevent_id %}&subevent={{ voucher.subevent_id }}{% endif %}"
id="id_url" readonly> class="form-control"
id="id_url" readonly>
</div>
</div> </div>
</div> {% endif %}
{% endif %} {% endif %}
{% bootstrap_field form.max_usages layout="control" %} {% bootstrap_field form.max_usages layout="control" %}
{% bootstrap_field form.valid_until layout="control" %} {% bootstrap_field form.valid_until layout="control" %}

View File

@@ -29,9 +29,4 @@ def getitem_filter(value, itemname):
if not value: if not value:
return '' return ''
try: return value[itemname]
return value[itemname]
except KeyError:
return ''
except TypeError:
return ''

View File

@@ -45,8 +45,7 @@ class PropagatedNode(Node):
<div class="propagated-settings-box locked panel panel-default"> <div class="propagated-settings-box locked panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<input type="hidden" name="_settings_ignore" value="{fnames}"> <input type="hidden" name="_settings_ignore" value="{fnames}">
<input type="hidden" name="decouple" value=""> <button class="btn btn-default pull-right btn-xs" name="decouple" value="{fnames}" data-action="unlink">
<button type="button" class="btn btn-default pull-right btn-xs" value="{fnames}" data-action="unlink">
<span class="fa fa-unlock"></span> {text_unlink} <span class="fa fa-unlock"></span> {text_unlink}
</button> </button>
<h4 class="panel-title"> <h4 class="panel-title">

View File

@@ -164,8 +164,6 @@ urlpatterns = [
re_path(r'^organizer/(?P<organizer>[^/]+)/devices$', organizer.DeviceListView.as_view(), name='organizer.devices'), re_path(r'^organizer/(?P<organizer>[^/]+)/devices$', organizer.DeviceListView.as_view(), name='organizer.devices'),
re_path(r'^organizer/(?P<organizer>[^/]+)/device/add$', organizer.DeviceCreateView.as_view(), re_path(r'^organizer/(?P<organizer>[^/]+)/device/add$', organizer.DeviceCreateView.as_view(),
name='organizer.device.add'), name='organizer.device.add'),
re_path(r'^organizer/(?P<organizer>[^/]+)/device/bulk_edit$', organizer.DeviceBulkUpdateView.as_view(),
name='organizer.device.bulk_edit'),
re_path(r'^organizer/(?P<organizer>[^/]+)/device/(?P<device>[^/]+)/edit$', organizer.DeviceUpdateView.as_view(), re_path(r'^organizer/(?P<organizer>[^/]+)/device/(?P<device>[^/]+)/edit$', organizer.DeviceUpdateView.as_view(),
name='organizer.device.edit'), name='organizer.device.edit'),
re_path(r'^organizer/(?P<organizer>[^/]+)/device/(?P<device>[^/]+)/connect$', organizer.DeviceConnectView.as_view(), re_path(r'^organizer/(?P<organizer>[^/]+)/device/(?P<device>[^/]+)/connect$', organizer.DeviceConnectView.as_view(),

View File

@@ -313,23 +313,6 @@ class CheckinListUpdate(EventPermissionRequiredMixin, UpdateView):
r['Content-Security-Policy'] = 'script-src \'unsafe-eval\'' r['Content-Security-Policy'] = 'script-src \'unsafe-eval\''
return r return r
def get_context_data(self, **kwargs):
return {
'items': [
{
'id': i.pk,
'name': str(i),
'variations': [
{
'id': v.pk,
'name': str(v.value)
} for v in i.variations.all()
]
} for i in self.request.event.items.filter(active=True).prefetch_related('variations')
],
**super().get_context_data(),
}
def get_object(self, queryset=None) -> CheckinList: def get_object(self, queryset=None) -> CheckinList:
try: try:
return self.request.event.checkin_lists.get( return self.request.event.checkin_lists.get(

View File

@@ -92,7 +92,6 @@ from ...base.i18n import language
from ...base.models.items import ( from ...base.models.items import (
Item, ItemCategory, ItemMetaProperty, Question, Quota, Item, ItemCategory, ItemMetaProperty, Question, Quota,
) )
from ...base.services.mail import TolerantDict
from ...base.settings import SETTINGS_AFFECTING_CSS, LazyI18nStringList from ...base.settings import SETTINGS_AFFECTING_CSS, LazyI18nStringList
from ..logdisplay import OVERVIEW_BANLIST from ..logdisplay import OVERVIEW_BANLIST
from . import CreateView, PaginationMixin, UpdateView from . import CreateView, PaginationMixin, UpdateView
@@ -328,27 +327,15 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat
'FORMAT': _('Output and export formats'), 'FORMAT': _('Output and export formats'),
'API': _('API features'), 'API': _('API features'),
} }
plugins_grouped = groupby(
sorted(
plugins,
key=lambda p: (
str(getattr(p, 'category', _('Other'))),
(0 if getattr(p, 'featured', False) else 1),
str(p.name).lower().replace('pretix ', '')
),
),
lambda p: str(getattr(p, 'category', _('Other')))
)
plugins_grouped = [(c, list(plist)) for c, plist in plugins_grouped]
context['plugins'] = sorted([ context['plugins'] = sorted([
(c, labels.get(c, c), plist, any(getattr(p, 'picture', None) for p in plist)) (c, labels.get(c, c), list(plist))
for c, plist for c, plist
in plugins_grouped in groupby(
sorted(plugins, key=lambda p: str(getattr(p, 'category', _('Other')))),
lambda p: str(getattr(p, 'category', _('Other')))
)
], key=lambda c: (order.index(c[0]), c[1]) if c[0] in order else (999, str(c[1]))) ], key=lambda c: (order.index(c[0]), c[1]) if c[0] in order else (999, str(c[1])))
context['plugins_active'] = self.object.get_plugins() context['plugins_active'] = self.object.get_plugins()
context['show_meta'] = settings.PRETIX_PLUGINS_SHOW_META
return context return context
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
@@ -367,17 +354,19 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat
} }
with transaction.atomic(): with transaction.atomic():
allow_restricted = request.user.has_active_staff_session(request.session.session_key)
for key, value in request.POST.items(): for key, value in request.POST.items():
if key.startswith("plugin:"): if key.startswith("plugin:"):
module = key.split(":")[1] module = key.split(":")[1]
if value == "enable" and module in plugins_available: if value == "enable" and module in plugins_available:
if getattr(plugins_available[module], 'restricted', False): if getattr(plugins_available[module], 'restricted', False):
if module not in request.event.settings.allowed_restricted_plugins: if not allow_restricted:
continue continue
self.request.event.log_action('pretix.event.plugins.enabled', user=self.request.user, self.request.event.log_action('pretix.event.plugins.enabled', user=self.request.user,
data={'plugin': module}) data={'plugin': module})
self.object.enable_plugin(module, allow_restricted=request.event.settings.allowed_restricted_plugins) self.object.enable_plugin(module, allow_restricted=allow_restricted)
else: else:
self.request.event.log_action('pretix.event.plugins.disabled', user=self.request.user, self.request.event.log_action('pretix.event.plugins.disabled', user=self.request.user,
data={'plugin': module}) data={'plugin': module})
@@ -742,7 +731,7 @@ class MailSettingsRendererPreview(MailSettingsPreview):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
v = str(request.event.settings.mail_text_order_placed) v = str(request.event.settings.mail_text_order_placed)
v = v.format_map(TolerantDict(self.placeholders('mail_text_order_placed'))) v = v.format_map(self.placeholders('mail_text_order_placed'))
renderers = request.event.get_html_mail_renderers() renderers = request.event.get_html_mail_renderers()
if request.GET.get('renderer') in renderers: if request.GET.get('renderer') in renderers:
with rolledback_transaction(): with rolledback_transaction():
@@ -1055,9 +1044,6 @@ class EventLog(EventPermissionRequiredMixin, PaginationMixin, ListView):
elif self.request.GET.get('user'): elif self.request.GET.get('user'):
qs = qs.filter(user_id=self.request.GET.get('user')) qs = qs.filter(user_id=self.request.GET.get('user'))
if self.request.GET.get('action_type'):
qs = qs.filter(action_type=self.request.GET['action_type'])
if self.request.GET.get('content_type'): if self.request.GET.get('content_type'):
qs = qs.filter(content_type=get_object_or_404(ContentType, pk=self.request.GET.get('content_type'))) qs = qs.filter(content_type=get_object_or_404(ContentType, pk=self.request.GET.get('content_type')))
@@ -1429,7 +1415,7 @@ class QuickSetupView(FormView):
}) })
quota.items.add(*items) quota.items.add(*items)
self.request.event.set_active_plugins(plugins_active, allow_restricted=plugins_active) self.request.event.set_active_plugins(plugins_active, allow_restricted=True)
self.request.event.save() self.request.event.save()
messages.success(self.request, _('Your changes have been saved. You can now go on with looking at the details ' messages.success(self.request, _('Your changes have been saved. You can now go on with looking at the details '
'or take your event live to start selling!')) 'or take your event live to start selling!'))

View File

@@ -102,7 +102,7 @@ class ItemList(ListView):
).annotate( ).annotate(
var_count=Count('variations') var_count=Count('variations')
).prefetch_related("category").order_by( ).prefetch_related("category").order_by(
F('category__position').asc(nulls_first=True), F('category__position').desc(nulls_first=True),
'category', 'position' 'category', 'position'
) )

View File

@@ -243,8 +243,6 @@ class MailSettingsSetupView(TemplateView):
messages.success(request, _('Your changes have been saved.')) messages.success(request, _('Your changes have been saved.'))
return redirect(self.get_success_url()) return redirect(self.get_success_url())
else: else:
self.smtp_form._unmask_secret_fields()
backend = get_connection( backend = get_connection(
backend=settings.EMAIL_CUSTOM_SMTP_BACKEND, backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
host=self.smtp_form.cleaned_data['smtp_host'], host=self.smtp_form.cleaned_data['smtp_host'],

View File

@@ -265,6 +265,8 @@ class EventWizard(SafeSessionWizardView):
event.has_subevents = foundation_data['has_subevents'] event.has_subevents = foundation_data['has_subevents']
event.testmode = True event.testmode = True
form_dict['basics'].save() form_dict['basics'].save()
event.set_active_plugins(settings.PRETIX_PLUGINS_DEFAULT.split(","), allow_restricted=True)
event.save(update_fields=['plugins'])
event.log_action( event.log_action(
'pretix.event.added', 'pretix.event.added',
user=self.request.user, user=self.request.user,
@@ -297,9 +299,6 @@ class EventWizard(SafeSessionWizardView):
elif self.clone_from: elif self.clone_from:
event.copy_data_from(self.clone_from) event.copy_data_from(self.clone_from)
else: else:
event.set_active_plugins(settings.PRETIX_PLUGINS_DEFAULT.split(","),
allow_restricted=settings.PRETIX_PLUGINS_DEFAULT.split(","))
event.save(update_fields=['plugins'])
event.checkin_lists.create( event.checkin_lists.create(
name=_('Default'), name=_('Default'),
all_products=True all_products=True

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