mirror of
https://github.com/pretix/pretix.git
synced 2025-12-10 01:12:28 +00:00
Compare commits
3 Commits
fix-2556-r
...
add-fixed-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d574ab612 | ||
|
|
690f22d444 | ||
|
|
9b0b1585e6 |
12
.github/workflows/tests.yml
vendored
12
.github/workflows/tests.yml
vendored
@@ -18,17 +18,17 @@ jobs:
|
||||
name: Tests
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.7", "3.8", "3.9"]
|
||||
python-version: [3.6, 3.7, 3.8]
|
||||
database: [sqlite, postgres, mysql]
|
||||
exclude:
|
||||
- database: mysql
|
||||
python-version: "3.8"
|
||||
python-version: 3.7
|
||||
- database: sqlite
|
||||
python-version: 3.7
|
||||
- database: mysql
|
||||
python-version: "3.9"
|
||||
python-version: 3.6
|
||||
- database: sqlite
|
||||
python-version: "3.7"
|
||||
- database: sqlite
|
||||
python-version: "3.8"
|
||||
python-version: 3.6
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: getong/mariadb-action@v1.1
|
||||
|
||||
@@ -65,9 +65,6 @@ Example::
|
||||
A comma-separated list of plugins that are not available even though they are installed.
|
||||
Defaults to an empty string.
|
||||
|
||||
``plugins_show_meta``
|
||||
Whether to show authors and versions of plugins, defaults to ``on``.
|
||||
|
||||
``auth_backends``
|
||||
A comma-separated list of available auth backends. Defaults to ``pretix.base.auth.NativeAuthBackend``.
|
||||
|
||||
@@ -223,30 +220,12 @@ Example::
|
||||
``user``, ``password``
|
||||
The SMTP user data to use for the connection. Empty by default.
|
||||
|
||||
``tls``, ``ssl``
|
||||
Use STARTTLS or SSL for the SMTP connection. Off by default.
|
||||
|
||||
``from``
|
||||
The email address to set as ``From`` header in outgoing emails by the system.
|
||||
Default: ``pretix@localhost``
|
||||
|
||||
``from_notifications``
|
||||
The email address to set as ``From`` header in admin notification emails by the system.
|
||||
Defaults to the value of ``from``.
|
||||
|
||||
``from_organizers``
|
||||
The email address to set as ``From`` header in outgoing emails by the system sent on behalf of organizers.
|
||||
Defaults to the value of ``from``.
|
||||
|
||||
``custom_sender_verification_required``
|
||||
If this is on (the default), organizers need to verify email addresses they want to use as senders in their event.
|
||||
|
||||
``custom_sender_spf_string``
|
||||
If this is set to a valid SPF string, pretix will show a warning if organizers use a sender address from a domain
|
||||
that does not include this value.
|
||||
|
||||
``custom_smtp_allow_private_networks``
|
||||
If this is off (the default), custom SMTP servers cannot be private network addresses.
|
||||
``tls``, ``ssl``
|
||||
Use STARTTLS or SSL for the SMTP connection. Off by default.
|
||||
|
||||
``admins``
|
||||
Comma-separated list of email addresses that should receive a report about every error code 500 thrown by pretix.
|
||||
@@ -303,7 +282,7 @@ You can use an existing memcached server as pretix's caching backend::
|
||||
``location``
|
||||
The location of memcached, either a host:port combination or a socket file.
|
||||
|
||||
If no memcached is configured, pretix will use redis for caching. If neither is configured, pretix will not use any caching.
|
||||
If no memcached is configured, pretix will use Django's built-in local-memory caching method.
|
||||
|
||||
.. note:: If you use memcached and you deploy pretix across multiple servers, you should use *one*
|
||||
shared memcached instance, not multiple ones, because cache invalidations would not be
|
||||
@@ -466,10 +445,8 @@ You can configure the maximum file size for uploading various files::
|
||||
max_size_image = 12
|
||||
; Max upload size for favicons in MiB, defaults to 1 MiB
|
||||
max_size_favicon = 2
|
||||
; Max upload size for email attachments of manually sent emails in MiB, defaults to 10 MiB
|
||||
; Max upload size for email attachments in MiB, defaults to 10 MiB
|
||||
max_size_email_attachment = 15
|
||||
; Max upload size for email attachments of automatically sent emails in MiB, defaults to 1 MiB
|
||||
max_size_email_auto_attachment = 2
|
||||
; Max upload size for other files in MiB, defaults to 10 MiB
|
||||
; This includes all file upload type order questions
|
||||
max_size_other = 100
|
||||
|
||||
@@ -36,6 +36,9 @@ Linux and firewalls, we recommend that you start with `ufw`_.
|
||||
SSL certificates can be obtained for free these days. We also *do not* provide support for HTTP-only
|
||||
installations except for evaluation purposes.
|
||||
|
||||
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
|
||||
**MariaDB 10.2.7 or newer**.
|
||||
|
||||
.. warning:: By default, using `ufw` in conjunction will not have any effect. Please make sure to either bind the exposed
|
||||
ports of your docker container explicitly to 127.0.0.1 or configure docker to respect any set up firewall
|
||||
rules.
|
||||
@@ -58,9 +61,6 @@ directory writable to the user that runs pretix inside the docker container::
|
||||
Database
|
||||
--------
|
||||
|
||||
.. warning:: **Please use PostgreSQL for all new installations**. If you need to go for MySQL, make sure you run
|
||||
**MySQL 5.7 or newer** or **MariaDB 10.2.7 or newer**.
|
||||
|
||||
Next, we need a database and a database user. We can create these with any kind of database managing tool or directly on
|
||||
our database's shell. Please make sure that UTF8 is used as encoding for the best compatibility. You can check this with
|
||||
the following command::
|
||||
@@ -91,8 +91,6 @@ When using MySQL, make sure you set the character set of the database to ``utf8m
|
||||
|
||||
mysql > CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
You will also need to make sure that ``sql_mode`` in your ``my.cnf`` file does **not** include ``ONLY_FULL_GROUP_BY``.
|
||||
|
||||
Redis
|
||||
-----
|
||||
|
||||
@@ -108,18 +106,6 @@ Now 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
|
||||
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
|
||||
|
||||
@@ -34,6 +34,9 @@ Linux and firewalls, we recommend that you start with `ufw`_.
|
||||
SSL certificates can be obtained for free these days. We also *do not* provide support for HTTP-only
|
||||
installations except for evaluation purposes.
|
||||
|
||||
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
|
||||
**MariaDB 10.2.7 or newer**.
|
||||
|
||||
Unix user
|
||||
---------
|
||||
|
||||
@@ -47,9 +50,6 @@ In this guide, all code lines prepended with a ``#`` symbol are commands that yo
|
||||
Database
|
||||
--------
|
||||
|
||||
.. warning:: **Please use PostgreSQL for all new installations**. If you need to go for MySQL, make sure you run
|
||||
**MySQL 5.7 or newer** or **MariaDB 10.2.7 or newer**.
|
||||
|
||||
Having the database server installed, we still need a database and a database user. We can create these with any kind
|
||||
of database managing tool or directly on our database's shell. Please make sure that UTF8 is used as encoding for the
|
||||
best compatibility. You can check this with the following command::
|
||||
@@ -65,8 +65,6 @@ When using MySQL, make sure you set the character set of the database to ``utf8m
|
||||
|
||||
mysql > CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
You will also need to make sure that ``sql_mode`` in your ``my.cnf`` file does **not** include ``ONLY_FULL_GROUP_BY``.
|
||||
|
||||
Package dependencies
|
||||
--------------------
|
||||
|
||||
@@ -144,7 +142,7 @@ If you're running MySQL, also install the client library::
|
||||
|
||||
(venv)$ pip3 install mysqlclient
|
||||
|
||||
Note that you need Python 3.7 or newer. You can find out your Python version using ``python -V``.
|
||||
Note that you need Python 3.6 or newer. You can find out your Python version using ``python -V``.
|
||||
|
||||
We also need to create a data directory::
|
||||
|
||||
@@ -261,14 +259,14 @@ The following snippet is an example on how to configure a nginx proxy for pretix
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /var/pretix/venv/lib/python3.10/site-packages/pretix/static.dist/;
|
||||
alias /var/pretix/venv/lib/python3.7/site-packages/pretix/static.dist/;
|
||||
access_log off;
|
||||
expires 365d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
}
|
||||
|
||||
.. note:: Remember to replace the ``python3.10`` in the ``/static/`` path in the config
|
||||
.. note:: Remember to replace the ``python3.7`` in the ``/static/`` path in the config
|
||||
above with your python version.
|
||||
|
||||
We recommend reading about setting `strong encryption settings`_ for your web server.
|
||||
|
||||
@@ -99,8 +99,7 @@ following endpoint:
|
||||
"hardware_brand": "Samsung",
|
||||
"hardware_model": "Galaxy S",
|
||||
"software_brand": "pretixdroid",
|
||||
"software_version": "4.1.0",
|
||||
"info": {"arbitrary": "data"}
|
||||
"software_version": "4.1.0"
|
||||
}
|
||||
|
||||
You will receive a response equivalent to the response of your initialization request.
|
||||
|
||||
@@ -43,8 +43,6 @@ Possible permissions are:
|
||||
* Can view vouchers
|
||||
* Can change vouchers
|
||||
|
||||
.. _`rest-compat`:
|
||||
|
||||
Compatibility
|
||||
-------------
|
||||
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
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::
|
||||
:maxdepth: 2
|
||||
|
||||
|
||||
@@ -58,12 +58,6 @@ lines list of objects The actual invo
|
||||
created before this field was introduced as well as for
|
||||
all lines not created by a product (e.g. a shipping or
|
||||
cancellation fee).
|
||||
├ subevent integer Event series date ID used to create this line. Note that everything
|
||||
about the subevent might have changed since the creation
|
||||
of the invoice. Can be ``null`` for all invoice lines
|
||||
created before this field was introduced as well as for
|
||||
all lines not created by a product (e.g. a shipping or
|
||||
cancellation fee) as well as for all events that are not a series.
|
||||
├ fee_type string Fee type, e.g. ``shipping``, ``service``, ``payment``,
|
||||
``cancellation``, ``giftcard``, or ``other. Can be ``null`` for
|
||||
all invoice lines
|
||||
@@ -126,10 +120,6 @@ internal_reference string Customer's refe
|
||||
|
||||
The attribute ``lines.event_location`` has been added.
|
||||
|
||||
.. versionchanged:: 4.6
|
||||
|
||||
The attribute ``lines.subevent`` has been added.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -195,7 +185,6 @@ Endpoints
|
||||
"description": "Budget Ticket",
|
||||
"item": 1234,
|
||||
"variation": 245,
|
||||
"subevent": null,
|
||||
"fee_type": null,
|
||||
"fee_internal_type": null,
|
||||
"event_date_from": "2017-12-27T10:00:00Z",
|
||||
@@ -285,7 +274,6 @@ Endpoints
|
||||
"description": "Budget Ticket",
|
||||
"item": 1234,
|
||||
"variation": 245,
|
||||
"subevent": null,
|
||||
"fee_type": null,
|
||||
"fee_internal_type": null,
|
||||
"event_date_from": "2017-12-27T10:00:00Z",
|
||||
|
||||
@@ -24,9 +24,6 @@ active boolean If ``false``, t
|
||||
description multi-lingual string A public description of the variation. May contain
|
||||
Markdown syntax or can be ``null``.
|
||||
position integer An integer, used for sorting
|
||||
require_approval boolean If ``true``, orders with this variation will need to be
|
||||
approved by the event organizer before they can be
|
||||
paid.
|
||||
require_membership boolean If ``true``, booking this variation requires an active membership.
|
||||
require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will
|
||||
be hidden from users without a valid membership.
|
||||
@@ -79,7 +76,6 @@ Endpoints
|
||||
"en": "S"
|
||||
},
|
||||
"active": true,
|
||||
"require_approval": false,
|
||||
"require_membership": false,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
@@ -101,7 +97,6 @@ Endpoints
|
||||
"en": "L"
|
||||
},
|
||||
"active": true,
|
||||
"require_approval": false,
|
||||
"require_membership": false,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
@@ -152,7 +147,6 @@ Endpoints
|
||||
"price": "10.00",
|
||||
"original_price": null,
|
||||
"active": true,
|
||||
"require_approval": false,
|
||||
"require_membership": false,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
@@ -189,7 +183,6 @@ Endpoints
|
||||
"value": {"en": "Student"},
|
||||
"default_price": "10.00",
|
||||
"active": true,
|
||||
"require_approval": false,
|
||||
"require_membership": false,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
@@ -216,7 +209,6 @@ Endpoints
|
||||
"price": "10.00",
|
||||
"original_price": null,
|
||||
"active": true,
|
||||
"require_approval": false,
|
||||
"require_membership": false,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
@@ -274,7 +266,6 @@ Endpoints
|
||||
"price": "10.00",
|
||||
"original_price": null,
|
||||
"active": false,
|
||||
"require_approval": false,
|
||||
"require_membership": false,
|
||||
"require_membership_hidden": false,
|
||||
"require_membership_types": [],
|
||||
|
||||
@@ -839,7 +839,6 @@ Creating orders
|
||||
* ``comment`` (optional)
|
||||
* ``custom_followup_at`` (optional)
|
||||
* ``checkin_attention`` (optional)
|
||||
* ``require_approval`` (optional)
|
||||
* ``invoice_address`` (optional)
|
||||
|
||||
* ``company``
|
||||
@@ -899,9 +898,8 @@ Creating orders
|
||||
|
||||
* ``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
|
||||
whether these emails are enabled for certain sales channels. If set to ``null``, behavior will be controlled by pretix'
|
||||
settings based on the sales channels (added in pretix 4.7). Defaults to ``false``.
|
||||
Used to be ``send_mail`` before pretix 3.14.
|
||||
whether these emails are enabled for certain sales channels. Defaults to
|
||||
``false``. 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
|
||||
to incrementing integers starting with ``1``. Then, you can reference one of these
|
||||
|
||||
@@ -16,22 +16,15 @@ Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the tax rule
|
||||
name multi-lingual string The tax rules' name
|
||||
internal_name string An optional name that is only used in the backend
|
||||
rate decimal (string) Tax rate in percent
|
||||
price_includes_tax boolean If ``true`` (default), tax is assumed to be included in
|
||||
the specified product price
|
||||
eu_reverse_charge boolean If ``true``, EU reverse charge rules are applied
|
||||
home_country string Merchant country (required for reverse charge), can be
|
||||
``null`` or empty string
|
||||
keep_gross_if_rate_changes boolean If ``true``, changes of the tax rate based on custom
|
||||
rules keep the gross price constant (default is ``false``)
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
.. versionchanged:: 4.6
|
||||
|
||||
The ``internal_name`` and ``keep_gross_if_rate_changes`` attributes have been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -63,11 +56,9 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "VAT"},
|
||||
"internal_name": "VAT",
|
||||
"rate": "19.00",
|
||||
"price_includes_tax": true,
|
||||
"eu_reverse_charge": false,
|
||||
"keep_gross_if_rate_changes": false,
|
||||
"home_country": "DE"
|
||||
}
|
||||
]
|
||||
@@ -103,11 +94,9 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "VAT"},
|
||||
"internal_name": "VAT",
|
||||
"rate": "19.00",
|
||||
"price_includes_tax": true,
|
||||
"eu_reverse_charge": false,
|
||||
"keep_gross_if_rate_changes": false,
|
||||
"home_country": "DE"
|
||||
}
|
||||
|
||||
@@ -151,11 +140,9 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "VAT"},
|
||||
"internal_name": "VAT",
|
||||
"rate": "19.00",
|
||||
"price_includes_tax": true,
|
||||
"eu_reverse_charge": false,
|
||||
"keep_gross_if_rate_changes": false,
|
||||
"home_country": "DE"
|
||||
}
|
||||
|
||||
@@ -198,11 +185,9 @@ Endpoints
|
||||
{
|
||||
"id": 1,
|
||||
"name": {"en": "VAT"},
|
||||
"internal_name": "VAT",
|
||||
"rate": "20.00",
|
||||
"price_includes_tax": true,
|
||||
"eu_reverse_charge": false,
|
||||
"keep_gross_if_rate_changes": false,
|
||||
"home_country": "DE"
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
explicitly be set through the ``auth_backends`` directive in the ``pretix.cfg`` :ref:`configuration file <config>`.
|
||||
Authentication backends are *not* collected through a signal. Instead, they must explicitly be set through the
|
||||
``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
|
||||
use ``User.objects.get_or_create_for_backend`` to get a :py:class:`pretix.base.models.User` object from the database
|
||||
or create a new one.
|
||||
In each of these methods (``form_authenticate``, ``request_authenticate`` or your custom view) you are supposed to
|
||||
either get an existing :py:class:`pretix.base.models.User` object from the database or create a new one. There are a
|
||||
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
|
||||
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.
|
||||
* You **MUST** create new users with the ``auth_backend`` attribute set to the ``identifier`` value of your backend.
|
||||
|
||||
* 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.
|
||||
|
||||
``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
|
||||
---------------------
|
||||
|
||||
@@ -70,7 +59,6 @@ The backend interface
|
||||
|
||||
.. automethod:: authentication_url
|
||||
|
||||
|
||||
Logging users in
|
||||
----------------
|
||||
|
||||
@@ -80,45 +68,3 @@ recommend that you use the following utility method to correctly set session val
|
||||
authentication (if activated):
|
||||
|
||||
.. 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)
|
||||
|
||||
@@ -45,17 +45,13 @@ Attribute Type Description
|
||||
name string The human-readable name of your plugin
|
||||
author string Your name
|
||||
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"``,
|
||||
``"INTEGRATION"``, ``"CUSTOMIZATION"``, ``"FORMAT"``, or ``"API"``,
|
||||
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.
|
||||
restricted boolean (optional) ``False`` by default, restricts a plugin such that it can only be enabled
|
||||
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.
|
||||
================== ==================== ===========================================================
|
||||
|
||||
@@ -78,10 +74,8 @@ A working example would be:
|
||||
name = _("PayPal")
|
||||
author = _("the pretix team")
|
||||
version = '1.0.0'
|
||||
category = 'PAYMENT'
|
||||
picture = 'pretix_paypal/paypal_logo.svg'
|
||||
category = 'PAYMENT
|
||||
visible = True
|
||||
featured = False
|
||||
restricted = False
|
||||
description = _("This plugin allows you to receive payments via PayPal")
|
||||
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
|
||||
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
|
||||
-------------------
|
||||
|
||||
@@ -61,7 +61,7 @@ Variable Description
|
||||
``attendee_city`` City 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)
|
||||
``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_*`` 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)
|
||||
|
||||
@@ -1,562 +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``, and ``value``.
|
||||
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",
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
: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"
|
||||
},
|
||||
{
|
||||
"id": "attendee_email",
|
||||
"label": "Email",
|
||||
"value": "test@example.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
"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
|
||||
@@ -1,301 +0,0 @@
|
||||
Secrets Import
|
||||
==============
|
||||
|
||||
Usually, pretix generates ticket secrets (i.e. the QR code used for scanning) itself. You can read more about this
|
||||
process at :ref:`secret_generators`.
|
||||
|
||||
With the "Secrets Import" plugin, you can upload your own list of secrets to be used instead. This is useful for
|
||||
integrating with third-party check-in systems.
|
||||
|
||||
|
||||
API Resource description
|
||||
-------------------------
|
||||
|
||||
The secrets import plugin provides a HTTP API that allows you to create new secrets.
|
||||
|
||||
The imported secret resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the secret
|
||||
secret string Actual string content of the secret (QR code content)
|
||||
used boolean Whether the secret was already used for a ticket. If ``true``,
|
||||
the secret can no longer be deleted. Secrets are never used
|
||||
twice, even if an order is canceled or deleted.
|
||||
item integer Internal ID of a product, or ``null``. If set, the secret
|
||||
will only be used for tickets of this product.
|
||||
variation integer Internal ID of a product variation, or ``null``. If set, the secret
|
||||
will only be used for tickets of this product variation.
|
||||
subevent integer Internal ID of an event series date, or ``null``. If set, the secret
|
||||
will only be used for tickets of this event series date.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
API Endpoints
|
||||
-------------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/
|
||||
|
||||
Returns a list of all secrets imported for an event.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/imported_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
|
||||
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"secret": "foobar",
|
||||
"used": false,
|
||||
"item": null,
|
||||
"variation": null,
|
||||
"subevent": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
: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)/imported_secrets/(id)/
|
||||
|
||||
Returns information on one secret, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/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,
|
||||
"secret": "foobar",
|
||||
"used": false,
|
||||
"item": null,
|
||||
"variation": null,
|
||||
"subevent": null
|
||||
}
|
||||
|
||||
: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 secret to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/secret does not exist **or** you have no permission to view it.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/
|
||||
|
||||
Create a new secret.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 166
|
||||
|
||||
{
|
||||
"secret": "foobar",
|
||||
"used": false,
|
||||
"item": null,
|
||||
"variation": null,
|
||||
"subevent": null
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"secret": "foobar",
|
||||
"used": false,
|
||||
"item": null,
|
||||
"variation": null,
|
||||
"subevent": null
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to a create new secret for
|
||||
:param event: The ``slug`` field of the event to create a new secret for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The secret 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 secrets.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/bulk_create/
|
||||
|
||||
Create new secrets in bulk (up to 500 per request). The request either succeeds or fails entirely.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/bulk_create/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 166
|
||||
|
||||
[
|
||||
{
|
||||
"secret": "foobar",
|
||||
"used": false,
|
||||
"item": null,
|
||||
"variation": null,
|
||||
"subevent": null
|
||||
},
|
||||
{
|
||||
"secret": "baz",
|
||||
"used": false,
|
||||
"item": null,
|
||||
"variation": null,
|
||||
"subevent": null
|
||||
}
|
||||
]
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"secret": "foobar",
|
||||
"used": false,
|
||||
"item": null,
|
||||
"variation": null,
|
||||
"subevent": null
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"secret": "baz",
|
||||
"used": false,
|
||||
"item": null,
|
||||
"variation": null,
|
||||
"subevent": null
|
||||
}
|
||||
]
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create new secrets for
|
||||
:param event: The ``slug`` field of the event to create new secrets for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The secrets 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 secrets.
|
||||
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/(id)/
|
||||
|
||||
Update a secret. 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/imported_secrets/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 34
|
||||
|
||||
{
|
||||
"item": 2
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: text/javascript
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"secret": "foobar",
|
||||
"used": false,
|
||||
"item": 2,
|
||||
"variation": null,
|
||||
"subevent": null
|
||||
}
|
||||
|
||||
: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 secret to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The secret could not be modified due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/secret does not exist **or** you have no permission to change it.
|
||||
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/imported_secrets/(id)/
|
||||
|
||||
Delete a secret. You can only delete secrets that have not yet been used.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/imported_secrets/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 secret to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/secret does not exist **or** you have no permission to change it **or** the secret has already been used
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
.. _`plugin-docs`:
|
||||
|
||||
Plugin documentation
|
||||
====================
|
||||
|
||||
@@ -12,13 +10,11 @@ If you want to **create** a plugin, please go to the
|
||||
:maxdepth: 2
|
||||
|
||||
list
|
||||
pretixdroid
|
||||
banktransfer
|
||||
ticketoutputpdf
|
||||
badges
|
||||
campaigns
|
||||
certificates
|
||||
digital
|
||||
exhibitors
|
||||
imported_secrets
|
||||
webinar
|
||||
presale-saml
|
||||
|
||||
@@ -5,6 +5,6 @@ List of plugins
|
||||
===============
|
||||
|
||||
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
|
||||
|
||||
@@ -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
368
doc/plugins/pretixdroid.rst
Normal 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
|
||||
@@ -1,6 +1,5 @@
|
||||
-e ../src/
|
||||
sphinx==2.3.*
|
||||
jinja2==3.0.*
|
||||
sphinx-rtd-theme
|
||||
sphinxcontrib-httpdomain
|
||||
sphinxcontrib-images
|
||||
|
||||
@@ -103,7 +103,6 @@ prepending
|
||||
preprocessor
|
||||
presale
|
||||
pretix
|
||||
pretixLEAD
|
||||
pretixSCAN
|
||||
pretixdroid
|
||||
pretixPOS
|
||||
|
||||
@@ -203,4 +203,4 @@ Then, please contact support@pretix.eu and we will enable DKIM for your domain o
|
||||
|
||||
|
||||
.. _Sender Policy Framework: https://en.wikipedia.org/wiki/Sender_Policy_Framework
|
||||
.. _SPF specification: http://www.open-spf.org/SPF_Record_Syntax
|
||||
.. _SPF specification: http://www.openspf.org/SPF_Record_Syntax
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
.. _secret_generators:
|
||||
|
||||
Ticket secret generators
|
||||
========================
|
||||
|
||||
|
||||
@@ -19,4 +19,4 @@
|
||||
# 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/>.
|
||||
#
|
||||
__version__ = "4.8.0.dev0"
|
||||
__version__ = "4.6.0.dev0"
|
||||
|
||||
@@ -167,8 +167,6 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
||||
('GET', 'api-v1:checkinlist-list'),
|
||||
('POST', 'api-v1:checkinlistpos-redeem'),
|
||||
('POST', 'plugins:pretix_posbackend:order.posprintlog'),
|
||||
('POST', 'plugins:pretix_posbackend:order.poslock'),
|
||||
('DELETE', 'plugins:pretix_posbackend:order.poslock'),
|
||||
('DELETE', 'api-v1:cartposition-detail'),
|
||||
('GET', 'api-v1:giftcard-list'),
|
||||
('POST', 'api-v1:giftcard-transact'),
|
||||
@@ -176,11 +174,8 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
||||
('POST', 'plugins:pretix_posbackend:posreceipt-list'),
|
||||
('POST', 'plugins:pretix_posbackend:posclosing-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'),
|
||||
('POST', 'plugins:pretix_posbackend:stripeterminal.token'),
|
||||
('PUT', 'plugins:pretix_posbackend:file.upload'),
|
||||
('GET', 'api-v1:revokedsecrets-list'),
|
||||
('GET', 'api-v1:event.settings'),
|
||||
('GET', 'plugins:pretix_seating:event.event'),
|
||||
|
||||
@@ -60,7 +60,7 @@ class CheckinListSerializer(I18nAwareModelSerializer):
|
||||
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
full_data.update(data)
|
||||
|
||||
for item in full_data.get('limit_products', []):
|
||||
for item in full_data.get('limit_products'):
|
||||
if event != item.event:
|
||||
raise ValidationError(_('One or more items do not belong to this event.'))
|
||||
|
||||
|
||||
@@ -637,7 +637,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
||||
class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = TaxRule
|
||||
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', 'keep_gross_if_rate_changes')
|
||||
fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country')
|
||||
|
||||
|
||||
class EventSettingsSerializer(SettingsSerializer):
|
||||
@@ -713,6 +713,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'ticket_download_require_validated_email',
|
||||
'ticket_secret_length',
|
||||
'mail_prefix',
|
||||
'mail_from',
|
||||
'mail_from_name',
|
||||
'mail_attach_ical',
|
||||
'mail_attach_tickets',
|
||||
|
||||
@@ -58,9 +58,8 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = ItemVariation
|
||||
fields = ('id', 'value', 'active', 'description',
|
||||
'position', 'default_price', 'price', 'original_price', 'require_approval',
|
||||
'require_membership', 'require_membership_types',
|
||||
'require_membership_hidden', 'available_from', 'available_until',
|
||||
'position', 'default_price', 'price', 'original_price',
|
||||
'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until',
|
||||
'sales_channels', 'hide_without_voucher',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -75,9 +74,8 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = ItemVariation
|
||||
fields = ('id', 'value', 'active', 'description',
|
||||
'position', 'default_price', 'price', 'original_price', 'require_approval',
|
||||
'require_membership', 'require_membership_types',
|
||||
'require_membership_hidden', 'available_from', 'available_until',
|
||||
'position', 'default_price', 'price', 'original_price',
|
||||
'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until',
|
||||
'sales_channels', 'hide_without_voucher',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -251,12 +249,9 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {}
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
picture = validated_data.pop('picture', None)
|
||||
require_membership_types = validated_data.pop('require_membership_types', [])
|
||||
item = Item.objects.create(**validated_data)
|
||||
if 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:
|
||||
require_membership_types = variation_data.pop('require_membership_types', [])
|
||||
|
||||
@@ -934,8 +934,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
consume_carts = serializers.ListField(child=serializers.CharField(), required=False)
|
||||
force = serializers.BooleanField(default=False, required=False)
|
||||
payment_date = serializers.DateTimeField(required=False, allow_null=True)
|
||||
send_email = serializers.BooleanField(default=False, required=False, allow_null=True)
|
||||
require_approval = serializers.BooleanField(default=False, required=False)
|
||||
send_email = 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)
|
||||
|
||||
@@ -948,7 +947,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
model = Order
|
||||
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
||||
'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):
|
||||
if pp is None:
|
||||
@@ -1042,8 +1041,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
force = validated_data.pop('force', False)
|
||||
simulate = validated_data.pop('simulate', 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:
|
||||
iadata = validated_data.pop('invoice_address')
|
||||
@@ -1222,8 +1219,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
order.set_expires(subevents=[p.get('subevent') for p in positions_data])
|
||||
order.meta_info = "{}"
|
||||
order.total = Decimal('0.00')
|
||||
if validated_data.get('require_approval') is not None:
|
||||
order.require_approval = validated_data['require_approval']
|
||||
if simulate:
|
||||
order = WrappedModel(order)
|
||||
order.last_modified = now()
|
||||
@@ -1431,7 +1426,7 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = InvoiceLine
|
||||
fields = ('position', 'description', 'item', 'variation', 'subevent', 'attendee_name', 'event_date_from',
|
||||
fields = ('position', 'description', 'item', 'variation', 'attendee_name', 'event_date_from',
|
||||
'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_name', 'fee_type',
|
||||
'fee_internal_type', 'event_location')
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ class InitializationRequestSerializer(serializers.Serializer):
|
||||
hardware_model = serializers.CharField(max_length=190)
|
||||
software_brand = serializers.CharField(max_length=190)
|
||||
software_version = serializers.CharField(max_length=190)
|
||||
info = serializers.JSONField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class UpdateRequestSerializer(serializers.Serializer):
|
||||
@@ -50,7 +49,6 @@ class UpdateRequestSerializer(serializers.Serializer):
|
||||
hardware_model = serializers.CharField(max_length=190)
|
||||
software_brand = serializers.CharField(max_length=190)
|
||||
software_version = serializers.CharField(max_length=190)
|
||||
info = serializers.JSONField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class GateSerializer(serializers.ModelSerializer):
|
||||
@@ -96,7 +94,6 @@ class InitializeView(APIView):
|
||||
device.hardware_model = serializer.validated_data.get('hardware_model')
|
||||
device.software_brand = serializer.validated_data.get('software_brand')
|
||||
device.software_version = serializer.validated_data.get('software_version')
|
||||
device.info = serializer.validated_data.get('info')
|
||||
device.api_token = generate_api_token()
|
||||
device.save()
|
||||
|
||||
@@ -117,7 +114,6 @@ class UpdateView(APIView):
|
||||
device.hardware_model = serializer.validated_data.get('hardware_model')
|
||||
device.software_brand = serializer.validated_data.get('software_brand')
|
||||
device.software_version = serializer.validated_data.get('software_version')
|
||||
device.info = serializer.validated_data.get('info')
|
||||
device.save()
|
||||
device.log_action('pretix.device.updated', data=serializer.validated_data, auth=device)
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
||||
def exporters(self):
|
||||
exporters = []
|
||||
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)
|
||||
exporters.append(ex)
|
||||
return exporters
|
||||
@@ -147,11 +147,7 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
||||
@cached_property
|
||||
def exporters(self):
|
||||
exporters = []
|
||||
if isinstance(self.request.auth, (Device, TeamAPIToken)):
|
||||
perm_holder = self.request.auth
|
||||
else:
|
||||
perm_holder = self.request.user
|
||||
events = perm_holder.get_events_with_permission('can_view_orders', request=self.request).filter(
|
||||
events = (self.request.auth or self.request.user).get_events_with_permission('can_view_orders', request=self.request).filter(
|
||||
organizer=self.request.organizer
|
||||
)
|
||||
responses = register_multievent_data_exporters.send(self.request.organizer)
|
||||
@@ -161,12 +157,8 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
||||
return exporters
|
||||
|
||||
def get_serializer_kwargs(self):
|
||||
if isinstance(self.request.auth, (Device, TeamAPIToken)):
|
||||
perm_holder = self.request.auth
|
||||
else:
|
||||
perm_holder = self.request.user
|
||||
return {
|
||||
'events': perm_holder.get_events_with_permission('can_view_orders', request=self.request).filter(
|
||||
'events': self.request.auth.get_events_with_permission('can_view_orders', request=self.request).filter(
|
||||
organizer=self.request.organizer
|
||||
)
|
||||
}
|
||||
|
||||
@@ -646,11 +646,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
payment and order.total == Decimal('0.00') and order.status == Order.STATUS_PAID and
|
||||
not order.require_approval and payment.provider == "free"
|
||||
)
|
||||
if order.require_approval:
|
||||
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:
|
||||
if free_flow:
|
||||
email_template = request.event.settings.mail_text_order_free
|
||||
log_entry = 'pretix.event.order.email.order_free'
|
||||
email_attendees = request.event.settings.mail_send_order_free_attendee
|
||||
@@ -663,13 +659,12 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
|
||||
_order_placed_email(
|
||||
request.event, order, payment.payment_provider if payment else None, email_template,
|
||||
log_entry, invoice, payment, is_free=free_flow
|
||||
log_entry, invoice, payment
|
||||
)
|
||||
if email_attendees:
|
||||
for p in order.positions.all():
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
||||
_order_placed_email_attendee(request.event, order, p, email_attendees_template, log_entry,
|
||||
is_free=free_flow)
|
||||
_order_placed_email_attendee(request.event, order, p, email_attendees_template, log_entry)
|
||||
|
||||
if not free_flow and order.status == Order.STATUS_PAID and payment:
|
||||
payment._send_paid_mail(invoice, None, '')
|
||||
|
||||
@@ -94,9 +94,6 @@ class BaseAuthBackend:
|
||||
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``.
|
||||
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
|
||||
|
||||
@@ -107,9 +104,7 @@ class BaseAuthBackend:
|
||||
reverse proxy, you can directly return a ``User`` object that will be logged in.
|
||||
|
||||
``request`` will contain the current request.
|
||||
|
||||
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``.
|
||||
You are expected to either return a ``User`` object (if login was successful) or ``None``.
|
||||
"""
|
||||
return
|
||||
|
||||
@@ -151,8 +146,7 @@ class NativeAuthBackend(BaseAuthBackend):
|
||||
d = OrderedDict([
|
||||
('email', forms.EmailField(label=_("E-mail"), max_length=254,
|
||||
widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))),
|
||||
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput,
|
||||
max_length=4096)),
|
||||
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput)),
|
||||
])
|
||||
return d
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ from django.core.mail.backends.smtp import EmailBackend
|
||||
from django.db.models import Count
|
||||
from django.dispatch import receiver
|
||||
from django.template.loader import get_template
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import (
|
||||
get_language, gettext_lazy as _, pgettext_lazy,
|
||||
@@ -165,20 +164,9 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
has_addons=Count('addons')
|
||||
))
|
||||
htmlctx['cart'] = [(k, list(v)) for k, v in groupby(
|
||||
sorted(
|
||||
positions,
|
||||
key=lambda op: (
|
||||
(op.addon_to.positionid if op.addon_to_id else op.positionid),
|
||||
op.positionid
|
||||
)
|
||||
),
|
||||
key=lambda op: (
|
||||
op.item,
|
||||
op.variation,
|
||||
op.subevent,
|
||||
op.attendee_name,
|
||||
op.addon_to_id,
|
||||
(op.pk if op.has_addons else None)
|
||||
positions, key=lambda op: (
|
||||
op.item, op.variation, op.subevent, op.attendee_name,
|
||||
(op.pk if op.addon_to_id else None), (op.pk if op.has_addons else None)
|
||||
)
|
||||
)]
|
||||
|
||||
@@ -310,11 +298,7 @@ def get_email_context(**kwargs):
|
||||
val = [val]
|
||||
for v in val:
|
||||
if all(rp in kwargs for rp in v.required_context):
|
||||
try:
|
||||
ctx[v.identifier] = v.render(kwargs)
|
||||
except:
|
||||
ctx[v.identifier] = '(error)'
|
||||
logger.exception(f'Failed to process email placeholder {v.identifier}.')
|
||||
ctx[v.identifier] = v.render(kwargs)
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -469,15 +453,6 @@ def base_placeholders(sender, **kwargs):
|
||||
}
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event_location', ['event_or_subevent'], lambda event_or_subevent: str(event_or_subevent.location or ''),
|
||||
lambda event: str(event.location or ''),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'event_admission_time', ['event_or_subevent'],
|
||||
lambda event_or_subevent: date_format(event_or_subevent.date_admission, 'TIME_FORMAT') if event_or_subevent.date_admission else '',
|
||||
lambda event: date_format(event.date_admission, 'TIME_FORMAT') if event.date_admission else '',
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
'subevent', ['waiting_list_entry', 'event'],
|
||||
lambda waiting_list_entry, event: str(waiting_list_entry.subevent or event),
|
||||
@@ -647,10 +622,6 @@ def base_placeholders(sender, **kwargs):
|
||||
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
|
||||
v
|
||||
))
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
'meta_%s' % k, ['event_or_subevent'], lambda event_or_subevent, k=k: event_or_subevent.meta_data[k],
|
||||
v
|
||||
))
|
||||
|
||||
return ph
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
import io
|
||||
import re
|
||||
import tempfile
|
||||
from collections import OrderedDict, namedtuple
|
||||
from decimal import Decimal
|
||||
@@ -45,13 +46,26 @@ from django.conf import settings
|
||||
from django.db.models import QuerySet
|
||||
from django.utils.formats import localize
|
||||
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.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:
|
||||
@@ -214,7 +228,7 @@ class ListExporter(BaseExporter):
|
||||
pass
|
||||
|
||||
def _render_xlsx(self, form_data, output_file=None):
|
||||
wb = SafeWorkbook(write_only=True)
|
||||
wb = Workbook(write_only=True)
|
||||
ws = wb.create_sheet()
|
||||
self.prepare_xlsx_sheet(ws)
|
||||
try:
|
||||
@@ -228,7 +242,7 @@ class ListExporter(BaseExporter):
|
||||
total = line.total
|
||||
continue
|
||||
ws.append([
|
||||
val for val in line
|
||||
excel_safe(val) for val in line
|
||||
])
|
||||
if total:
|
||||
counter += 1
|
||||
@@ -333,7 +347,7 @@ class MultiSheetListExporter(ListExporter):
|
||||
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
|
||||
|
||||
def _render_xlsx(self, form_data, output_file=None):
|
||||
wb = SafeWorkbook(write_only=True)
|
||||
wb = Workbook(write_only=True)
|
||||
n_sheets = len(self.sheets)
|
||||
for i_sheet, (s, l) in enumerate(self.sheets):
|
||||
ws = wb.create_sheet(str(l))
|
||||
@@ -347,7 +361,8 @@ class MultiSheetListExporter(ListExporter):
|
||||
total = line.total
|
||||
continue
|
||||
ws.append([
|
||||
val for val in line
|
||||
excel_safe(val)
|
||||
for val in line
|
||||
])
|
||||
if total:
|
||||
counter += 1
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
#
|
||||
from .answers import * # noqa
|
||||
from .dekodi import * # noqa
|
||||
from .events import * # noqa
|
||||
from .invoices import * # noqa
|
||||
from .json import * # noqa
|
||||
from .mail import * # noqa
|
||||
|
||||
@@ -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
|
||||
@@ -573,7 +573,6 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
pgettext('address', 'State'),
|
||||
_('Voucher'),
|
||||
_('Pseudonymization ID'),
|
||||
_('Ticket secret'),
|
||||
_('Seat ID'),
|
||||
_('Seat name'),
|
||||
_('Seat zone'),
|
||||
@@ -670,7 +669,6 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
op.state or '',
|
||||
op.voucher.code if op.voucher else '',
|
||||
op.pseudonymization_id,
|
||||
op.secret,
|
||||
]
|
||||
|
||||
if op.seat:
|
||||
|
||||
@@ -38,7 +38,6 @@ import i18nfield.forms
|
||||
from django import forms
|
||||
from django.forms.models import ModelFormMetaclass
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from formtools.wizard.views import SessionWizardView
|
||||
from hierarkey.forms import HierarkeyForm
|
||||
|
||||
@@ -113,42 +112,12 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
|
||||
if isinstance(f, (RelativeDateTimeField, RelativeDateField)):
|
||||
f.set_event(self.obj)
|
||||
|
||||
def _unmask_secret_fields(self):
|
||||
def save(self):
|
||||
for k, v in self.cleaned_data.items():
|
||||
if isinstance(self.fields.get(k), SecretKeySettingsField) and self.cleaned_data.get(k) == SECRET_REDACTED:
|
||||
self.cleaned_data[k] = self.initial[k]
|
||||
|
||||
def save(self):
|
||||
self._unmask_secret_fields()
|
||||
return super().save()
|
||||
|
||||
def clean(self):
|
||||
d = super().clean()
|
||||
|
||||
# There is logic in HierarkeyForm.save() to only persist fields that changed. HierarkeyForm determines if
|
||||
# something changed by comparing `self._s.get(name)` to `value`. This leaves an edge case open for multi-lingual
|
||||
# text fields. On the very first load, the initial value in `self._s.get(name)` will be a LazyGettextProxy-based
|
||||
# string. However, only some of the languages are usually visible, so even if the user does not change anything
|
||||
# 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.
|
||||
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):
|
||||
value = d.get(name)
|
||||
if not value:
|
||||
continue
|
||||
|
||||
current = self._s.get(name, as_type=type(value))
|
||||
if name not in self.changed_data:
|
||||
d[name] = current
|
||||
|
||||
return d
|
||||
|
||||
def get_new_filename(self, name: str) -> str:
|
||||
from pretix.base.models import Event
|
||||
|
||||
|
||||
@@ -154,7 +154,6 @@ class RegistrationForm(forms.Form):
|
||||
widget=forms.PasswordInput(attrs={
|
||||
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
|
||||
}),
|
||||
max_length=4096,
|
||||
required=True
|
||||
)
|
||||
password_repeat = forms.CharField(
|
||||
@@ -162,7 +161,6 @@ class RegistrationForm(forms.Form):
|
||||
widget=forms.PasswordInput(attrs={
|
||||
'autocomplete': 'new-password' # see https://bugs.chromium.org/p/chromium/issues/detail?id=370363#c7
|
||||
}),
|
||||
max_length=4096,
|
||||
required=True
|
||||
)
|
||||
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
|
||||
@@ -206,13 +204,11 @@ class PasswordRecoverForm(forms.Form):
|
||||
password = forms.CharField(
|
||||
label=_('Password'),
|
||||
widget=forms.PasswordInput,
|
||||
max_length=4096,
|
||||
required=True
|
||||
)
|
||||
password_repeat = forms.CharField(
|
||||
label=_('Repeat password'),
|
||||
widget=forms.PasswordInput,
|
||||
max_length=4096,
|
||||
widget=forms.PasswordInput
|
||||
)
|
||||
|
||||
def __init__(self, user_id=None, *args, **kwargs):
|
||||
|
||||
@@ -41,16 +41,16 @@ from io import BytesIO
|
||||
import dateutil.parser
|
||||
import pycountry
|
||||
import pytz
|
||||
from babel import Locale
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.core.validators import (
|
||||
MaxValueValidator, MinValueValidator, RegexValidator,
|
||||
)
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db.models import QuerySet
|
||||
from django.forms import Select, widgets
|
||||
from django.utils import translation
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.html import escape
|
||||
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 (
|
||||
ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField,
|
||||
)
|
||||
from pretix.helpers.countries import (
|
||||
CachedCountries, get_phone_prefixes_sorted_and_localized,
|
||||
)
|
||||
from pretix.helpers.countries import CachedCountries
|
||||
from pretix.helpers.escapejson import escapejson_attr
|
||||
from pretix.helpers.i18n import get_format_without_seconds
|
||||
from pretix.presale.signals import question_form_fields
|
||||
@@ -189,15 +187,6 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
defaults = {
|
||||
'widget': self.widget,
|
||||
'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.titles = kwargs.pop('titles')
|
||||
@@ -218,7 +207,6 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
if fname == 'title' and self.scheme_titles:
|
||||
d = dict(defaults)
|
||||
d.pop('max_length', None)
|
||||
d.pop('validators', None)
|
||||
field = forms.ChoiceField(
|
||||
**d,
|
||||
choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]]
|
||||
@@ -227,7 +215,6 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
elif fname == 'salutation':
|
||||
d = dict(defaults)
|
||||
d.pop('max_length', None)
|
||||
d.pop('validators', None)
|
||||
field = forms.ChoiceField(
|
||||
**d,
|
||||
choices=[('', '---')] + PERSON_NAME_SALUTATIONS
|
||||
@@ -264,14 +251,17 @@ class WrappedPhonePrefixSelect(Select):
|
||||
|
||||
def __init__(self, initial=None):
|
||||
choices = [("", "---------")]
|
||||
|
||||
if initial:
|
||||
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
||||
if initial in values:
|
||||
self.initial = "+%d" % prefix
|
||||
break
|
||||
choices += get_phone_prefixes_sorted_and_localized()
|
||||
super().__init__(choices=choices, attrs={'aria-label': pgettext_lazy('phonenumber', 'International area code')})
|
||||
language = get_babel_locale() # changed from default implementation that used the django locale
|
||||
locale = Locale(translation.to_locale(language))
|
||||
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
||||
prefix = "+%d" % prefix
|
||||
if initial and initial in values:
|
||||
self.initial = prefix
|
||||
for country_code in values:
|
||||
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):
|
||||
return super().render(name, value or self.initial, *args, **kwargs)
|
||||
@@ -315,12 +305,7 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
|
||||
silently deleting data.
|
||||
"""
|
||||
if value:
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = PhoneNumber.from_string(value)
|
||||
except:
|
||||
pass
|
||||
if isinstance(value, PhoneNumber):
|
||||
if type(value) == PhoneNumber:
|
||||
if value.country_code and value.national_number:
|
||||
return [
|
||||
"+%d" % value.country_code,
|
||||
@@ -348,41 +333,23 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
|
||||
def guess_country(event):
|
||||
# Try to guess the initial country from either the country of the merchant
|
||||
# or the locale. This will hopefully save at least some users some scrolling :)
|
||||
locale = get_language_without_region()
|
||||
country = event.settings.region or event.settings.invoice_address_from_country
|
||||
if not country:
|
||||
country = get_country_by_locale(get_language_without_region())
|
||||
valid_countries = countries.countries
|
||||
if '-' in locale:
|
||||
parts = locale.split('-')
|
||||
# TODO: does this actually work?
|
||||
if parts[1].upper() in valid_countries:
|
||||
country = Country(parts[1].upper())
|
||||
elif parts[0].upper() in valid_countries:
|
||||
country = Country(parts[0].upper())
|
||||
else:
|
||||
if locale.upper() in valid_countries:
|
||||
country = Country(locale.upper())
|
||||
return country
|
||||
|
||||
|
||||
def get_country_by_locale(locale):
|
||||
country = None
|
||||
valid_countries = countries.countries
|
||||
if '-' in locale:
|
||||
parts = locale.split('-')
|
||||
# TODO: does this actually work?
|
||||
if parts[1].upper() in valid_countries:
|
||||
country = Country(parts[1].upper())
|
||||
elif parts[0].upper() in valid_countries:
|
||||
country = Country(parts[0].upper())
|
||||
else:
|
||||
if locale.upper() in valid_countries:
|
||||
country = Country(locale.upper())
|
||||
return country
|
||||
|
||||
|
||||
def guess_phone_prefix(event):
|
||||
with language(get_babel_locale()):
|
||||
country = str(guess_country(event))
|
||||
return get_phone_prefix(country)
|
||||
|
||||
|
||||
def get_phone_prefix(country):
|
||||
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
||||
if country in values:
|
||||
return prefix
|
||||
return None
|
||||
|
||||
|
||||
class QuestionCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
|
||||
option_template_name = 'pretixbase/forms/widgets/checkbox_option_with_links.html'
|
||||
|
||||
@@ -707,7 +674,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
label=label, required=required,
|
||||
min_value=q.valid_number_min or Decimal('0.00'),
|
||||
max_value=q.valid_number_max,
|
||||
help_text=help_text,
|
||||
help_text=q.help_text,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_STRING:
|
||||
@@ -813,26 +780,25 @@ class BaseQuestionsForm(forms.Form):
|
||||
if q.valid_datetime_max:
|
||||
field.validators.append(MaxDateTimeValidator(q.valid_datetime_max))
|
||||
elif q.type == Question.TYPE_PHONENUMBER:
|
||||
if initial:
|
||||
with language(get_babel_locale()):
|
||||
default_country = guess_country(event)
|
||||
default_prefix = None
|
||||
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
||||
if str(default_country) in values:
|
||||
default_prefix = prefix
|
||||
try:
|
||||
initial = PhoneNumber().from_string(initial.answer)
|
||||
initial = PhoneNumber().from_string(initial.answer) if initial else "+{}.".format(default_prefix)
|
||||
except NumberParseException:
|
||||
initial = None
|
||||
|
||||
if not initial:
|
||||
phone_prefix = guess_phone_prefix(event)
|
||||
if phone_prefix:
|
||||
initial = "+{}.".format(phone_prefix)
|
||||
|
||||
field = PhoneNumberField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
|
||||
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
|
||||
# the future.
|
||||
initial=initial,
|
||||
widget=WrappedPhoneNumberPrefixWidget()
|
||||
)
|
||||
field = PhoneNumberField(
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
|
||||
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
|
||||
# the future.
|
||||
initial=initial,
|
||||
widget=WrappedPhoneNumberPrefixWidget()
|
||||
)
|
||||
field.question = q
|
||||
if answers:
|
||||
# Cache the answer object for later use
|
||||
|
||||
@@ -42,24 +42,6 @@ from django.utils.timezone import get_current_timezone, now
|
||||
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):
|
||||
def __init__(self, attrs=None, date_format=None):
|
||||
attrs = attrs or {}
|
||||
@@ -80,10 +62,6 @@ class DatePickerWidget(forms.DateInput):
|
||||
|
||||
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):
|
||||
def __init__(self, attrs=None, time_format=None):
|
||||
@@ -105,13 +83,17 @@ class TimePickerWidget(forms.TimeInput):
|
||||
|
||||
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):
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Browsers can't recognize that the server already has a file uploaded
|
||||
# Don't mark this input as being required if we already have an answer
|
||||
# (this needs to be done via the attrs, otherwise we wouldn't get the "required" star on the field label)
|
||||
attrs = kwargs.get('attrs', {})
|
||||
if kwargs.get('required') and kwargs.get('initial'):
|
||||
attrs.update({'required': None})
|
||||
kwargs.update({'attrs': attrs})
|
||||
|
||||
self.position = kwargs.pop('position')
|
||||
self.event = kwargs.pop('event')
|
||||
self.answer = kwargs.pop('answer')
|
||||
@@ -143,15 +125,6 @@ class UploadedFileWidget(forms.ClearableFileInput):
|
||||
'answer': self.answer.pk,
|
||||
})
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
# Browsers can't recognize that the server already has a file uploaded
|
||||
# Don't mark this input as being required if we already have an answer
|
||||
# (this needs to be done via the attrs, otherwise we wouldn't get the "required" star on the field label)
|
||||
ctx = super().get_context(name, value, attrs)
|
||||
if ctx['widget']['is_initial']:
|
||||
ctx['widget']['attrs']['required'] = False
|
||||
return ctx
|
||||
|
||||
def format_value(self, value):
|
||||
if self.is_initial(value):
|
||||
return self.FakeFile(value, self.position, self.event, self.answer)
|
||||
@@ -205,10 +178,6 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
# Skip one hierarchy level
|
||||
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):
|
||||
def __init__(self, require_business=False, attrs=None):
|
||||
|
||||
@@ -23,9 +23,7 @@ from decimal import Decimal
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import models
|
||||
from django.db.models import (
|
||||
Case, Count, F, OuterRef, Q, Subquery, Sum, Value, When,
|
||||
)
|
||||
from django.db.models import Case, F, OuterRef, Q, Subquery, Sum, Value, When
|
||||
from django.db.models.functions import Coalesce
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
@@ -47,18 +45,6 @@ class Command(BaseCommand):
|
||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
),
|
||||
position_cnt=Case(
|
||||
When(Q(status__in=('e', 'c')) | Q(require_approval=True), then=Value(0)),
|
||||
default=Coalesce(
|
||||
Subquery(
|
||||
OrderPosition.objects.filter(
|
||||
order=OuterRef('pk')
|
||||
).order_by().values('order').annotate(p=Count('*')).values('p'),
|
||||
output_field=models.IntegerField()
|
||||
), Value(0), output_field=models.IntegerField()
|
||||
),
|
||||
output_field=models.IntegerField()
|
||||
),
|
||||
fee_total=Coalesce(
|
||||
Subquery(
|
||||
OrderFee.objects.filter(
|
||||
@@ -75,15 +61,6 @@ class Command(BaseCommand):
|
||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
),
|
||||
tx_cnt=Coalesce(
|
||||
Subquery(
|
||||
Transaction.objects.filter(
|
||||
order=OuterRef('pk'),
|
||||
item__isnull=False,
|
||||
).order_by().values('order').annotate(p=Sum(F('count'))).values('p'),
|
||||
output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
|
||||
),
|
||||
).annotate(
|
||||
correct_total=Case(
|
||||
When(Q(status=Order.STATUS_CANCELED) | Q(status=Order.STATUS_EXPIRED) | Q(require_approval=True),
|
||||
@@ -93,15 +70,13 @@ class Command(BaseCommand):
|
||||
),
|
||||
).exclude(
|
||||
total=F('position_total') + F('fee_total'),
|
||||
tx_total=F('correct_total'),
|
||||
tx_cnt=F('position_cnt')
|
||||
tx_total=F('correct_total')
|
||||
).select_related('event')
|
||||
for o in qs:
|
||||
if abs(o.tx_total - o.correct_total) < Decimal('0.00001') and abs(o.position_total + o.fee_total - o.total) < Decimal('0.00001') \
|
||||
and o.tx_cnt == o.position_cnt:
|
||||
if abs(o.tx_total - o.correct_total) < Decimal('0.00001') and abs(o.position_total + o.fee_total - o.total) < Decimal('0.00001'):
|
||||
# Ignore SQLite which treats Decimals like floats…
|
||||
continue
|
||||
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}")
|
||||
|
||||
self.stderr.write(self.style.SUCCESS('Check completed.'))
|
||||
self.stderr.write(self.style.SUCCESS(f'Check completed.'))
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.2.9 on 2021-12-13 14:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0204_orderposition_backfill_is_bundled'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='itemvariation',
|
||||
name='require_approval',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -1,19 +0,0 @@
|
||||
# Generated by Django 3.2.9 on 2022-01-12 10:59
|
||||
|
||||
import phonenumber_field.modelfields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0205_itemvariation_require_approval'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='customer',
|
||||
name='phone',
|
||||
field=phonenumber_field.modelfields.PhoneNumberField(max_length=128, null=True, region=None),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 3.2.4 on 2022-01-19 14:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0206_customer_phone'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='taxrule',
|
||||
name='internal_name',
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='taxrule',
|
||||
name='keep_gross_if_rate_changes',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -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')},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -44,7 +44,7 @@ from django.contrib.auth.models import (
|
||||
)
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
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.utils.crypto import get_random_string, salted_hmac
|
||||
from django.utils.timezone import now
|
||||
@@ -61,10 +61,6 @@ from pretix.helpers.urls import build_absolute_uri
|
||||
from .base import LoggingMixin
|
||||
|
||||
|
||||
class EmailAddressTakenError(IntegrityError):
|
||||
pass
|
||||
|
||||
|
||||
class UserManager(BaseUserManager):
|
||||
"""
|
||||
This is the user manager for our custom user model. See the User
|
||||
@@ -87,116 +83,6 @@ class UserManager(BaseUserManager):
|
||||
user.save()
|
||||
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():
|
||||
return get_random_string(length=32)
|
||||
@@ -231,10 +117,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
:type needs_password_change: bool
|
||||
:param timezone: The user's preferred timezone.
|
||||
: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'
|
||||
@@ -270,7 +152,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
)
|
||||
notifications_token = models.CharField(max_length=255, default=generate_notifications_token)
|
||||
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)
|
||||
|
||||
objects = UserManager()
|
||||
@@ -283,7 +164,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
verbose_name = _("User")
|
||||
verbose_name_plural = _("Users")
|
||||
ordering = ('email',)
|
||||
unique_together = (('auth_backend', 'auth_backend_identifier'),)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
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))
|
||||
)
|
||||
|
||||
@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()
|
||||
def get_organizers_with_permission(self, permission, request=None):
|
||||
"""
|
||||
|
||||
@@ -221,7 +221,7 @@ class CheckinList(LoggedModel):
|
||||
return rules
|
||||
|
||||
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:
|
||||
cls.validate_rules(v, seen_nonbool=seen_nonbool or operator not in ('or', 'and'), depth=depth + 1)
|
||||
|
||||
@@ -29,7 +29,6 @@ from django.db.models import F, Q
|
||||
from django.utils.crypto import get_random_string, salted_hmac
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
|
||||
from pretix.base.banlist import banned
|
||||
from pretix.base.models.base import LoggedModel
|
||||
@@ -46,7 +45,6 @@ class Customer(LoggedModel):
|
||||
organizer = models.ForeignKey(Organizer, related_name='customers', on_delete=models.CASCADE)
|
||||
identifier = models.CharField(max_length=190, db_index=True, unique=True)
|
||||
email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('E-mail'), max_length=190)
|
||||
phone = PhoneNumberField(null=True, blank=True, verbose_name=_('Phone number'))
|
||||
password = models.CharField(verbose_name=_('Password'), max_length=128)
|
||||
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
|
||||
name_parts = models.JSONField(default=dict)
|
||||
@@ -89,7 +87,6 @@ class Customer(LoggedModel):
|
||||
self.name_parts = {}
|
||||
self.name_cached = ''
|
||||
self.email = None
|
||||
self.phone = None
|
||||
self.save()
|
||||
self.all_logentries().update(data={}, shredded=True)
|
||||
self.orders.all().update(customer=None)
|
||||
@@ -172,7 +169,6 @@ class Customer(LoggedModel):
|
||||
return salted_hmac(key_salt, payload).hexdigest()
|
||||
|
||||
def get_email_context(self):
|
||||
from pretix.base.email import get_name_parts_localized
|
||||
ctx = {
|
||||
'name': self.name,
|
||||
'organizer': self.organizer.name,
|
||||
@@ -181,13 +177,7 @@ class Customer(LoggedModel):
|
||||
for f, l, w in name_scheme['fields']:
|
||||
if f == 'full_name':
|
||||
continue
|
||||
ctx['name_%s' % f] = get_name_parts_localized(self.name_parts, 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)
|
||||
|
||||
ctx['name_%s' % f] = self.name_parts.get(f, '')
|
||||
return ctx
|
||||
|
||||
@property
|
||||
|
||||
@@ -156,9 +156,6 @@ class Device(LoggedModel):
|
||||
null=True,
|
||||
blank=False
|
||||
)
|
||||
info = models.JSONField(
|
||||
null=True, blank=True,
|
||||
)
|
||||
|
||||
objects = ScopedManager(organizer='organizer')
|
||||
|
||||
|
||||
@@ -665,13 +665,13 @@ class Event(EventMixin, LoggedModel):
|
||||
|
||||
return locking.LockManager(self)
|
||||
|
||||
def get_mail_backend(self, timeout=None):
|
||||
def get_mail_backend(self, timeout=None, force_custom=False):
|
||||
"""
|
||||
Returns an email server connection, either by using the system-wide connection
|
||||
or by returning a custom one based on the event's settings.
|
||||
"""
|
||||
|
||||
if self.settings.smtp_use_custom:
|
||||
if self.settings.smtp_use_custom or force_custom:
|
||||
return get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
|
||||
host=self.settings.smtp_host,
|
||||
port=self.settings.smtp_port,
|
||||
@@ -1179,21 +1179,21 @@ class Event(EventMixin, LoggedModel):
|
||||
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_available = self.get_available_plugins()
|
||||
|
||||
enable = [m for m in modules if m not in plugins_active and m in plugins_available]
|
||||
|
||||
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)
|
||||
elif hasattr(plugins_available[module].app, 'installed'):
|
||||
getattr(plugins_available[module].app, 'installed')(self)
|
||||
|
||||
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()
|
||||
from pretix.presale.style import regenerate_css
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ import dateutil.parser
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
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.models import Q
|
||||
from django.utils import formats
|
||||
@@ -479,14 +479,12 @@ class Item(LoggedModel):
|
||||
min_per_order = models.IntegerField(
|
||||
verbose_name=_('Minimum amount per order'),
|
||||
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 '
|
||||
'the field empty or set it to 0, there is no special limit for this product.')
|
||||
)
|
||||
max_per_order = models.IntegerField(
|
||||
verbose_name=_('Maximum amount per order'),
|
||||
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 '
|
||||
'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.')
|
||||
@@ -766,9 +764,6 @@ class ItemVariation(models.Model):
|
||||
:type default_price: decimal.Decimal
|
||||
:param original_price: The item's "original" price. Will not be used for any calculations, will just be shown.
|
||||
:type original_price: decimal.Decimal
|
||||
:param require_approval: If set to ``True``, orders containing this variation can only be processed and paid after
|
||||
approval by an administrator
|
||||
:type require_approval: bool
|
||||
"""
|
||||
item = models.ForeignKey(
|
||||
Item,
|
||||
@@ -804,13 +799,6 @@ class ItemVariation(models.Model):
|
||||
help_text=_('If set, this will be displayed next to the current price to show that the current price is a '
|
||||
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
|
||||
)
|
||||
require_approval = models.BooleanField(
|
||||
verbose_name=_('Require approval'),
|
||||
default=False,
|
||||
help_text=_('If this variation is part of an order, the order will be put into an "approval" state and '
|
||||
'will need to be confirmed by you before it can be paid and completed. You can use this e.g. for '
|
||||
'discounted tickets that are only available to specific groups.'),
|
||||
)
|
||||
require_membership = models.BooleanField(
|
||||
verbose_name=_('Require a valid membership'),
|
||||
default=False,
|
||||
@@ -844,7 +832,7 @@ class ItemVariation(models.Model):
|
||||
blank=True,
|
||||
)
|
||||
hide_without_voucher = models.BooleanField(
|
||||
verbose_name=_('Show only if a matching voucher is redeemed.'),
|
||||
verbose_name=_('This variation will only be shown if a voucher matching the product is redeemed.'),
|
||||
default=False,
|
||||
help_text=_('This variation will be hidden from the event page until the user enters a voucher '
|
||||
'that unlocks this variation.')
|
||||
@@ -1699,7 +1687,7 @@ class Quota(LoggedModel):
|
||||
if event != item.event:
|
||||
raise ValidationError(_('One or more items do not belong to this event.'))
|
||||
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.'))
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -638,13 +638,12 @@ class Order(LockModel, LoggedModel):
|
||||
return False
|
||||
if self.user_cancel_deadline and now() > self.user_cancel_deadline:
|
||||
return False
|
||||
|
||||
if self.status == Order.STATUS_PAID or self.payment_refund_sum > Decimal('0.00'):
|
||||
if self.status == Order.STATUS_PENDING:
|
||||
return self.event.settings.cancel_allow_user
|
||||
elif self.status == Order.STATUS_PAID:
|
||||
if self.total == Decimal('0.00'):
|
||||
return self.event.settings.cancel_allow_user
|
||||
return self.event.settings.cancel_allow_user_paid
|
||||
elif self.status == Order.STATUS_PENDING:
|
||||
return self.event.settings.cancel_allow_user
|
||||
return False
|
||||
|
||||
def propose_auto_refunds(self, amount: Decimal, payments: list=None):
|
||||
@@ -951,7 +950,7 @@ class Order(LockModel, LoggedModel):
|
||||
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
|
||||
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
|
||||
auth=None, attach_tickets=False, position: 'OrderPosition'=None, auto_email=True,
|
||||
attach_ical=False, attach_other_files: list=None):
|
||||
attach_ical=False):
|
||||
"""
|
||||
Sends an email to the user that placed this order. Basically, this method does two things:
|
||||
|
||||
@@ -977,7 +976,7 @@ class Order(LockModel, LoggedModel):
|
||||
SendMailException, TolerantDict, mail, render_mail,
|
||||
)
|
||||
|
||||
if not self.email and not (position and position.attendee_email):
|
||||
if not self.email:
|
||||
return
|
||||
|
||||
for k, v in self.event.meta_data.items():
|
||||
@@ -995,8 +994,7 @@ class Order(LockModel, LoggedModel):
|
||||
recipient, subject, template, context,
|
||||
self.event, self.locale, self, headers=headers, sender=sender,
|
||||
invoices=invoices, attach_tickets=attach_tickets,
|
||||
position=position, auto_email=auto_email, attach_ical=attach_ical,
|
||||
attach_other_files=attach_other_files,
|
||||
position=position, auto_email=auto_email, attach_ical=attach_ical
|
||||
)
|
||||
except SendMailException:
|
||||
raise
|
||||
@@ -1331,10 +1329,6 @@ class AbstractPosition(models.Model):
|
||||
else:
|
||||
return {}
|
||||
|
||||
@property
|
||||
def item_and_variation(self):
|
||||
return self.item, self.variation
|
||||
|
||||
@meta_info_data.setter
|
||||
def meta_info_data(self, d):
|
||||
self.meta_info = json.dumps(d)
|
||||
@@ -1447,15 +1441,6 @@ class AbstractPosition(models.Model):
|
||||
lines = [r.strip() for r in lines if r]
|
||||
return '\n'.join(lines).strip()
|
||||
|
||||
def requires_approval(self, invoice_address=None):
|
||||
if self.item.require_approval:
|
||||
return True
|
||||
if self.variation and self.variation.require_approval:
|
||||
return True
|
||||
if self.item.tax_rule and self.item.tax_rule._require_approval(invoice_address):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class OrderPayment(models.Model):
|
||||
"""
|
||||
@@ -1729,10 +1714,10 @@ class OrderPayment(models.Model):
|
||||
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}
|
||||
try:
|
||||
position.send_mail(
|
||||
self.order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.order_paid', user,
|
||||
invoices=[],
|
||||
invoices=[], position=position,
|
||||
attach_tickets=True,
|
||||
attach_ical=self.order.event.settings.mail_attach_ical
|
||||
)
|
||||
@@ -2331,7 +2316,7 @@ class OrderPosition(AbstractPosition):
|
||||
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
|
||||
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
|
||||
auth=None, attach_tickets=False, attach_ical=False, attach_other_files: list=None):
|
||||
auth=None, attach_tickets=False, attach_ical=False):
|
||||
"""
|
||||
Sends an email to the attendee. Basically, this method does two things:
|
||||
|
||||
@@ -2372,7 +2357,6 @@ class OrderPosition(AbstractPosition):
|
||||
invoices=invoices,
|
||||
attach_tickets=attach_tickets,
|
||||
attach_ical=attach_ical,
|
||||
attach_other_files=attach_other_files,
|
||||
)
|
||||
except SendMailException:
|
||||
raise
|
||||
|
||||
@@ -191,12 +191,12 @@ class Organizer(LoggedModel):
|
||||
e.delete()
|
||||
self.teams.all().delete()
|
||||
|
||||
def get_mail_backend(self, timeout=None):
|
||||
def get_mail_backend(self, timeout=None, force_custom=False):
|
||||
"""
|
||||
Returns an email server connection, either by using the system-wide connection
|
||||
or by returning a custom one based on the organizer's settings.
|
||||
"""
|
||||
if self.settings.smtp_use_custom:
|
||||
if self.settings.smtp_use_custom or force_custom:
|
||||
return get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
|
||||
host=self.settings.smtp_host,
|
||||
port=self.settings.smtp_port,
|
||||
|
||||
@@ -26,7 +26,7 @@ import jsonschema
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.core.exceptions import ValidationError
|
||||
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.utils.deconstruct import deconstructible
|
||||
from django.utils.timezone import now
|
||||
@@ -281,26 +281,10 @@ class Seat(models.Model):
|
||||
q = Q(has_order=True) | Q(has_voucher=True)
|
||||
if ignore_cart is not 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(
|
||||
distance=(
|
||||
Power(F('x') - self_x, Value(2), output_field=models.FloatField()) +
|
||||
Power(F('y') - self_y, Value(2), output_field=models.FloatField())
|
||||
Power(F('x') - Value(self.x), Value(2), output_field=models.FloatField()) +
|
||||
Power(F('y') - Value(self.y), Value(2), output_field=models.FloatField())
|
||||
)
|
||||
).exclude(pk=self.pk).filter(
|
||||
q,
|
||||
|
||||
@@ -81,15 +81,6 @@ class TaxedPrice:
|
||||
name=self.name,
|
||||
)
|
||||
|
||||
def __eq__(self, other):
|
||||
return (
|
||||
self.gross == other.gross and
|
||||
self.net == other.net and
|
||||
self.tax == other.tax and
|
||||
self.rate == other.rate and
|
||||
self.name == other.name
|
||||
)
|
||||
|
||||
|
||||
TAXED_ZERO = TaxedPrice(
|
||||
gross=Decimal('0.00'),
|
||||
@@ -136,13 +127,8 @@ def cc_to_vat_prefix(country_code):
|
||||
|
||||
class TaxRule(LoggedModel):
|
||||
event = models.ForeignKey('Event', related_name='tax_rules', on_delete=models.CASCADE)
|
||||
internal_name = models.CharField(
|
||||
verbose_name=_('Internal name'),
|
||||
max_length=190,
|
||||
null=True, blank=True,
|
||||
)
|
||||
name = I18nCharField(
|
||||
verbose_name=_('Official name'),
|
||||
verbose_name=_('Name'),
|
||||
help_text=_('Should be short, e.g. "VAT"'),
|
||||
max_length=190,
|
||||
)
|
||||
@@ -155,10 +141,6 @@ class TaxRule(LoggedModel):
|
||||
verbose_name=_("The configured product prices include the tax amount"),
|
||||
default=True,
|
||||
)
|
||||
keep_gross_if_rate_changes = models.BooleanField(
|
||||
verbose_name=_("Keep gross amount constant if the tax rate changes based on the invoice address"),
|
||||
default=False,
|
||||
)
|
||||
eu_reverse_charge = models.BooleanField(
|
||||
verbose_name=_("Use EU reverse charge taxation rules"),
|
||||
default=False,
|
||||
@@ -216,8 +198,6 @@ class TaxRule(LoggedModel):
|
||||
s = _('plus {rate}% {name}').format(rate=self.rate, name=self.name)
|
||||
if self.eu_reverse_charge:
|
||||
s += ' ({})'.format(_('reverse charge enabled'))
|
||||
if self.internal_name:
|
||||
return f'{self.internal_name} ({s})'
|
||||
return str(s)
|
||||
|
||||
@property
|
||||
@@ -231,7 +211,7 @@ class TaxRule(LoggedModel):
|
||||
rule = self.get_matching_rule(invoice_address)
|
||||
if rule.get('action', 'vat') == 'block':
|
||||
raise self.SaleNotAllowed()
|
||||
if rule.get('action', 'vat') in ('vat', 'require_approval') and rule.get('rate') is not None:
|
||||
if rule.get('action', 'vat') == 'vat' and rule.get('rate') is not None:
|
||||
return Decimal(rule.get('rate'))
|
||||
return Decimal(self.rate)
|
||||
|
||||
@@ -248,19 +228,13 @@ class TaxRule(LoggedModel):
|
||||
rate = override_tax_rate
|
||||
elif invoice_address:
|
||||
adjust_rate = self.tax_rate_for(invoice_address)
|
||||
if (adjust_rate == gross_price_is_tax_rate or force_fixed_gross_price or self.keep_gross_if_rate_changes) and base_price_is == 'gross':
|
||||
if (adjust_rate == gross_price_is_tax_rate or force_fixed_gross_price) and base_price_is == 'gross':
|
||||
rate = adjust_rate
|
||||
elif adjust_rate != rate:
|
||||
if self.keep_gross_if_rate_changes:
|
||||
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
|
||||
base_price = normal_price.gross
|
||||
base_price_is = 'gross'
|
||||
subtract_from_gross = Decimal('0.00')
|
||||
else:
|
||||
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
|
||||
base_price = normal_price.net
|
||||
base_price_is = 'net'
|
||||
subtract_from_gross = Decimal('0.00')
|
||||
normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross)
|
||||
base_price = normal_price.net
|
||||
base_price_is = 'net'
|
||||
subtract_from_gross = Decimal('0.00')
|
||||
rate = adjust_rate
|
||||
|
||||
if rate == Decimal('0.00'):
|
||||
@@ -363,19 +337,12 @@ class TaxRule(LoggedModel):
|
||||
|
||||
return False
|
||||
|
||||
def _require_approval(self, invoice_address):
|
||||
if self._custom_rules:
|
||||
rule = self.get_matching_rule(invoice_address)
|
||||
if rule.get('action', 'vat') == 'require_approval':
|
||||
return True
|
||||
return False
|
||||
|
||||
def _tax_applicable(self, invoice_address):
|
||||
if self._custom_rules:
|
||||
rule = self.get_matching_rule(invoice_address)
|
||||
if rule.get('action', 'vat') == 'block':
|
||||
raise self.SaleNotAllowed()
|
||||
return rule.get('action', 'vat') in ('vat', 'require_approval')
|
||||
return rule.get('action', 'vat') == 'vat'
|
||||
|
||||
if not self.eu_reverse_charge:
|
||||
# No reverse charge rules? Always apply VAT!
|
||||
|
||||
@@ -37,7 +37,6 @@ import hashlib
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
@@ -55,7 +54,6 @@ from django.utils.functional import SimpleLazyObject
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from PyPDF2 import PdfFileReader
|
||||
from pytz import timezone
|
||||
from reportlab.graphics import renderPDF
|
||||
@@ -204,11 +202,6 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"editor_sample": 'foo@bar.com',
|
||||
"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", {
|
||||
"label": _("Event name"),
|
||||
"editor_sample": _("Sample event name"),
|
||||
@@ -623,14 +616,12 @@ class Renderer:
|
||||
preserveAspectRatio=True, anchor='n',
|
||||
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')
|
||||
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
|
||||
else:
|
||||
content = self._get_text_content(op, order, o)
|
||||
elif content == 'pseudonymization_id':
|
||||
content = op.pseudonymization_id
|
||||
|
||||
level = 'H'
|
||||
if len(content) > 32:
|
||||
@@ -657,45 +648,20 @@ class Renderer:
|
||||
return self._get_text_content(op, order, o, True)
|
||||
|
||||
ev = self._get_ev(op, order)
|
||||
|
||||
if not o['content']:
|
||||
return '(error)'
|
||||
|
||||
if o['content'] == 'other' or o['content'] == 'other_i18n':
|
||||
if o['content'] == 'other_i18n':
|
||||
text = str(LazyI18nString(o['text_i18n']))
|
||||
else:
|
||||
text = o['text']
|
||||
|
||||
def replace(x):
|
||||
if 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)
|
||||
|
||||
if o['content'] == 'other':
|
||||
return o['text']
|
||||
elif o['content'].startswith('itemmeta:'):
|
||||
return op.item.meta_data.get(o['content'][9:]) or ''
|
||||
|
||||
elif o['content'].startswith('meta:'):
|
||||
return ev.meta_data.get(o['content'][5:]) or ''
|
||||
|
||||
elif o['content'] in self.variables:
|
||||
try:
|
||||
return self.variables[o['content']]['evaluate'](op, order, ev)
|
||||
except:
|
||||
logger.exception('Failed to process variable.')
|
||||
return '(error)'
|
||||
|
||||
return ''
|
||||
|
||||
def _draw_imagearea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
|
||||
@@ -788,30 +754,20 @@ class Renderer:
|
||||
p.drawOn(canvas, 0, -h - ad[1])
|
||||
canvas.restoreState()
|
||||
|
||||
def draw_page(self, canvas: Canvas, order: Order, op: OrderPosition, show_page=True, only_page=None):
|
||||
page_count = self.bg_pdf.getNumPages()
|
||||
|
||||
if not only_page and not show_page:
|
||||
raise ValueError("only_page=None and show_page=False cannot be combined")
|
||||
|
||||
for page in range(page_count):
|
||||
if only_page and only_page != page + 1:
|
||||
continue
|
||||
for o in self.layout:
|
||||
if o.get('page', 1) != page + 1:
|
||||
continue
|
||||
if o['type'] == "barcodearea":
|
||||
self._draw_barcodearea(canvas, op, order, o)
|
||||
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 draw_page(self, canvas: Canvas, order: Order, op: OrderPosition, show_page=True):
|
||||
for o in self.layout:
|
||||
if o['type'] == "barcodearea":
|
||||
self._draw_barcodearea(canvas, op, o)
|
||||
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(0).mediaBox[2], self.bg_pdf.getPage(0).mediaBox[3]))
|
||||
if show_page:
|
||||
canvas.showPage()
|
||||
|
||||
def render_background(self, buffer, title=_('Ticket')):
|
||||
if settings.PDFTK:
|
||||
@@ -824,7 +780,7 @@ class Renderer:
|
||||
subprocess.run([
|
||||
settings.PDFTK,
|
||||
os.path.join(d, 'front.pdf'),
|
||||
'multibackground',
|
||||
'background',
|
||||
os.path.join(d, 'back.pdf'),
|
||||
'output',
|
||||
os.path.join(d, 'out.pdf'),
|
||||
@@ -838,8 +794,8 @@ class Renderer:
|
||||
new_pdf = PdfFileReader(buffer)
|
||||
output = PdfFileWriter()
|
||||
|
||||
for i, page in enumerate(new_pdf.pages):
|
||||
bg_page = copy.copy(self.bg_pdf.getPage(i))
|
||||
for page in new_pdf.pages:
|
||||
bg_page = copy.copy(self.bg_pdf.getPage(0))
|
||||
bg_page.mergePage(page)
|
||||
output.addPage(bg_page)
|
||||
|
||||
|
||||
@@ -273,11 +273,6 @@ class RelativeDateTimeField(forms.MultiValueField):
|
||||
minutes_before=None
|
||||
))
|
||||
|
||||
def has_changed(self, initial, data):
|
||||
if initial is None:
|
||||
initial = self.widget.decompress(initial)
|
||||
return super().has_changed(initial, data)
|
||||
|
||||
def clean(self, value):
|
||||
if value[0] == 'absolute' and not value[1]:
|
||||
raise ValidationError(self.error_messages['incomplete'])
|
||||
|
||||
@@ -214,8 +214,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
refund_amount = o.payment_refund_sum
|
||||
|
||||
try:
|
||||
if auto_refund or manual_refund:
|
||||
_try_auto_refund(o.pk, auto_refund=auto_refund, manual_refund=manual_refund, allow_partial=True,
|
||||
if auto_refund:
|
||||
_try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True,
|
||||
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
|
||||
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
|
||||
comment=gettext('Event canceled'))
|
||||
@@ -272,8 +272,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
ocm.commit()
|
||||
refund_amount = o.payment_refund_sum - o.total
|
||||
|
||||
if auto_refund or manual_refund:
|
||||
_try_auto_refund(o.pk, auto_refund=auto_refund, manual_refund=manual_refund, allow_partial=True,
|
||||
if auto_refund:
|
||||
_try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True,
|
||||
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
|
||||
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
|
||||
comment=gettext('Event canceled'))
|
||||
|
||||
@@ -426,10 +426,10 @@ class CartManager:
|
||||
if not cp.includes_tax:
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
||||
cp_is_net=True, bundled_sum=bundled_sum)
|
||||
price = TaxedPrice(net=price.net, gross=price.net, rate=Decimal('0'), tax=Decimal('0'), name='')
|
||||
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
|
||||
pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent,
|
||||
cp_is_net=True, bundled_sum=bundled_sum)
|
||||
pbv = TaxedPrice(net=pbv.net, gross=pbv.net, rate=Decimal('0'), tax=Decimal('0'), name='')
|
||||
pbv = TaxedPrice(net=pbv.net, gross=pbv.net, rate=0, tax=0, name='')
|
||||
else:
|
||||
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
|
||||
bundled_sum=bundled_sum)
|
||||
@@ -1106,11 +1106,10 @@ def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress
|
||||
rate = pos.item.tax_rule.tax_rate_for(invoice_address)
|
||||
|
||||
if pos.tax_rate != rate:
|
||||
if not pos.item.tax_rule.keep_gross_if_rate_changes:
|
||||
current_net = pos.price - pos.tax_value
|
||||
new_gross = pos.item.tax(current_net, base_price_is='net', invoice_address=invoice_address).gross
|
||||
totaldiff += new_gross - pos.price
|
||||
pos.price = new_gross
|
||||
current_net = pos.price - pos.tax_value
|
||||
new_gross = pos.item.tax(current_net, base_price_is='net', invoice_address=invoice_address).gross
|
||||
totaldiff += new_gross - pos.price
|
||||
pos.price = new_gross
|
||||
pos.includes_tax = rate != Decimal('0.00')
|
||||
pos.override_tax_rate = rate
|
||||
pos.save(update_fields=['price', 'includes_tax', 'override_tax_rate'])
|
||||
|
||||
@@ -373,22 +373,22 @@ class SQLLogic:
|
||||
).astimezone(pytz.UTC))
|
||||
elif values[0] == 'date_from':
|
||||
return Coalesce(
|
||||
F('subevent__date_from'),
|
||||
F('order__event__date_from'),
|
||||
F(f'subevent__date_from'),
|
||||
F(f'order__event__date_from'),
|
||||
)
|
||||
elif values[0] == 'date_to':
|
||||
return Coalesce(
|
||||
F('subevent__date_to'),
|
||||
F('subevent__date_from'),
|
||||
F('order__event__date_to'),
|
||||
F('order__event__date_from'),
|
||||
F(f'subevent__date_to'),
|
||||
F(f'subevent__date_from'),
|
||||
F(f'order__event__date_to'),
|
||||
F(f'order__event__date_from'),
|
||||
)
|
||||
elif values[0] == 'date_admission':
|
||||
return Coalesce(
|
||||
F('subevent__date_admission'),
|
||||
F('subevent__date_from'),
|
||||
F('order__event__date_admission'),
|
||||
F('order__event__date_from'),
|
||||
F(f'subevent__date_admission'),
|
||||
F(f'subevent__date_from'),
|
||||
F(f'order__event__date_admission'),
|
||||
F(f'order__event__date_from'),
|
||||
)
|
||||
else:
|
||||
raise ValueError(f'Unknown time type {values[0]}')
|
||||
|
||||
@@ -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):
|
||||
responses = register_data_exporters.send(event)
|
||||
for receiver, response in responses:
|
||||
if not response:
|
||||
continue
|
||||
ex = response(event, event.organizer, set_progress)
|
||||
if ex.identifier == provider:
|
||||
d = ex.render(form_data)
|
||||
|
||||
@@ -102,7 +102,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
payment = ""
|
||||
if invoice.event.settings.invoice_include_expire_date and invoice.order.status == Order.STATUS_PENDING:
|
||||
if payment:
|
||||
payment += "<br /><br />"
|
||||
payment += "<br />"
|
||||
payment += pgettext("invoice", "Please complete your payment before {expire_date}.").format(
|
||||
expire_date=date_format(invoice.order.expires, "SHORT_DATE_FORMAT")
|
||||
)
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
import hashlib
|
||||
import inspect
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import smtplib
|
||||
@@ -52,7 +51,6 @@ from bs4 import BeautifulSoup
|
||||
from celery import chain
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.mail import (
|
||||
EmailMultiAlternatives, SafeMIMEMultipart, get_connection,
|
||||
)
|
||||
@@ -75,9 +73,8 @@ from pretix.base.services.tasks import TransactionAwareTask
|
||||
from pretix.base.services.tickets import get_tickets_for_order
|
||||
from pretix.base.signals import email_filter, global_email_filter
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.hierarkey import clean_filename
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
from pretix.presale.ical import get_private_icals
|
||||
from pretix.presale.ical import get_ical
|
||||
|
||||
logger = logging.getLogger('pretix.base.mail')
|
||||
INVALID_ADDRESS = 'invalid-pretix-mail-address'
|
||||
@@ -97,7 +94,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None,
|
||||
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None,
|
||||
customer: Customer = None, invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None,
|
||||
attach_ical=False, attach_cached_files: Sequence = None, attach_other_files: list=None):
|
||||
attach_ical=False, attach_cached_files: Sequence = None):
|
||||
"""
|
||||
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
|
||||
|
||||
@@ -145,8 +142,6 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
|
||||
:param attach_cached_files: A list of cached file to attach to this email.
|
||||
|
||||
:param attach_other_files: A list of file paths on our storage to attach.
|
||||
|
||||
:raises MailOrderException: on obvious, immediate failures. Not raising an exception does not necessarily mean
|
||||
that the email has been sent, just that it has been queued by the email backend.
|
||||
"""
|
||||
@@ -217,8 +212,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
for bcc_mail in settings_holder.settings.mail_bcc.split(','):
|
||||
bcc.append(bcc_mail.strip())
|
||||
|
||||
if settings_holder.settings.mail_from in (settings.DEFAULT_FROM_EMAIL, settings.MAIL_FROM_ORGANIZERS) \
|
||||
and settings_holder.settings.contact_mail and not headers.get('Reply-To'):
|
||||
if settings_holder.settings.mail_from == settings.DEFAULT_FROM_EMAIL and settings_holder.settings.contact_mail and not headers.get('Reply-To'):
|
||||
headers['Reply-To'] = settings_holder.settings.contact_mail
|
||||
|
||||
prefix = settings_holder.settings.get('mail_prefix')
|
||||
@@ -307,7 +301,6 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
organizer=organizer.pk if organizer else None,
|
||||
customer=customer.pk if customer else None,
|
||||
attach_cached_files=[(cf.id if isinstance(cf, CachedFile) else cf) for cf in attach_cached_files] if attach_cached_files else [],
|
||||
attach_other_files=attach_other_files,
|
||||
)
|
||||
|
||||
if invoices:
|
||||
@@ -345,8 +338,7 @@ class CustomEmail(EmailMultiAlternatives):
|
||||
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
|
||||
event: int = None, position: int = None, headers: dict = None, bcc: List[str] = None,
|
||||
invoices: List[int] = None, order: int = None, attach_tickets=False, user=None,
|
||||
organizer=None, customer=None, attach_ical=False, attach_cached_files: List[int] = None,
|
||||
attach_other_files: List[str] = None) -> bool:
|
||||
organizer=None, customer=None, attach_ical=False, attach_cached_files: List[int] = None) -> bool:
|
||||
email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers)
|
||||
if html is not None:
|
||||
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
|
||||
@@ -430,7 +422,18 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
}
|
||||
)
|
||||
if attach_ical:
|
||||
for i, cal in enumerate(get_private_icals(event, [position] if position else order.positions.all())):
|
||||
ical_events = set()
|
||||
if event.has_subevents:
|
||||
if position:
|
||||
ical_events.add(position.subevent)
|
||||
else:
|
||||
for p in order.positions.all():
|
||||
ical_events.add(p.subevent)
|
||||
else:
|
||||
ical_events.add(order.event)
|
||||
|
||||
for i, e in enumerate(ical_events):
|
||||
cal = get_ical([e])
|
||||
email.attach('event-{}.ics'.format(i), cal.serialize(), 'text/calendar')
|
||||
|
||||
email = email_filter.send_chained(event, 'message', message=email, order=order, user=user)
|
||||
@@ -452,20 +455,6 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
logger.exception('Could not attach invoice to email')
|
||||
pass
|
||||
|
||||
if attach_other_files:
|
||||
for fname in attach_other_files:
|
||||
ftype, _ = mimetypes.guess_type(fname)
|
||||
data = default_storage.open(fname).read()
|
||||
try:
|
||||
email.attach(
|
||||
clean_filename(os.path.basename(fname)),
|
||||
data,
|
||||
ftype
|
||||
)
|
||||
except:
|
||||
logger.exception('Could not attach file to email')
|
||||
pass
|
||||
|
||||
if attach_cached_files:
|
||||
for cf in CachedFile.objects.filter(id__in=attach_cached_files):
|
||||
if cf.file:
|
||||
@@ -579,7 +568,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
|
||||
}
|
||||
)
|
||||
raise e
|
||||
if log_target:
|
||||
if logger:
|
||||
log_target.log_action(
|
||||
'pretix.email.error',
|
||||
data={
|
||||
|
||||
@@ -148,7 +148,7 @@ def send_notification_mail(notification: Notification, user: User):
|
||||
),
|
||||
'body': body_plain,
|
||||
'html': body_html,
|
||||
'sender': settings.MAIL_FROM_NOTIFICATIONS,
|
||||
'sender': settings.MAIL_FROM,
|
||||
'headers': {},
|
||||
'user': user.pk
|
||||
})
|
||||
|
||||
@@ -700,7 +700,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
invoice_address=address, force_custom_price=True, max_discount=max_discount)
|
||||
changed_prices[cp.pk] = bprice
|
||||
else:
|
||||
bundled_sum = Decimal('0.00')
|
||||
bundled_sum = 0
|
||||
if not cp.addon_to_id:
|
||||
for bundledp in cp.addons.all():
|
||||
if bundledp.is_bundled:
|
||||
@@ -856,7 +856,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
|
||||
total=total,
|
||||
testmode=True if sales_channel.testmode_supported and event.testmode else False,
|
||||
meta_info=json.dumps(meta_info or {}),
|
||||
require_approval=any(p.requires_approval(invoice_address=address) for p in positions),
|
||||
require_approval=any(p.item.require_approval for p in positions),
|
||||
sales_channel=sales_channel.identifier,
|
||||
customer=customer,
|
||||
)
|
||||
@@ -932,7 +932,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,
|
||||
invoice, payment: OrderPayment, is_free=False):
|
||||
invoice, payment: OrderPayment):
|
||||
email_context = get_email_context(event=event, order=order, payment=payment if pprov else None)
|
||||
email_subject = _('Your order: %(code)s') % {'code': order.code}
|
||||
try:
|
||||
@@ -941,29 +941,24 @@ def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider,
|
||||
log_entry,
|
||||
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
|
||||
attach_tickets=True,
|
||||
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 [
|
||||
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
||||
] if a],
|
||||
attach_ical=event.settings.mail_attach_ical
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order received email could not be sent')
|
||||
|
||||
|
||||
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):
|
||||
email_context = get_email_context(event=event, order=order, position=position)
|
||||
email_subject = _('Your event registration: %(code)s') % {'code': order.code}
|
||||
|
||||
try:
|
||||
position.send_mail(
|
||||
order.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
log_entry,
|
||||
invoices=[],
|
||||
attach_tickets=True,
|
||||
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 [
|
||||
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
||||
] if a],
|
||||
position=position,
|
||||
attach_ical=event.settings.mail_attach_ical
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order received email could not be sent to attendee')
|
||||
@@ -1069,13 +1064,11 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
|
||||
email_attendees_template = event.settings.mail_text_order_placed_attendee
|
||||
|
||||
if sales_channel in event.settings.mail_sales_channel_placed_paid:
|
||||
_order_placed_email(event, order, pprov, email_template, log_entry, invoice, payment,
|
||||
is_free=free_order_flow)
|
||||
_order_placed_email(event, order, pprov, email_template, log_entry, invoice, payment)
|
||||
if email_attendees:
|
||||
for p in order.positions.all():
|
||||
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
|
||||
_order_placed_email_attendee(event, order, p, email_attendees_template, log_entry,
|
||||
is_free=free_order_flow)
|
||||
_order_placed_email_attendee(event, order, p, email_attendees_template, log_entry)
|
||||
|
||||
return order.id
|
||||
|
||||
@@ -1527,8 +1520,6 @@ class OrderChangeManager:
|
||||
self._invoice_dirty = True
|
||||
|
||||
self._operations.append(self.SplitOperation(position))
|
||||
for a in position.addons.all():
|
||||
self._operations.append(self.SplitOperation(a))
|
||||
|
||||
def set_addons(self, addons):
|
||||
if self._operations:
|
||||
@@ -1595,22 +1586,21 @@ class OrderChangeManager:
|
||||
|
||||
op = opcache[a['addon_to']]
|
||||
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
|
||||
|
||||
if item.category_id not in available_categories[op.pk]:
|
||||
raise OrderError(error_messages['addon_invalid_base'])
|
||||
|
||||
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
|
||||
quotas = list(item.quotas.filter(subevent=subevent)
|
||||
if variation is None else variation.quotas.filter(subevent=subevent))
|
||||
quotas = list(item.quotas.filter(subevent=op.subevent)
|
||||
if variation is None else variation.quotas.filter(subevent=op.subevent))
|
||||
if not quotas:
|
||||
raise OrderError(error_messages['unavailable'])
|
||||
|
||||
if (a['item'], a['variation']) in input_addons[op.id]:
|
||||
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'])
|
||||
|
||||
if not item.is_available() or (variation and not variation.is_available()):
|
||||
@@ -1620,11 +1610,11 @@ class OrderChangeManager:
|
||||
variation and self.order.sales_channel not in variation.sales_channels):
|
||||
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'])
|
||||
|
||||
if subevent and variation and variation.pk in subevent.var_overrides and \
|
||||
not subevent.var_overrides[variation.pk].is_available():
|
||||
if op.subevent and variation and variation.pk in op.subevent.var_overrides and \
|
||||
not op.subevent.var_overrides[variation.pk].is_available():
|
||||
raise OrderError(error_messages['not_for_sale'])
|
||||
|
||||
if item.has_variations and not variation:
|
||||
@@ -1633,10 +1623,10 @@ class OrderChangeManager:
|
||||
if variation and variation.item_id != item.pk:
|
||||
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'])
|
||||
|
||||
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'])
|
||||
|
||||
if item.require_bundling:
|
||||
@@ -2075,7 +2065,7 @@ class OrderChangeManager:
|
||||
split_order.code = None
|
||||
split_order.datetime = now()
|
||||
split_order.secret = generate_secret()
|
||||
split_order.require_approval = self.order.require_approval and any(p.requires_approval(invoice_address=self._invoice_address) for p in split_positions)
|
||||
split_order.require_approval = self.order.require_approval and any(p.item.require_approval for p in split_positions)
|
||||
split_order.save()
|
||||
split_order.log_action('pretix.event.order.changed.split_from', user=self.user, auth=self.auth, data={
|
||||
'original_order': self.order.code
|
||||
@@ -2385,8 +2375,7 @@ def perform_order(self, event: Event, payment_provider: str, positions: List[str
|
||||
_unset = object()
|
||||
|
||||
|
||||
def _try_auto_refund(order, auto_refund=True, manual_refund=False, allow_partial=False,
|
||||
source=OrderRefund.REFUND_SOURCE_BUYER,
|
||||
def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=OrderRefund.REFUND_SOURCE_BUYER,
|
||||
refund_as_giftcard=False, giftcard_expires=_unset, giftcard_conditions=None, comment=None):
|
||||
notify_admin = False
|
||||
error = False
|
||||
@@ -2396,9 +2385,9 @@ def _try_auto_refund(order, auto_refund=True, manual_refund=False, allow_partial
|
||||
if refund_amount <= Decimal('0.00'):
|
||||
return
|
||||
|
||||
can_auto_refund_sum = 0
|
||||
|
||||
if refund_as_giftcard:
|
||||
proposals = {}
|
||||
can_auto_refund = True
|
||||
can_auto_refund_sum = refund_amount
|
||||
with transaction.atomic():
|
||||
giftcard = order.event.organizer.issued_gift_cards.create(
|
||||
@@ -2438,41 +2427,42 @@ def _try_auto_refund(order, auto_refund=True, manual_refund=False, allow_partial
|
||||
if r.state != OrderRefund.REFUND_STATE_DONE:
|
||||
notify_admin = True
|
||||
|
||||
elif auto_refund:
|
||||
else:
|
||||
proposals = order.propose_auto_refunds(refund_amount)
|
||||
can_auto_refund_sum = sum(proposals.values())
|
||||
if (allow_partial and can_auto_refund_sum) or can_auto_refund_sum == refund_amount:
|
||||
for p, value in proposals.items():
|
||||
can_auto_refund = (allow_partial and can_auto_refund_sum) or can_auto_refund_sum == refund_amount
|
||||
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():
|
||||
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', {
|
||||
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)
|
||||
})
|
||||
|
||||
try:
|
||||
r.payment_provider.execute_refund(r)
|
||||
except PaymentException as e:
|
||||
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
|
||||
error = True
|
||||
notify_admin = True
|
||||
else:
|
||||
if r.state not in (OrderRefund.REFUND_STATE_TRANSIT, OrderRefund.REFUND_STATE_DONE):
|
||||
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 manual_refund:
|
||||
|
||||
@@ -113,8 +113,10 @@ class QuotaAvailability:
|
||||
be a few minutes outdated. In this case, you may not rely on the results in the ``count_*`` properties.
|
||||
"""
|
||||
now_dt = now_dt or now()
|
||||
quota_ids_set = {q.id for q in self._queue}
|
||||
if not quota_ids_set:
|
||||
quotas = list(set(self._queue))
|
||||
quotas_original = list(self._queue)
|
||||
self._queue.clear()
|
||||
if not quotas:
|
||||
return
|
||||
|
||||
if allow_cache:
|
||||
@@ -127,7 +129,7 @@ class QuotaAvailability:
|
||||
elif settings.HAS_REDIS:
|
||||
rc = get_redis_connection("redis")
|
||||
quotas_by_event = defaultdict(list)
|
||||
for q in [_q for _q in self._queue if _q.id in quota_ids_set]:
|
||||
for q in quotas_original:
|
||||
quotas_by_event[q.event_id].append(q)
|
||||
|
||||
for eventid, evquotas in quotas_by_event.items():
|
||||
@@ -137,19 +139,16 @@ class QuotaAvailability:
|
||||
data = [rv for rv in redisval.decode().split(',')]
|
||||
# Except for some rare situations, we don't want to use cache entries older than 2 minutes
|
||||
if time.time() - int(data[2]) < 120 or allow_cache_stale:
|
||||
quota_ids_set.remove(q.id)
|
||||
quotas_original.remove(q)
|
||||
quotas.remove(q)
|
||||
if data[1] == "None":
|
||||
self.results[q] = int(data[0]), None
|
||||
else:
|
||||
self.results[q] = int(data[0]), int(data[1])
|
||||
|
||||
if not quota_ids_set:
|
||||
if not quotas:
|
||||
return
|
||||
|
||||
quotas = [_q for _q in self._queue if _q.id in quota_ids_set]
|
||||
quotas_original = list(quotas)
|
||||
self._queue.clear()
|
||||
|
||||
self._compute(quotas, now_dt)
|
||||
|
||||
for q in quotas_original:
|
||||
@@ -285,16 +284,15 @@ class QuotaAvailability:
|
||||
seq = Q(subevent_id__in=subevents)
|
||||
if None in subevents:
|
||||
seq |= Q(subevent__isnull=True)
|
||||
quota_ids = {q.pk for q in quotas}
|
||||
op_lookup = OrderPosition.objects.filter(
|
||||
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING],
|
||||
order__event_id__in=events,
|
||||
).filter(seq).filter(
|
||||
Q(
|
||||
Q(variation_id__isnull=True) &
|
||||
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
|
||||
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
|
||||
) | Q(
|
||||
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids})
|
||||
variation_id__in={i['itemvariation_id'] for i in q_vars if self._quota_objects[i['quota_id']] in quotas})
|
||||
).order_by()
|
||||
if any(q.release_after_exit for q in quotas):
|
||||
op_lookup = op_lookup.annotate(
|
||||
@@ -361,7 +359,6 @@ class QuotaAvailability:
|
||||
func = 'GREATEST'
|
||||
|
||||
subevents = {q.subevent_id for q in quotas}
|
||||
quota_ids = {q.pk for q in quotas}
|
||||
seq = Q(subevent_id__in=subevents)
|
||||
if None in subevents:
|
||||
seq |= Q(subevent__isnull=True)
|
||||
@@ -373,9 +370,10 @@ class QuotaAvailability:
|
||||
Q(
|
||||
Q(
|
||||
Q(variation_id__isnull=True) &
|
||||
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
|
||||
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
|
||||
) | Q(
|
||||
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids}
|
||||
variation_id__in={i['itemvariation_id'] for i in q_vars if
|
||||
self._quota_objects[i['quota_id']] in quotas}
|
||||
) | Q(
|
||||
quota_id__in=[q.pk for q in quotas]
|
||||
)
|
||||
@@ -400,7 +398,6 @@ class QuotaAvailability:
|
||||
def _compute_carts(self, quotas, q_items, q_vars, size_left, now_dt):
|
||||
events = {q.event_id for q in quotas}
|
||||
subevents = {q.subevent_id for q in quotas}
|
||||
quota_ids = {q.pk for q in quotas}
|
||||
seq = Q(subevent_id__in=subevents)
|
||||
if None in subevents:
|
||||
seq |= Q(subevent__isnull=True)
|
||||
@@ -416,9 +413,9 @@ class QuotaAvailability:
|
||||
Q(
|
||||
Q(
|
||||
Q(variation_id__isnull=True) &
|
||||
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
|
||||
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
|
||||
) | Q(
|
||||
variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids}
|
||||
variation_id__in={i['itemvariation_id'] for i in q_vars if self._quota_objects[i['quota_id']] in quotas}
|
||||
)
|
||||
)
|
||||
).order_by().values('item_id', 'subevent_id', 'variation_id').annotate(c=Count('*'))
|
||||
@@ -437,7 +434,6 @@ class QuotaAvailability:
|
||||
def _compute_waitinglist(self, quotas, q_items, q_vars, size_left):
|
||||
events = {q.event_id for q in quotas}
|
||||
subevents = {q.subevent_id for q in quotas}
|
||||
quota_ids = {q.pk for q in quotas}
|
||||
seq = Q(subevent_id__in=subevents)
|
||||
if None in subevents:
|
||||
seq |= Q(subevent__isnull=True)
|
||||
@@ -448,8 +444,9 @@ class QuotaAvailability:
|
||||
Q(
|
||||
Q(
|
||||
Q(variation_id__isnull=True) &
|
||||
Q(item_id__in={i['item_id'] for i in q_items if i['quota_id'] in quota_ids})
|
||||
) | Q(variation_id__in={i['itemvariation_id'] for i in q_vars if i['quota_id'] in quota_ids})
|
||||
Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas})
|
||||
) | Q(variation_id__in={i['itemvariation_id'] for i in q_vars if
|
||||
self._quota_objects[i['quota_id']] in quotas})
|
||||
)
|
||||
).order_by().values('item_id', 'subevent_id', 'variation_id').annotate(c=Count('*'))
|
||||
for line in w_lookup:
|
||||
|
||||
@@ -45,7 +45,7 @@ def validate_plan_change(event, subevent, plan):
|
||||
seat=OuterRef('pk'),
|
||||
canceled=False,
|
||||
).exclude(
|
||||
order__status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)
|
||||
order__status=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)
|
||||
))
|
||||
).annotate(has_v=Count('vouchers')).filter(
|
||||
subevent=subevent,
|
||||
@@ -69,7 +69,7 @@ def generate_seats(event, subevent, plan, mapping, blocked_guids=None):
|
||||
seat=OuterRef('pk'),
|
||||
canceled=False,
|
||||
).exclude(
|
||||
order__status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)
|
||||
order__status=Order.STATUS_CANCELED
|
||||
)),
|
||||
has_v=Count('vouchers')
|
||||
).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)
|
||||
CartPosition.objects.filter(seat__in=[s.pk for s in current_seats.values()]).delete()
|
||||
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()],
|
||||
).update(seat=None)
|
||||
Seat.objects.filter(pk__in=[s.pk for s in current_seats.values()]).delete()
|
||||
|
||||
@@ -94,24 +94,6 @@ def primary_font_kwargs():
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
'widget': forms.CheckboxSelectMultiple,
|
||||
'label': _("Allow usage of restricted plugins"),
|
||||
'choices': plugins_available,
|
||||
}
|
||||
|
||||
|
||||
class LazyI18nStringList(UserList):
|
||||
def __init__(self, init_list=None):
|
||||
super().__init__()
|
||||
@@ -127,13 +109,6 @@ class LazyI18nStringList(UserList):
|
||||
|
||||
|
||||
DEFAULTS = {
|
||||
'allowed_restricted_plugins': {
|
||||
'default': [],
|
||||
'type': list,
|
||||
'form_class': forms.MultipleChoiceField,
|
||||
'serializer_class': serializers.MultipleChoiceField,
|
||||
'form_kwargs': lambda: restricted_plugin_kwargs(),
|
||||
},
|
||||
'customer_accounts': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
@@ -161,15 +136,11 @@ DEFAULTS = {
|
||||
'type': int,
|
||||
'form_class': forms.IntegerField,
|
||||
'serializer_class': serializers.IntegerField,
|
||||
'serializer_kwargs': dict(
|
||||
min_value=1,
|
||||
),
|
||||
'form_kwargs': dict(
|
||||
min_value=1,
|
||||
required=True,
|
||||
label=_("Maximum number of items per order"),
|
||||
help_text=_("Add-on products will not be counted.")
|
||||
),
|
||||
)
|
||||
},
|
||||
'display_net_prices': {
|
||||
'default': 'False',
|
||||
@@ -397,12 +368,11 @@ DEFAULTS = {
|
||||
'form_class': I18nFormField,
|
||||
'serializer_class': I18nField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Custom recipient field"),
|
||||
label=_("Custom address field"),
|
||||
widget=I18nTextInput,
|
||||
help_text=_("If you want to add a custom text field, e.g. for a country-specific registration number, to "
|
||||
"your invoice address form, please fill in the label here. This label will both be used for "
|
||||
"asking the user to input their details as well as for displaying the value on the invoice. It will "
|
||||
"be shown on the invoice below the headline. "
|
||||
"asking the user to input their details as well as for displaying the value on the invoice. "
|
||||
"The field will not be required.")
|
||||
)
|
||||
},
|
||||
@@ -470,11 +440,9 @@ DEFAULTS = {
|
||||
'type': int,
|
||||
'form_class': forms.IntegerField,
|
||||
'serializer_class': serializers.IntegerField,
|
||||
'serializer_kwargs': dict(),
|
||||
'form_kwargs': dict(
|
||||
label=_("Minimum length of invoice number after prefix"),
|
||||
help_text=_("The part of your invoice number after your prefix will be filled up with leading zeros up to this length, e.g. INV-001 or INV-00001."),
|
||||
required=True,
|
||||
)
|
||||
},
|
||||
'invoice_numbers_consecutive': {
|
||||
@@ -538,7 +506,6 @@ DEFAULTS = {
|
||||
MinValueValidator(12),
|
||||
MaxValueValidator(64),
|
||||
],
|
||||
required=True,
|
||||
widget=forms.NumberInput(
|
||||
attrs={
|
||||
'min': '12',
|
||||
@@ -553,13 +520,9 @@ DEFAULTS = {
|
||||
'type': int,
|
||||
'form_class': forms.IntegerField,
|
||||
'serializer_class': serializers.IntegerField,
|
||||
'serializer_kwargs': dict(
|
||||
min_value=0,
|
||||
),
|
||||
'form_kwargs': dict(
|
||||
min_value=0,
|
||||
label=_("Reservation period"),
|
||||
required=True,
|
||||
help_text=_("The number of minutes the items in a user's cart are reserved for this user."),
|
||||
)
|
||||
},
|
||||
@@ -614,7 +577,6 @@ DEFAULTS = {
|
||||
'form_kwargs': dict(
|
||||
label=_("Set payment term"),
|
||||
widget=forms.RadioSelect,
|
||||
required=True,
|
||||
choices=(
|
||||
('days', _("in days")),
|
||||
('minutes', _("in minutes"))
|
||||
@@ -1129,13 +1091,9 @@ DEFAULTS = {
|
||||
'type': int,
|
||||
'serializer_class': serializers.IntegerField,
|
||||
'form_class': forms.IntegerField,
|
||||
'serializer_kwargs': dict(
|
||||
min_value=1,
|
||||
),
|
||||
'form_kwargs': dict(
|
||||
label=_("Waiting list response time"),
|
||||
min_value=1,
|
||||
required=True,
|
||||
help_text=_("If a ticket voucher is sent to a person on the waiting list, it has to be redeemed within this "
|
||||
"number of hours until it expires and can be re-assigned to the next person on the list."),
|
||||
widget=forms.NumberInput(),
|
||||
@@ -1598,32 +1556,6 @@ DEFAULTS = {
|
||||
help_text=_("If enabled, we will attach an .ics calendar file to order confirmation emails."),
|
||||
)
|
||||
},
|
||||
'mail_attach_ical_paid_only': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Attach calendar files only after order has been paid"),
|
||||
help_text=_("Use this if you e.g. put a private access link into the calendar file to make sure people only "
|
||||
"receive it after their payment was confirmed."),
|
||||
)
|
||||
},
|
||||
'mail_attach_ical_description': {
|
||||
'default': '',
|
||||
'type': LazyI18nString,
|
||||
'form_class': I18nFormField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Event description"),
|
||||
widget=I18nTextarea,
|
||||
help_text=_(
|
||||
"You can use this to share information with your attendees, such as travel information or the link to a digital event. "
|
||||
"If you keep it empty, we will put a link to the event shop, the admission time, and your organizer name in there. "
|
||||
"We do not allow using placeholders with sensitive person-specific data as calendar entries are often shared with an "
|
||||
"unspecified number of people."
|
||||
),
|
||||
)
|
||||
},
|
||||
'mail_prefix': {
|
||||
'default': None,
|
||||
'type': str,
|
||||
@@ -1640,7 +1572,7 @@ DEFAULTS = {
|
||||
'type': str
|
||||
},
|
||||
'mail_from': {
|
||||
'default': settings.MAIL_FROM_ORGANIZERS,
|
||||
'default': settings.MAIL_FROM,
|
||||
'type': str,
|
||||
'form_class': forms.EmailField,
|
||||
'serializer_class': serializers.EmailField,
|
||||
@@ -1755,30 +1687,6 @@ You can change your order details and view the status of your order at
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_attachment_new_order': {
|
||||
'default': None,
|
||||
'type': File,
|
||||
'form_class': ExtFileField,
|
||||
'form_kwargs': dict(
|
||||
label=_('Attachment for new orders'),
|
||||
ext_whitelist=(".pdf",),
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT,
|
||||
help_text=_('This file will be attached to the first email that we send for every new order. Therefore it will be '
|
||||
'combined with the "Placed order", "Free order", or "Received order" texts from above. It will be sent '
|
||||
'to both order contacts and attendees. You can use this e.g. to send your terms of service. Do not use '
|
||||
'it to send non-public information as this file might be sent before payment is confirmed or the order '
|
||||
'is approved. To avoid this vital email going to spam, you can only upload PDF files of up to {size} MB.').format(
|
||||
size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT // (1024 * 1024),
|
||||
)
|
||||
),
|
||||
'serializer_class': UploadedFileField,
|
||||
'serializer_kwargs': dict(
|
||||
allowed_types=[
|
||||
'application/pdf'
|
||||
],
|
||||
max_size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT,
|
||||
)
|
||||
},
|
||||
'mail_send_order_placed_attendee': {
|
||||
'type': bool,
|
||||
'default': 'False'
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from typing import List, Tuple
|
||||
|
||||
from django.db import transaction
|
||||
@@ -69,11 +70,11 @@ def shred_constraints(event: Event):
|
||||
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']
|
||||
if max_date is not None and max_date >= now():
|
||||
return _('Your event needs to be over to use this feature.')
|
||||
if max_date is not None and max_date > now() - timedelta(days=30):
|
||||
return _('Your event needs to be over for at least 30 days to use this feature.')
|
||||
else:
|
||||
if (event.date_to or event.date_from) >= now():
|
||||
return _('Your event needs to be over to use this feature.')
|
||||
if (event.date_to or event.date_from) > now() - timedelta(days=30):
|
||||
return _('Your event needs to be over for at least 30 days to use this feature.')
|
||||
if event.live:
|
||||
return _('Your ticket shop needs to be offline to use this feature.')
|
||||
return None
|
||||
|
||||
@@ -90,16 +90,13 @@
|
||||
{% for groupkey, positions in cart %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if not groupkey.4 %} {# is not addon #}
|
||||
{% if not groupkey.4 %} {# is addon #}
|
||||
{{ positions|length }}x
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if groupkey.4 %} {# is addon #}
|
||||
+
|
||||
{% if positions|length > 1 %}
|
||||
{{ positions|length }}x
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{{ groupkey.0.name }}{% if groupkey.1 %} – {{ groupkey.1.value }}{% endif %}
|
||||
{% if groupkey.2 %} {# subevent #}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
{% extends "error.html" %}
|
||||
{% load i18n %}
|
||||
{% load rich_text %}
|
||||
{% load static %}
|
||||
{% block title %}{% trans "Redirect" %}{% endblock %}
|
||||
{% block content %}
|
||||
<i class="fa fa-link fa-fw big-icon"></i>
|
||||
<div class="error-details">
|
||||
<h1>{% trans "Redirect" %}</h1>
|
||||
<h3>
|
||||
{% blocktrans trimmed with host="<strong>"|add:hostname|add:"</strong>"|safe %}
|
||||
The link you clicked on wants to redirect you to a destination on the website {{ host }}.
|
||||
{% endblocktrans %}
|
||||
{% blocktrans trimmed %}
|
||||
Please only proceed if you trust this website to be safe.
|
||||
{% endblocktrans %}
|
||||
</h3>
|
||||
<p>
|
||||
<a href="{{ url }}" class="btn btn-primary btn-lg">
|
||||
{% blocktrans trimmed with host=hostname %}
|
||||
Proceed to {{ host }}
|
||||
{% endblocktrans %}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -30,85 +30,67 @@ from django.utils.translation import gettext as _
|
||||
from django.views.decorators.csrf import requires_csrf_token
|
||||
from sentry_sdk import last_event_id
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.middleware import get_language_from_request
|
||||
|
||||
|
||||
def csrf_failure(request, reason=""):
|
||||
try:
|
||||
locale = get_language_from_request(request)
|
||||
except:
|
||||
locale = "en"
|
||||
with language(locale): # Middleware might not have run, need to do this manually
|
||||
t = get_template('csrffail.html')
|
||||
c = {
|
||||
'reason': reason,
|
||||
'no_referer': reason == REASON_NO_REFERER,
|
||||
'no_referer1': _(
|
||||
"You are seeing this message because this HTTPS site requires a "
|
||||
"'Referer header' to be sent by your Web browser, but none was "
|
||||
"sent. This header is required for security reasons, to ensure "
|
||||
"that your browser is not being hijacked by third parties."),
|
||||
'no_referer2': _(
|
||||
"If you have configured your browser to disable 'Referer' headers, "
|
||||
"please re-enable them, at least for this site, or for HTTPS "
|
||||
"connections, or for 'same-origin' requests."),
|
||||
'no_cookie': reason == REASON_NO_CSRF_COOKIE,
|
||||
'no_cookie1': _(
|
||||
"You are seeing this message because this site requires a CSRF "
|
||||
"cookie when submitting forms. This cookie is required for "
|
||||
"security reasons, to ensure that your browser is not being "
|
||||
"hijacked by third parties."),
|
||||
'no_cookie2': _(
|
||||
"If you have configured your browser to disable cookies, please "
|
||||
"re-enable them, at least for this site, or for 'same-origin' "
|
||||
"requests."),
|
||||
}
|
||||
return HttpResponseForbidden(t.render(c), content_type='text/html')
|
||||
t = get_template('csrffail.html')
|
||||
c = {
|
||||
'reason': reason,
|
||||
'no_referer': reason == REASON_NO_REFERER,
|
||||
'no_referer1': _(
|
||||
"You are seeing this message because this HTTPS site requires a "
|
||||
"'Referer header' to be sent by your Web browser, but none was "
|
||||
"sent. This header is required for security reasons, to ensure "
|
||||
"that your browser is not being hijacked by third parties."),
|
||||
'no_referer2': _(
|
||||
"If you have configured your browser to disable 'Referer' headers, "
|
||||
"please re-enable them, at least for this site, or for HTTPS "
|
||||
"connections, or for 'same-origin' requests."),
|
||||
'no_cookie': reason == REASON_NO_CSRF_COOKIE,
|
||||
'no_cookie1': _(
|
||||
"You are seeing this message because this site requires a CSRF "
|
||||
"cookie when submitting forms. This cookie is required for "
|
||||
"security reasons, to ensure that your browser is not being "
|
||||
"hijacked by third parties."),
|
||||
'no_cookie2': _(
|
||||
"If you have configured your browser to disable cookies, please "
|
||||
"re-enable them, at least for this site, or for 'same-origin' "
|
||||
"requests."),
|
||||
}
|
||||
return HttpResponseForbidden(t.render(c), content_type='text/html')
|
||||
|
||||
|
||||
@requires_csrf_token
|
||||
def page_not_found(request, exception):
|
||||
exception_repr = exception.__class__.__name__
|
||||
# Try to get an "interesting" exception message, if any (and not the ugly
|
||||
# Resolver404 dictionary)
|
||||
try:
|
||||
locale = get_language_from_request(request)
|
||||
except:
|
||||
locale = "en"
|
||||
with language(locale): # Middleware might not have run, need to do this manually
|
||||
exception_repr = exception.__class__.__name__
|
||||
# Try to get an "interesting" exception message, if any (and not the ugly
|
||||
# Resolver404 dictionary)
|
||||
try:
|
||||
message = exception.args[0]
|
||||
except (AttributeError, IndexError):
|
||||
pass
|
||||
else:
|
||||
if isinstance(message, (str, Promise)):
|
||||
exception_repr = str(message)
|
||||
context = {
|
||||
'request_path': request.path,
|
||||
'exception': exception_repr,
|
||||
}
|
||||
template = get_template('404.html')
|
||||
body = template.render(context, request)
|
||||
r = HttpResponseNotFound(body)
|
||||
r.xframe_options_exempt = True
|
||||
return r
|
||||
message = exception.args[0]
|
||||
except (AttributeError, IndexError):
|
||||
pass
|
||||
else:
|
||||
if isinstance(message, (str, Promise)):
|
||||
exception_repr = str(message)
|
||||
context = {
|
||||
'request_path': request.path,
|
||||
'exception': exception_repr,
|
||||
}
|
||||
template = get_template('404.html')
|
||||
body = template.render(context, request)
|
||||
r = HttpResponseNotFound(body)
|
||||
r.xframe_options_exempt = True
|
||||
return r
|
||||
|
||||
|
||||
@requires_csrf_token
|
||||
def server_error(request):
|
||||
try:
|
||||
locale = get_language_from_request(request)
|
||||
except:
|
||||
locale = "en"
|
||||
with language(locale): # Middleware might not have run, need to do this manually
|
||||
try:
|
||||
template = loader.get_template('500.html')
|
||||
except TemplateDoesNotExist:
|
||||
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
|
||||
r = HttpResponseServerError(template.render({
|
||||
'request': request,
|
||||
'sentry_event_id': last_event_id(),
|
||||
}))
|
||||
r.xframe_options_exempt = True
|
||||
return r
|
||||
template = loader.get_template('500.html')
|
||||
except TemplateDoesNotExist:
|
||||
return HttpResponseServerError('<h1>Server Error (500)</h1>', content_type='text/html')
|
||||
r = HttpResponseServerError(template.render({
|
||||
'request': request,
|
||||
'sentry_event_id': last_event_id(),
|
||||
}))
|
||||
r.xframe_options_exempt = True
|
||||
return r
|
||||
|
||||
@@ -58,7 +58,7 @@ class BaseQuestionsViewMixin:
|
||||
def _positions_for_questions(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_question_override_sets(self, position, index):
|
||||
def get_question_override_sets(self, position):
|
||||
return []
|
||||
|
||||
def question_form_kwargs(self, cr):
|
||||
@@ -72,7 +72,7 @@ class BaseQuestionsViewMixin:
|
||||
submitted at once.
|
||||
"""
|
||||
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
|
||||
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 question_name, question_field in form.fields.items():
|
||||
if hasattr(question_field, 'question'):
|
||||
|
||||
@@ -23,38 +23,15 @@ import urllib.parse
|
||||
|
||||
from django.core import signing
|
||||
from django.http import HttpResponseBadRequest, HttpResponseRedirect
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
def _is_samesite_referer(request):
|
||||
referer = request.META.get('HTTP_REFERER')
|
||||
if referer is None:
|
||||
return False
|
||||
|
||||
referer = urllib.parse.urlparse(referer)
|
||||
|
||||
# Make sure we have a valid URL for Referer.
|
||||
if '' in (referer.scheme, referer.netloc):
|
||||
return False
|
||||
|
||||
return (referer.scheme, referer.netloc) == (request.scheme, request.get_host())
|
||||
|
||||
|
||||
def redir_view(request):
|
||||
signer = signing.Signer(salt='safe-redirect')
|
||||
try:
|
||||
url = signer.unsign(request.GET.get('url', ''))
|
||||
except signing.BadSignature:
|
||||
return HttpResponseBadRequest('Invalid parameter')
|
||||
|
||||
if not _is_samesite_referer(request):
|
||||
u = urllib.parse.urlparse(url)
|
||||
return render(request, 'pretixbase/redirect.html', {
|
||||
'hostname': u.hostname,
|
||||
'url': url,
|
||||
})
|
||||
|
||||
r = HttpResponseRedirect(url)
|
||||
r['X-Robots-Tag'] = 'noindex'
|
||||
return r
|
||||
|
||||
@@ -205,8 +205,6 @@ class AsyncFormView(AsyncMixin, FormView):
|
||||
Also, all form keyword arguments except ``instance`` need to be serializable.
|
||||
"""
|
||||
known_errortypes = ['ValidationError']
|
||||
expected_exceptions = (ValidationError,)
|
||||
task_base = ProfiledEventTask
|
||||
|
||||
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):
|
||||
@@ -224,7 +222,7 @@ class AsyncFormView(AsyncMixin, FormView):
|
||||
elif organizer:
|
||||
view_instance.request.organizer = organizer
|
||||
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:
|
||||
engine = import_module(settings.SESSION_ENGINE)
|
||||
self.SessionStore = engine.SessionStore
|
||||
@@ -233,7 +231,7 @@ class AsyncFormView(AsyncMixin, FormView):
|
||||
with translation.override(locale), timezone.override(pytz.timezone(tz)):
|
||||
form_class = view_instance.get_form_class()
|
||||
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 = form_class(**form_kwargs)
|
||||
@@ -241,10 +239,10 @@ class AsyncFormView(AsyncMixin, FormView):
|
||||
return view_instance.async_form_valid(self, form)
|
||||
|
||||
cls.async_execute = app.task(
|
||||
base=cls.task_base,
|
||||
base=ProfiledEventTask,
|
||||
bind=True,
|
||||
name=cls.__module__ + '.' + cls.__name__ + '.async_execute',
|
||||
throws=cls.expected_exceptions
|
||||
throws=(ValidationError,)
|
||||
)(async_execute)
|
||||
|
||||
def async_form_valid(self, task, form):
|
||||
|
||||
@@ -48,7 +48,7 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes.forms import SafeModelMultipleChoiceField
|
||||
|
||||
from ...base.forms import I18nModelForm
|
||||
from ...base.forms import I18nModelForm, SecretKeySettingsField
|
||||
|
||||
# Import for backwards compatibility with okd import paths
|
||||
from ...base.forms.widgets import ( # noqa
|
||||
@@ -373,6 +373,49 @@ class FontSelect(forms.RadioSelect):
|
||||
option_template_name = 'pretixcontrol/font_option.html'
|
||||
|
||||
|
||||
class SMTPSettingsMixin(forms.Form):
|
||||
smtp_use_custom = forms.BooleanField(
|
||||
label=_("Use custom SMTP server"),
|
||||
help_text=_("All mail related to your event will be sent over the smtp server specified by you."),
|
||||
required=False
|
||||
)
|
||||
smtp_host = forms.CharField(
|
||||
label=_("Hostname"),
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': 'mail.example.org'})
|
||||
)
|
||||
smtp_port = forms.IntegerField(
|
||||
label=_("Port"),
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': 'e.g. 587, 465, 25, ...'})
|
||||
)
|
||||
smtp_username = forms.CharField(
|
||||
label=_("Username"),
|
||||
widget=forms.TextInput(attrs={'placeholder': 'myuser@example.org'}),
|
||||
required=False
|
||||
)
|
||||
smtp_password = SecretKeySettingsField(
|
||||
label=_("Password"),
|
||||
required=False,
|
||||
)
|
||||
smtp_use_tls = forms.BooleanField(
|
||||
label=_("Use STARTTLS"),
|
||||
help_text=_("Commonly enabled on port 587."),
|
||||
required=False
|
||||
)
|
||||
smtp_use_ssl = forms.BooleanField(
|
||||
label=_("Use SSL"),
|
||||
help_text=_("Commonly enabled on port 465."),
|
||||
required=False
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
if data.get('smtp_use_tls') and data.get('smtp_use_ssl'):
|
||||
raise ValidationError(_('You can activate either SSL or STARTTLS security, but not both at the same time.'))
|
||||
return data
|
||||
|
||||
|
||||
class ItemMultipleChoiceField(SafeModelMultipleChoiceField):
|
||||
def label_from_instance(self, obj):
|
||||
return str(obj) if obj.active else mark_safe(f'<strike class="text-muted">{escape(obj)}</strike>')
|
||||
|
||||
@@ -43,7 +43,6 @@ from django.core.validators import validate_email
|
||||
from django.db.models import Prefetch, Q, prefetch_related_objects
|
||||
from django.forms import CheckboxSelectMultiple, formset_factory
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.timezone import get_current_timezone_name
|
||||
@@ -64,7 +63,7 @@ from pretix.base.settings import (
|
||||
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings,
|
||||
)
|
||||
from pretix.control.forms import (
|
||||
MultipleLanguagesWidget, SlugWidget, SplitDateTimeField,
|
||||
MultipleLanguagesWidget, SlugWidget, SMTPSettingsMixin, SplitDateTimeField,
|
||||
SplitDateTimePickerWidget,
|
||||
)
|
||||
from pretix.control.forms.widgets import Select2
|
||||
@@ -535,39 +534,34 @@ class EventSettingsForm(SettingsForm):
|
||||
'og_image',
|
||||
]
|
||||
|
||||
def _resolve_virtual_keys_input(self, data, prefix=''):
|
||||
# set all dependants of virtual_keys and
|
||||
# delete all virtual_fields to prevent them from being saved
|
||||
for virtual_key in self.virtual_keys:
|
||||
if prefix + virtual_key not in data:
|
||||
continue
|
||||
base_key = prefix + virtual_key.rsplit('_', 2)[0]
|
||||
asked_key = base_key + '_asked'
|
||||
required_key = base_key + '_required'
|
||||
|
||||
if data[prefix + virtual_key] == 'optional':
|
||||
data[asked_key] = True
|
||||
data[required_key] = False
|
||||
elif data[prefix + virtual_key] == 'required':
|
||||
data[asked_key] = True
|
||||
data[required_key] = True
|
||||
# Explicitly check for 'do_not_ask'.
|
||||
# Do not overwrite as default-behaviour when no value for virtual field is transmitted!
|
||||
elif data[prefix + virtual_key] == 'do_not_ask':
|
||||
data[asked_key] = False
|
||||
data[required_key] = False
|
||||
|
||||
# hierarkey.forms cannot handle non-existent keys in cleaned_data => do not delete, but set to None
|
||||
if not prefix:
|
||||
data[virtual_key] = None
|
||||
return data
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
settings_dict = self.event.settings.freeze()
|
||||
settings_dict.update(data)
|
||||
|
||||
data = self._resolve_virtual_keys_input(data)
|
||||
# set all dependants of virtual_keys and
|
||||
# delete all virtual_fields to prevent them from being saved
|
||||
for virtual_key in self.virtual_keys:
|
||||
if virtual_key not in data:
|
||||
continue
|
||||
base_key = virtual_key.rsplit('_', 2)[0]
|
||||
asked_key = base_key + '_asked'
|
||||
required_key = base_key + '_required'
|
||||
|
||||
if data[virtual_key] == 'optional':
|
||||
data[asked_key] = True
|
||||
data[required_key] = False
|
||||
elif data[virtual_key] == 'required':
|
||||
data[asked_key] = True
|
||||
data[required_key] = True
|
||||
# Explicitly check for 'do_not_ask'.
|
||||
# Do not overwrite as default-behaviour when no value for virtual field is transmitted!
|
||||
elif data[virtual_key] == 'do_not_ask':
|
||||
data[asked_key] = False
|
||||
data[required_key] = False
|
||||
|
||||
# hierarkey.forms cannot handle non-existent keys in cleaned_data => do not delete, but set to None
|
||||
data[virtual_key] = None
|
||||
|
||||
validate_event_settings(self.event, data)
|
||||
return data
|
||||
@@ -627,35 +621,6 @@ class EventSettingsForm(SettingsForm):
|
||||
else:
|
||||
self.initial[virtual_key] = 'do_not_ask'
|
||||
|
||||
@cached_property
|
||||
def changed_data(self):
|
||||
data = []
|
||||
|
||||
# We need to resolve the mapping between our "virtual" fields and the "real"fields here, otherwise
|
||||
# they are detected as "changed" on every save even though they aren't.
|
||||
in_data = self._resolve_virtual_keys_input(self.data.copy(), prefix=f'{self.prefix}-' if self.prefix else '')
|
||||
|
||||
for name, field in self.fields.items():
|
||||
prefixed_name = self.add_prefix(name)
|
||||
data_value = field.widget.value_from_datadict(in_data, self.files, prefixed_name)
|
||||
if not field.show_hidden_initial:
|
||||
# Use the BoundField's initial as this is the value passed to
|
||||
# the widget.
|
||||
initial_value = self[name].initial
|
||||
else:
|
||||
initial_prefixed_name = self.add_initial_prefix(name)
|
||||
hidden_widget = field.hidden_widget()
|
||||
try:
|
||||
initial_value = field.to_python(hidden_widget.value_from_datadict(
|
||||
self.data, self.files, initial_prefixed_name))
|
||||
except ValidationError:
|
||||
# Always assume data has changed if validation fails.
|
||||
data.append(name)
|
||||
continue
|
||||
if field.has_changed(initial_value, data_value):
|
||||
data.append(name)
|
||||
return data
|
||||
|
||||
|
||||
class CancelSettingsForm(SettingsForm):
|
||||
auto_fields = [
|
||||
@@ -865,15 +830,13 @@ def contains_web_channel_validate(val):
|
||||
raise ValidationError(_("The online shop must be selected to receive these emails."))
|
||||
|
||||
|
||||
class MailSettingsForm(SettingsForm):
|
||||
class MailSettingsForm(SMTPSettingsMixin, SettingsForm):
|
||||
auto_fields = [
|
||||
'mail_prefix',
|
||||
'mail_from',
|
||||
'mail_from_name',
|
||||
'mail_attach_ical',
|
||||
'mail_attach_tickets',
|
||||
'mail_attachment_new_order',
|
||||
'mail_attach_ical_paid_only',
|
||||
'mail_attach_ical_description',
|
||||
]
|
||||
|
||||
mail_sales_channel_placed_paid = forms.MultipleChoiceField(
|
||||
@@ -1081,8 +1044,7 @@ class MailSettingsForm(SettingsForm):
|
||||
'mail_text_download_reminder_attendee': ['event', 'order', 'position'],
|
||||
'mail_text_resend_link': ['event', 'order'],
|
||||
'mail_text_waiting_list': ['event', 'waiting_list_entry'],
|
||||
'mail_text_resend_all_links': ['event', 'orders'],
|
||||
'mail_attach_ical_description': ['event', 'event_or_subevent'],
|
||||
'mail_text_resend_all_links': ['event', 'orders']
|
||||
}
|
||||
|
||||
def _set_field_placeholders(self, fn, base_parameters):
|
||||
@@ -1217,7 +1179,6 @@ class TaxRuleLineForm(I18nForm):
|
||||
('reverse', _('Reverse charge')),
|
||||
('no', _('No VAT')),
|
||||
('block', _('Sale not allowed')),
|
||||
('require_approval', _('Order requires approval')),
|
||||
],
|
||||
)
|
||||
rate = forms.DecimalField(
|
||||
@@ -1251,7 +1212,7 @@ TaxRuleLineFormSet = formset_factory(
|
||||
class TaxRuleForm(I18nModelForm):
|
||||
class Meta:
|
||||
model = TaxRule
|
||||
fields = ['name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', 'keep_gross_if_rate_changes']
|
||||
fields = ['name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country']
|
||||
|
||||
|
||||
class WidgetCodeForm(forms.Form):
|
||||
|
||||
@@ -255,6 +255,7 @@ class OrderFilterForm(FilterForm):
|
||||
| Q(pk__in=matching_invoices)
|
||||
| Q(pk__in=matching_positions)
|
||||
| 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):
|
||||
mainq = mainq | q
|
||||
@@ -1880,7 +1881,7 @@ class VoucherFilterForm(FilterForm):
|
||||
if s == '<>':
|
||||
qs = qs.filter(Q(tag__isnull=True) | Q(tag=''))
|
||||
elif s[0] == '"' and s[-1] == '"':
|
||||
qs = qs.filter(tag__exact=s[1:-1])
|
||||
qs = qs.filter(tag__iexact=s[1:-1])
|
||||
else:
|
||||
qs = qs.filter(tag__icontains=s)
|
||||
|
||||
|
||||
@@ -627,9 +627,7 @@ class ItemUpdateForm(I18nModelForm):
|
||||
'class': 'scrolling-multiple-choice'
|
||||
}),
|
||||
'generate_tickets': TicketNullBooleanSelect(),
|
||||
'show_quota_left': ShowQuotaNullBooleanSelect(),
|
||||
'max_per_order': forms.widgets.NumberInput(attrs={'min': 0}),
|
||||
'min_per_order': forms.widgets.NumberInput(attrs={'min': 0}),
|
||||
'show_quota_left': ShowQuotaNullBooleanSelect()
|
||||
}
|
||||
|
||||
|
||||
@@ -715,7 +713,6 @@ class ItemVariationForm(I18nModelForm):
|
||||
'default_price',
|
||||
'original_price',
|
||||
'description',
|
||||
'require_approval',
|
||||
'require_membership',
|
||||
'require_membership_hidden',
|
||||
'require_membership_types',
|
||||
|
||||
@@ -1,129 +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 ipaddress
|
||||
import socket
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.forms import SecretKeySettingsField, SettingsForm
|
||||
|
||||
|
||||
class SMTPMailForm(SettingsForm):
|
||||
mail_from = forms.EmailField(
|
||||
label=_("Sender address"),
|
||||
help_text=_("Sender address for outgoing emails"),
|
||||
required=True,
|
||||
)
|
||||
smtp_host = forms.CharField(
|
||||
label=_("Hostname"),
|
||||
required=True,
|
||||
widget=forms.TextInput(attrs={'placeholder': 'mail.example.org'})
|
||||
)
|
||||
smtp_port = forms.IntegerField(
|
||||
label=_("Port"),
|
||||
required=True,
|
||||
widget=forms.TextInput(attrs={'placeholder': 'e.g. 587, 465, 25, ...'})
|
||||
)
|
||||
smtp_username = forms.CharField(
|
||||
label=_("Username"),
|
||||
widget=forms.TextInput(attrs={'placeholder': 'myuser@example.org'}),
|
||||
required=False
|
||||
)
|
||||
smtp_password = SecretKeySettingsField(
|
||||
label=_("Password"),
|
||||
required=False,
|
||||
)
|
||||
smtp_use_tls = forms.BooleanField(
|
||||
label=_("Use STARTTLS"),
|
||||
help_text=_("Commonly enabled on port 587."),
|
||||
required=False
|
||||
)
|
||||
smtp_use_ssl = forms.BooleanField(
|
||||
label=_("Use SSL"),
|
||||
help_text=_("Commonly enabled on port 465."),
|
||||
required=False
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
if data.get('smtp_use_tls') and data.get('smtp_use_ssl'):
|
||||
raise ValidationError(_('You can activate either SSL or STARTTLS security, but not both at the same time.'))
|
||||
for k, v in self.fields.items():
|
||||
val = data.get(k)
|
||||
if v._required and not val:
|
||||
self.add_error(k, _('This field is required.'))
|
||||
return data
|
||||
|
||||
def clean_smtp_host(self):
|
||||
v = self.cleaned_data['smtp_host']
|
||||
if not settings.MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS:
|
||||
try:
|
||||
if ipaddress.ip_address(v).is_private:
|
||||
raise ValidationError(_('You are not allowed to use this mail server, please choose one with a '
|
||||
'public IP address instead.'))
|
||||
except ValueError:
|
||||
try:
|
||||
if ipaddress.ip_address(socket.gethostbyname(v)).is_private:
|
||||
raise ValidationError(_('You are not allowed to use this mail server, please choose one with a '
|
||||
'public IP address instead.'))
|
||||
except OSError:
|
||||
raise ValidationError(_('We were unable to resolve this hostname.'))
|
||||
return v
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.obj.settings.mail_from in (settings.MAIL_FROM, settings.MAIL_FROM_ORGANIZERS):
|
||||
self.initial.pop('mail_from')
|
||||
|
||||
for k, v in self.fields.items():
|
||||
v._required = v.required
|
||||
v.required = False
|
||||
v.widget.is_required = False
|
||||
|
||||
|
||||
class SimpleMailForm(SettingsForm):
|
||||
mail_from = forms.EmailField(
|
||||
label=_("Sender address"),
|
||||
help_text=_("Sender address for outgoing emails"),
|
||||
required=True,
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
for k, v in self.fields.items():
|
||||
val = cleaned_data.get(k)
|
||||
if v._required and not val:
|
||||
self.add_error(k, _('This field is required.'))
|
||||
return cleaned_data
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.obj.settings.mail_from in (settings.MAIL_FROM, settings.MAIL_FROM_ORGANIZERS):
|
||||
self.initial.pop('mail_from')
|
||||
|
||||
for k, v in self.fields.items():
|
||||
v._required = v.required
|
||||
v.required = False
|
||||
v.widget.is_required = False
|
||||
@@ -452,7 +452,7 @@ class OrderPositionChangeForm(forms.Form):
|
||||
|
||||
@staticmethod
|
||||
def taxrule_label_from_instance(obj):
|
||||
return f"{obj.internal_name or obj.name} ({obj.rate} %)"
|
||||
return f"{obj.name} ({obj.rate} %)"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
instance = kwargs.pop('instance')
|
||||
@@ -482,9 +482,6 @@ class OrderPositionChangeForm(forms.Form):
|
||||
self.fields['tax_rule'].queryset = instance.event.tax_rules.all()
|
||||
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 (
|
||||
instance.item.seat_category_mappings.filter(subevent=instance.subevent).exists()
|
||||
):
|
||||
@@ -615,7 +612,7 @@ class OrderMailForm(forms.Form):
|
||||
)
|
||||
attach_tickets = forms.BooleanField(
|
||||
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
|
||||
)
|
||||
attach_invoices = forms.ModelMultipleChoiceField(
|
||||
@@ -749,17 +746,16 @@ class EventCancelForm(forms.Form):
|
||||
auto_refund = forms.BooleanField(
|
||||
label=_('Automatically refund money if possible'),
|
||||
initial=True,
|
||||
required=False,
|
||||
help_text=_('Only available for payment method that support automatic refunds.')
|
||||
required=False
|
||||
)
|
||||
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,
|
||||
required=False,
|
||||
help_text=_('Manual refunds will be created which will be listed in the manual refund to-do list. '
|
||||
'When combined with the automatic refund functionally, only payments with a payment method not '
|
||||
'supporting automatic refunds will be on your manual refund to-do list. Do not check if you want '
|
||||
'to refund some of the orders by offsetting with different orders or issuing gift cards.')
|
||||
help_text=_('If checked, all payments with a payment method not supporting automatic refunds will be on your '
|
||||
'manual refund to-do list. Do not check if you want to refund some of the orders by offsetting '
|
||||
'with different orders or issuing gift cards.')
|
||||
)
|
||||
refund_as_giftcard = forms.BooleanField(
|
||||
label=_('Refund order value to a gift card instead instead of the original payment method'),
|
||||
|
||||
@@ -44,23 +44,21 @@ from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes.forms import SafeModelMultipleChoiceField
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea
|
||||
from phonenumber_field.formfields import PhoneNumberField
|
||||
from pytz import common_timezones
|
||||
|
||||
from pretix.api.models import WebHook
|
||||
from pretix.api.webhooks import get_all_webhook_events
|
||||
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
|
||||
from pretix.base.forms.questions import (
|
||||
NamePartsFormField, WrappedPhoneNumberPrefixWidget, get_country_by_locale,
|
||||
get_phone_prefix,
|
||||
)
|
||||
from pretix.base.forms.questions import NamePartsFormField
|
||||
from pretix.base.forms.widgets import SplitDateTimePickerWidget
|
||||
from pretix.base.models import (
|
||||
Customer, Device, EventMetaProperty, Gate, GiftCard, Membership,
|
||||
MembershipType, Organizer, Team,
|
||||
)
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS
|
||||
from pretix.control.forms import ExtFileField, SplitDateTimeField
|
||||
from pretix.control.forms import (
|
||||
ExtFileField, SMTPSettingsMixin, SplitDateTimeField,
|
||||
)
|
||||
from pretix.control.forms.event import (
|
||||
SafeEventMultipleChoiceField, multimail_validate,
|
||||
)
|
||||
@@ -286,7 +284,6 @@ class OrganizerSettingsForm(SettingsForm):
|
||||
required=False,
|
||||
)
|
||||
auto_fields = [
|
||||
'allowed_restricted_plugins',
|
||||
'customer_accounts',
|
||||
'customer_accounts_link_by_email',
|
||||
'invoice_regenerate_allowed',
|
||||
@@ -340,12 +337,7 @@ class OrganizerSettingsForm(SettingsForm):
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
is_admin = kwargs.pop('is_admin', False)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not is_admin:
|
||||
del self.fields['allowed_restricted_plugins']
|
||||
|
||||
self.fields['name_scheme'].choices = (
|
||||
(k, _('Ask for {fields}, display like {example}').format(
|
||||
fields=' + '.join(str(vv[1]) for vv in v['fields']),
|
||||
@@ -362,8 +354,9 @@ class OrganizerSettingsForm(SettingsForm):
|
||||
]
|
||||
|
||||
|
||||
class MailSettingsForm(SettingsForm):
|
||||
class MailSettingsForm(SMTPSettingsMixin, SettingsForm):
|
||||
auto_fields = [
|
||||
'mail_from',
|
||||
'mail_from_name',
|
||||
]
|
||||
|
||||
@@ -428,7 +421,6 @@ class MailSettingsForm(SettingsForm):
|
||||
if f == 'full_name':
|
||||
continue
|
||||
placeholders['name_%s' % f] = name_scheme['sample'][f]
|
||||
placeholders['name_for_salutation'] = _("Mr Doe")
|
||||
return placeholders
|
||||
|
||||
def _set_field_placeholders(self, fn, base_parameters):
|
||||
@@ -543,21 +535,11 @@ class CustomerUpdateForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Customer
|
||||
fields = ['is_active', 'name_parts', 'email', 'is_verified', 'phone', 'locale']
|
||||
fields = ['is_active', 'name_parts', 'email', 'is_verified', 'locale']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not self.instance.phone and (self.instance.organizer.settings.region or self.instance.locale):
|
||||
country_code = self.instance.organizer.settings.region or get_country_by_locale(self.instance.locale)
|
||||
phone_prefix = get_phone_prefix(country_code)
|
||||
if phone_prefix:
|
||||
self.initial['phone'] = "+{}.".format(phone_prefix)
|
||||
|
||||
self.fields['phone'] = PhoneNumberField(
|
||||
label=_('Phone'),
|
||||
required=False,
|
||||
widget=WrappedPhoneNumberPrefixWidget()
|
||||
)
|
||||
self.fields['name_parts'] = NamePartsFormField(
|
||||
max_length=255,
|
||||
required=False,
|
||||
|
||||
@@ -385,12 +385,6 @@ class VoucherBulkForm(VoucherForm):
|
||||
if vouchers.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')]):
|
||||
raise ValidationError(_('If vouchers should be sent by email, subject, message and recipients need to be specified.'))
|
||||
|
||||
|
||||
@@ -347,7 +347,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'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.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}" '
|
||||
'to "{new_email}".'),
|
||||
'pretix.event.order.contact.confirmed': _('The email address has been confirmed to be working (the user clicked on a link '
|
||||
@@ -423,7 +423,6 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.voucher.added': _('The voucher has been created.'),
|
||||
'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.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.deleted': _('The voucher has been deleted.'),
|
||||
'pretix.voucher.redeemed': _('The voucher has been redeemed in order {order_code}.'),
|
||||
|
||||
@@ -43,6 +43,7 @@ from django.urls import get_script_prefix, resolve, reverse
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scope
|
||||
from hijack.templatetags.hijack_tags import is_hijacked
|
||||
|
||||
from pretix.base.models import Event, Organizer
|
||||
from pretix.base.models.auth import SuperuserPermissionSet, User
|
||||
@@ -142,18 +143,6 @@ class PermissionMiddleware:
|
||||
return redirect(reverse('control:user.settings.2fa'))
|
||||
|
||||
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):
|
||||
request.event = Event.objects.filter(
|
||||
slug=url.kwargs['event'],
|
||||
@@ -169,17 +158,6 @@ class PermissionMiddleware:
|
||||
else:
|
||||
request.eventpermset = request.user.get_event_permission_set(request.organizer, request.event)
|
||||
elif 'organizer' in url.kwargs:
|
||||
if url.kwargs['organizer'] == '-':
|
||||
# This is a hack that just takes the user to ANY organizer. It's useful to link to features in support
|
||||
# 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(
|
||||
slug=url.kwargs['organizer'],
|
||||
).first()
|
||||
@@ -205,7 +183,7 @@ class AuditLogMiddleware:
|
||||
|
||||
def __call__(self, request):
|
||||
if request.path.startswith(get_script_prefix() + 'control') and request.user.is_authenticated:
|
||||
if getattr(request.user, "is_hijacked", False):
|
||||
if is_hijacked(request):
|
||||
hijack_history = request.session.get('hijack_history', False)
|
||||
hijacker = get_object_or_404(User, pk=hijack_history[0])
|
||||
ss = hijacker.get_active_staff_session(request.session.get('hijacker_session'))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% load compress %}
|
||||
{% load i18n %}
|
||||
{% load hijack_tags %}
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html{% if rtl %} dir="rtl" class="rtl"{% endif %}>
|
||||
@@ -38,7 +39,7 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if request.user.is_hijacked %}
|
||||
{% if request|is_hijacked %}
|
||||
<div class="impersonate-warning">
|
||||
<span class="fa fa-user-secret"></span>
|
||||
{% blocktrans with user=request.user%}You are currently working on behalf of {{ user }}.{% endblocktrans %}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% load compress %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load hijack_tags %}
|
||||
{% load statici18n %}
|
||||
{% load eventsignal %}
|
||||
{% load eventurl %}
|
||||
@@ -350,7 +351,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if request.user.is_hijacked %}
|
||||
{% if request|is_hijacked %}
|
||||
<div class="impersonate-warning">
|
||||
<span class="fa fa-user-secret"></span>
|
||||
{% blocktrans with user=request.user%}You are currently working on behalf of {{ user }}.{% endblocktrans %}
|
||||
|
||||
@@ -74,17 +74,17 @@
|
||||
{{ c.datetime|date:"SHORT_DATETIME_FORMAT" }}
|
||||
{% if c.type == "exit" %}
|
||||
{% if c.auto_checked_in %}
|
||||
<span class="fa fa-fw fa-hourglass-end" data-toggle="tooltip"
|
||||
<span class="fa fa-fw fa-hourglass-end" data-toggle="tooltip_html"
|
||||
title="{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically marked not present: {{ date }}{% endblocktrans %}"></span>
|
||||
{% endif %}
|
||||
{% elif c.forced and c.successful %}
|
||||
<span class="fa fa-fw fa-warning" data-toggle="tooltip"
|
||||
<span class="fa fa-fw fa-warning" data-toggle="tooltip_html"
|
||||
title="{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Additional entry scan: {{ date }}{% endblocktrans %}"></span>
|
||||
{% elif c.forced and not c.successful %}
|
||||
<br>
|
||||
<small class="text-muted">{% trans "Failed in offline mode" %}</small>
|
||||
{% elif c.auto_checked_in %}
|
||||
<span class="fa fa-fw fa-magic" data-toggle="tooltip"
|
||||
<span class="fa fa-fw fa-magic" data-toggle="tooltip_html"
|
||||
title="{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}Automatically checked in: {{ date }}{% endblocktrans %}"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
<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" %}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{% load i18n %}{% blocktrans with code=code instance=instance %}Hello,
|
||||
|
||||
someone requested to use {{ address }} as a sender address on {{ instance }}.
|
||||
This will allow them to send emails that are shown to originate from this email address.
|
||||
If that was you, please enter the following confirmation code:
|
||||
|
||||
{{ code }}
|
||||
|
||||
If this was not requested by you, you can safely ignore this email.
|
||||
|
||||
Best regards,
|
||||
|
||||
Your {{ instance }} team
|
||||
{% endblocktrans %}
|
||||
@@ -1,127 +0,0 @@
|
||||
{% extends basetpl %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load hierarkey_form %}
|
||||
{% load static %}
|
||||
{% block title %}{% trans "Organizer" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "E-mail sending" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
<div class="panel-group" id="email">
|
||||
<div class="panel panel-default">
|
||||
<div class="accordion-radio">
|
||||
<div class="panel-heading">
|
||||
<p class="panel-title">
|
||||
<input type="radio" name="mode" value="system"
|
||||
data-parent="#email"
|
||||
{% if mode == "system" %}checked="checked"{% endif %}
|
||||
id="input_mode_system"
|
||||
data-toggle="radiocollapse" data-target="#mode_system"/>
|
||||
<label for="input_mode_system"><strong>{% trans "Use system default" %}</strong></label>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mode_system"
|
||||
class="panel-collapse collapsed {% if mode == "system" %}in{% endif %}">
|
||||
<div class="panel-body form-horizontal">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
E-mails will be sent through the system's default server. They will show the following
|
||||
sender information:
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<dl class="dl-horizontal">
|
||||
<dt>{% trans "From" context "mail_header" %}</dt>
|
||||
<dd>{{ object.settings.mail_from_name|default_if_none:object.name }}
|
||||
<{{ default_sender_address }}>
|
||||
</dd>
|
||||
{% if object.settings.contact_mail %}
|
||||
<dt>{% trans "Reply-To" context "mail_header" %}</dt>
|
||||
<dd>{{ object.settings.contact_mail }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="accordion-radio">
|
||||
<div class="panel-heading">
|
||||
<p class="panel-title">
|
||||
<input type="radio" name="mode" value="simple"
|
||||
data-parent="#email"
|
||||
{% if mode == "simple" %}checked="checked"{% endif %}
|
||||
id="input_mode_simple"
|
||||
data-toggle="radiocollapse" data-target="#mode_simple"/>
|
||||
<label for="input_mode_simple"><strong>{% trans "Use system email server with a custom sender address" %}</strong></label>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mode_simple"
|
||||
class="panel-collapse collapsed {% if mode == "simple" %}in{% endif %}">
|
||||
<div class="panel-body form-horizontal">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
E-mails will be sent through the system's default server but with your own sender
|
||||
address.
|
||||
This will make your emails look more personalized and coming directly from you, but it
|
||||
also might require some extra steps to ensure good deliverability.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% bootstrap_form simple_form layout="control" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="accordion-radio">
|
||||
<div class="panel-heading">
|
||||
<p class="panel-title">
|
||||
<input type="radio" name="mode" value="smtp"
|
||||
data-parent="#email"
|
||||
{% if mode == "smtp" %}checked="checked"{% endif %}
|
||||
id="input_mode_smtp"
|
||||
data-toggle="radiocollapse" data-target="#mode_smtp"/>
|
||||
<label for="input_mode_smtp"><strong>{% trans "Use a custom SMTP server" %}</strong></label>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mode_smtp"
|
||||
class="panel-collapse collapsed {% if mode == "smtp" %}in{% endif %}">
|
||||
<div class="panel-body form-horizontal">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
For full customization, you can configure your own SMTP server that will be used for
|
||||
email sending.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% bootstrap_form smtp_form layout="control" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if request.event %}
|
||||
<div class="panel panel-default">
|
||||
<div class="accordion-radio">
|
||||
<div class="panel-heading">
|
||||
<p class="panel-title">
|
||||
<input type="radio" name="mode" value="reset"
|
||||
data-parent="#reset"
|
||||
id="input_mode_reset"
|
||||
data-toggle="radiocollapse" data-target="#mode_reset"/>
|
||||
<label for="input_mode_reset"><strong>{% trans "Reset to organizer settings" %}</strong></label>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mode_reset"
|
||||
class="panel-collapse collapsed {% if mode == "reset" %}in{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Continue" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,79 +0,0 @@
|
||||
{% extends basetpl %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load hierarkey_form %}
|
||||
{% load static %}
|
||||
{% block title %}{% trans "Organizer" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "E-mail sending" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% for k, v in request.POST.items %}
|
||||
<input type="hidden" name="{{ k }}" value="{{ v }}">
|
||||
{% endfor %}
|
||||
<input type="hidden" name="state" value="save">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<p class="panel-title">
|
||||
<strong>{% trans "Use system email server with a custom sender address" %}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="panel-body form-horizontal">
|
||||
{% if spf_warning %}
|
||||
<div class="alert alert-warning">
|
||||
<p>
|
||||
{{ spf_warning }}
|
||||
</p>
|
||||
{% if spf_record %}
|
||||
<p>
|
||||
{% trans "This is the SPF record we found on your domain:" %}
|
||||
</p>
|
||||
<pre><code>{{ spf_record }}</code></pre>
|
||||
<p>
|
||||
{% trans "To fix this, include the following part before the last word:" %}
|
||||
</p>
|
||||
<pre><code>{{ spf_key }}</code></pre>
|
||||
{% else %}
|
||||
<p>
|
||||
{% trans "Your new SPF record could look like this:" %}
|
||||
</p>
|
||||
<pre><code>v=spf1 a mx {{ spf_key }} ~all</code></pre>
|
||||
{% endif %}
|
||||
<p>
|
||||
{% trans "Please keep in mind that updates to DNS might require multiple hours to take effect." %}
|
||||
</p>
|
||||
</div>
|
||||
{% elif spf_key %}
|
||||
<div class="alert alert-success">
|
||||
{% blocktrans trimmed %}
|
||||
We found an SPF record on your domain that includes this system. Great!
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if verification %}
|
||||
<h3>{% trans "Verification" %}</h3>
|
||||
<p>
|
||||
{% blocktrans trimmed with recp=recp %}
|
||||
We've sent an email to {{ recp }} with a confirmation code to verify that this email address
|
||||
is owned by you. Please enter the verification code below:
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label" for="id_verification">
|
||||
{% trans "Verification code" %}
|
||||
</label>
|
||||
<div class="col-md-9">
|
||||
<input type="text" name="verification" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,42 +0,0 @@
|
||||
{% extends basetpl %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load hierarkey_form %}
|
||||
{% load static %}
|
||||
{% block title %}{% trans "Organizer" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "E-mail sending" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% for k, v in request.POST.items %}
|
||||
<input type="hidden" name="{{ k }}" value="{{ v }}">
|
||||
{% endfor %}
|
||||
<input type="hidden" name="state" value="save">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<p class="panel-title">
|
||||
<strong>{% trans "Use a custom SMTP server" %}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="panel-body form-horizontal">
|
||||
<div class="alert alert-success">
|
||||
{% blocktrans trimmed %}
|
||||
A test connection to your SMTP server was successful. You can now save your new settings
|
||||
to put them in use.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% if known_host_problem %}
|
||||
<div class="alert alert-warning">
|
||||
{{ known_host_problem }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -12,55 +12,17 @@
|
||||
<div class="tabbed-form">
|
||||
<fieldset>
|
||||
<legend>{% trans "General" %}</legend>
|
||||
{% bootstrap_field form.mail_prefix layout="control" %}
|
||||
{% bootstrap_field form.mail_attach_tickets layout="control" %}
|
||||
{% url "control:organizer.settings.mail" organizer=request.organizer.slug as org_url %}
|
||||
{% propagated request.event org_url "mail_from" "smtp_use_custom" "smtp_host" "smtp_port" "smtp_username" "smtp_password" "smtp_use_tls" "smtp_use_ssl" %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
{% trans "Sending method" %}
|
||||
</label>
|
||||
<div class="col-md-9 static-form-row-with-btn">
|
||||
{% if request.event.settings.smtp_use_custom %}
|
||||
{% trans "Custom SMTP server" %}: {{ request.event.settings.smtp_host }}
|
||||
{% else %}
|
||||
{% trans "System-provided email server" %}
|
||||
{% endif %}
|
||||
|
||||
<a href="{% url "control:event.settings.mail.setup" organizer=request.organizer.slug event=request.event.slug %}"
|
||||
class="btn btn-default">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
{% trans "Sender address" %}
|
||||
</label>
|
||||
<div class="col-md-9 static-form-row-with-btn">
|
||||
{{ request.event.settings.mail_from }}
|
||||
|
||||
<a href="{% url "control:event.settings.mail.setup" organizer=request.organizer.slug event=request.event.slug %}"
|
||||
class="btn btn-default">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endpropagated %}
|
||||
{% propagated request.event org_url "mail_from_name" "mail_text_signature" "mail_bcc" %}
|
||||
{% propagated request.event org_url "mail_from" "mail_from_name" "mail_text_signature" "mail_bcc" %}
|
||||
{% bootstrap_field form.mail_from layout="control" %}
|
||||
{% bootstrap_field form.mail_from_name layout="control" %}
|
||||
{% bootstrap_field form.mail_text_signature layout="control" %}
|
||||
{% bootstrap_field form.mail_bcc layout="control" %}
|
||||
{% endpropagated %}
|
||||
{% bootstrap_field form.mail_sales_channel_placed_paid layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Calendar invites" %}</legend>
|
||||
{% bootstrap_field form.mail_prefix layout="control" %}
|
||||
{% bootstrap_field form.mail_attach_tickets layout="control" %}
|
||||
{% bootstrap_field form.mail_attach_ical layout="control" %}
|
||||
{% bootstrap_field form.mail_attach_ical_paid_only layout="control" %}
|
||||
{% bootstrap_field form.mail_attach_ical_description layout="control" %}
|
||||
{% bootstrap_field form.mail_sales_channel_placed_paid layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "E-mail design" %}</legend>
|
||||
@@ -85,7 +47,6 @@
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "E-mail content" %}</legend>
|
||||
<h4>{% trans "Text" %}</h4>
|
||||
<div class="panel-group" id="questions_group">
|
||||
{% blocktrans asvar title_placed_order %}Placed order{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="order_placed" title=title_placed_order items="mail_text_order_placed,mail_send_order_placed_attendee,mail_text_order_placed_attendee" exclude="mail_send_order_placed_attendee" %}
|
||||
@@ -120,14 +81,27 @@
|
||||
{% blocktrans asvar title_require_approval %}Order approval process{% endblocktrans %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_require_approval items="mail_text_order_placed_require_approval,mail_text_order_approved,mail_text_order_approved_free,mail_text_order_denied" %}
|
||||
</div>
|
||||
<h4>{% trans "Attachments" %}</h4>
|
||||
{% bootstrap_field form.mail_attachment_new_order layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "SMTP settings" %}</legend>
|
||||
{% propagated request.event org_url "smtp_use_custom" "smtp_host" "smtp_port" "smtp_username" "smtp_password" "smtp_use_tls" "smtp_use_ssl" %}
|
||||
{% bootstrap_field form.smtp_use_custom layout="control" %}
|
||||
{% bootstrap_field form.smtp_host layout="control" %}
|
||||
{% bootstrap_field form.smtp_port layout="control" %}
|
||||
{% bootstrap_field form.smtp_username layout="control" %}
|
||||
{% bootstrap_field form.smtp_password layout="control" %}
|
||||
{% bootstrap_field form.smtp_use_tls layout="control" %}
|
||||
{% bootstrap_field form.smtp_use_ssl layout="control" %}
|
||||
{% endpropagated %}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-default btn-save pull-left" name="test" value="1">
|
||||
{% trans "Save and test custom SMTP connection" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
{% extends "pretixcontrol/event/settings_base.html" %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "Available 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>
|
||||
<h1>{% trans "Installed plugins" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal form-plugins">
|
||||
{% csrf_token %}
|
||||
{% if "success" in request.GET %}
|
||||
@@ -18,71 +11,71 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="tabbed-form">
|
||||
{% for cat, catlabel, plist, has_pictures in plugins %}
|
||||
{% for cat, catlabel, plist in plugins %}
|
||||
<fieldset>
|
||||
<legend>{{ catlabel }}</legend>
|
||||
<div class="plugin-list">
|
||||
{% for plugin in plist %}
|
||||
<div class="plugin-container {% if plugin.featured %}featured-plugin{% endif %}">
|
||||
{% if plugin.featured %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-body">
|
||||
{% endif %}
|
||||
<div class="plugin-text">
|
||||
{% if plugin.featured or plugin.experimental %}
|
||||
<p class="text-muted">
|
||||
{% if plugin.featured %}
|
||||
<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>
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
{% for plugin in plist %}
|
||||
<tr class="{% if plugin.app.compatibility_errors %}warning{% elif plugin.module in plugins_active %}success{% else %}default{% endif %}">
|
||||
<td>
|
||||
<strong>{{ plugin.name }}</strong>
|
||||
{% if plugin.author %}
|
||||
<p class="meta text-muted">
|
||||
{% blocktrans trimmed with v=plugin.version a=plugin.author %}
|
||||
Version {{ v }} by <em>{{ a }}</em>
|
||||
{% endblocktrans %}</p>
|
||||
{% else %}
|
||||
<div class="plugin-action flip">
|
||||
<button class="btn btn-primary{% if plugin.featured %} btn-lg{% endif %}" name="plugin:{{ plugin.module }}"
|
||||
value="enable">{% trans "Enable" %}</button>
|
||||
<p class="meta text-muted">
|
||||
{% blocktrans trimmed with v=plugin.version a=plugin.author %}
|
||||
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>
|
||||
{% endif %}
|
||||
{% if plugin.featured %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% 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 %}
|
||||
</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>
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user