forked from CGM_Public/pretix_original
Compare commits
124 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e487e35db | ||
|
|
418bfa8b6b | ||
|
|
d080e35999 | ||
|
|
b641d343d6 | ||
|
|
377765e2e1 | ||
|
|
b8b5835eff | ||
|
|
4383187e36 | ||
|
|
38e826724f | ||
|
|
6b983d5f55 | ||
|
|
0c46aa42c7 | ||
|
|
acefd98ef2 | ||
|
|
4cc86c0f24 | ||
|
|
3bb29f8995 | ||
|
|
5724b46224 | ||
|
|
6bdf521207 | ||
|
|
7edb8d098a | ||
|
|
424a92676a | ||
|
|
6852844410 | ||
|
|
3d2adf9900 | ||
|
|
bca3d182ff | ||
|
|
f6890e0e65 | ||
|
|
561b94957a | ||
|
|
c6ba50a639 | ||
|
|
d7849d3ac1 | ||
|
|
efcd3f01bb | ||
|
|
b0385c8325 | ||
|
|
8b0814fe9f | ||
|
|
386e658d0b | ||
|
|
4ef96b7e94 | ||
|
|
c0c2782db6 | ||
|
|
141634eb49 | ||
|
|
a86dfcd504 | ||
|
|
76c6bd57e9 | ||
|
|
73776ce0dd | ||
|
|
4fc6593b60 | ||
|
|
08fdf1dcad | ||
|
|
c2cc49bf34 | ||
|
|
0655a7cad1 | ||
|
|
788757b7f0 | ||
|
|
4bf97a0699 | ||
|
|
74a55bfe0e | ||
|
|
fe3422edc8 | ||
|
|
a166ebeb07 | ||
|
|
2dfd507134 | ||
|
|
89da42e98c | ||
|
|
d42a937d39 | ||
|
|
c2d47ca7d3 | ||
|
|
0570b51324 | ||
|
|
b19d339c37 | ||
|
|
beea439df8 | ||
|
|
30a2d853fd | ||
|
|
044d6720d2 | ||
|
|
2427421945 | ||
|
|
ff86fcf000 | ||
|
|
7b1fa90d70 | ||
|
|
16d191c5e0 | ||
|
|
1517e86034 | ||
|
|
ef7c497bce | ||
|
|
60b86d5e72 | ||
|
|
9329caabed | ||
|
|
bacd6b8191 | ||
|
|
11e3bd4d39 | ||
|
|
c890f4cdc0 | ||
|
|
6aeb82b06a | ||
|
|
e65ef392a3 | ||
|
|
57252dca2e | ||
|
|
10aa590bd3 | ||
|
|
6ce1270cbd | ||
|
|
5dc87d5494 | ||
|
|
7ce63e5b16 | ||
|
|
27b514dc36 | ||
|
|
ca956f8e3b | ||
|
|
a4ad518717 | ||
|
|
9696fad482 | ||
|
|
d62931d4c0 | ||
|
|
8212bb5875 | ||
|
|
ba1a5f0e35 | ||
|
|
a2fd012106 | ||
|
|
35a3804751 | ||
|
|
a3fb10bcb0 | ||
|
|
d19cdfb83f | ||
|
|
b404f6d619 | ||
|
|
dda1368d81 | ||
|
|
ddade60625 | ||
|
|
b1e8e072d4 | ||
|
|
b6ade23c50 | ||
|
|
6573578ef1 | ||
|
|
33fc752a5f | ||
|
|
ff043e98f3 | ||
|
|
ddfe065ebd | ||
|
|
bd5b63a90e | ||
|
|
0432798d23 | ||
|
|
ecb2865cb8 | ||
|
|
43a05e1cb3 | ||
|
|
fa28b9c13a | ||
|
|
8a2b38792a | ||
|
|
6652c2e61a | ||
|
|
88b3f588b8 | ||
|
|
eabcececb0 | ||
|
|
31a2a40bce | ||
|
|
a6e897b481 | ||
|
|
a000bacb1f | ||
|
|
b60d38ff3b | ||
|
|
dea47a4e87 | ||
|
|
78e2ea4fef | ||
|
|
9173832ce1 | ||
|
|
d7c9e82bae | ||
|
|
fb7a540efb | ||
|
|
f57a9cbae0 | ||
|
|
fdff1c80d3 | ||
|
|
986122e7f8 | ||
|
|
5dbb01342a | ||
|
|
f81c388906 | ||
|
|
63bc6c17c9 | ||
|
|
5c8d1fde32 | ||
|
|
d0b449ea89 | ||
|
|
377117548d | ||
|
|
634445b79d | ||
|
|
496e4c800a | ||
|
|
83fe53ac2b | ||
|
|
534e6eb32d | ||
|
|
23b863df96 | ||
|
|
fbca5e3ab1 | ||
|
|
37f6b7023c |
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -6,7 +6,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/src"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
versioning-strategy: increase
|
||||
|
||||
2
.github/workflows/strings.yml
vendored
2
.github/workflows/strings.yml
vendored
@@ -38,7 +38,6 @@ jobs:
|
||||
run: sudo apt update && sudo apt install gettext
|
||||
- name: Install Dependencies
|
||||
run: pip3 install -e ".[dev]"
|
||||
working-directory: ./src
|
||||
- name: Compile messages
|
||||
run: python manage.py compilemessages
|
||||
working-directory: ./src
|
||||
@@ -64,7 +63,6 @@ jobs:
|
||||
run: sudo apt update && sudo apt install enchant-2 hunspell hunspell-de-de aspell-en aspell-de
|
||||
- name: Install Dependencies
|
||||
run: pip3 install -e ".[dev]"
|
||||
working-directory: ./src
|
||||
- name: Spellcheck translations
|
||||
run: potypo
|
||||
working-directory: ./src
|
||||
|
||||
2
.github/workflows/style.yml
vendored
2
.github/workflows/style.yml
vendored
@@ -36,7 +36,6 @@ jobs:
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install Dependencies
|
||||
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
|
||||
working-directory: ./src
|
||||
- name: Run isort
|
||||
run: isort -c .
|
||||
working-directory: ./src
|
||||
@@ -57,7 +56,6 @@ jobs:
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install Dependencies
|
||||
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
|
||||
working-directory: ./src
|
||||
- name: Run flake8
|
||||
run: flake8 .
|
||||
working-directory: ./src
|
||||
|
||||
1
.github/workflows/tests.yml
vendored
1
.github/workflows/tests.yml
vendored
@@ -64,7 +64,6 @@ jobs:
|
||||
run: sudo apt update && sudo apt install gettext mariadb-client
|
||||
- name: Install Python dependencies
|
||||
run: pip3 install --ignore-requires-python -e ".[dev]" mysqlclient psycopg2-binary # We ignore that flake8 needs newer python as we don't run flake8 during tests
|
||||
working-directory: ./src
|
||||
- name: Run checks
|
||||
run: python manage.py check
|
||||
working-directory: ./src
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
env/
|
||||
build/
|
||||
dist/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.ropeproject
|
||||
|
||||
@@ -5,8 +5,8 @@ tests:
|
||||
- virtualenv env
|
||||
- source env/bin/activate
|
||||
- pip install -U pip wheel setuptools
|
||||
- cd src
|
||||
- XDG_CACHE_HOME=/cache pip3 install -e ".[dev]"
|
||||
- cd src
|
||||
- python manage.py check
|
||||
- make all compress
|
||||
- py.test --reruns 3 -n 3 tests
|
||||
@@ -21,14 +21,15 @@ pypi:
|
||||
- virtualenv env
|
||||
- source env/bin/activate
|
||||
- pip install -U pip wheel setuptools check-manifest twine
|
||||
- cd src
|
||||
- XDG_CACHE_HOME=/cache pip3 install -e ".[dev]"
|
||||
- python setup.py sdist
|
||||
- pip install dist/pretix-*.tar.gz
|
||||
- python -m pretix migrate
|
||||
- python -m pretix check
|
||||
- check-manifest
|
||||
- cd src
|
||||
- make npminstall
|
||||
- cd ..
|
||||
- check-manifest
|
||||
- python setup.py sdist bdist_wheel
|
||||
- twine check dist/*
|
||||
- twine upload dist/*
|
||||
|
||||
@@ -19,6 +19,8 @@ RUN apt-get update && \
|
||||
python3-dev \
|
||||
sudo \
|
||||
supervisor \
|
||||
libmaxminddb0 \
|
||||
libmaxminddb-dev \
|
||||
zlib1g-dev && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
|
||||
47
MANIFEST.in
Normal file
47
MANIFEST.in
Normal file
@@ -0,0 +1,47 @@
|
||||
include LICENSE
|
||||
include README.rst
|
||||
include src/Makefile
|
||||
global-include *.proto
|
||||
recursive-include src/pretix/static *
|
||||
recursive-include src/pretix/static.dist *
|
||||
recursive-include src/pretix/locale *
|
||||
recursive-include src/pretix/helpers/locale *
|
||||
recursive-include src/pretix/base/templates *
|
||||
recursive-include src/pretix/control/templates *
|
||||
recursive-include src/pretix/presale/templates *
|
||||
recursive-include src/pretix/plugins/banktransfer/templates *
|
||||
recursive-include src/pretix/plugins/banktransfer/static *
|
||||
recursive-include src/pretix/plugins/manualpayment/templates *
|
||||
recursive-include src/pretix/plugins/manualpayment/static *
|
||||
recursive-include src/pretix/plugins/paypal/templates *
|
||||
recursive-include src/pretix/plugins/paypal/static *
|
||||
recursive-include src/pretix/plugins/paypal2/templates *
|
||||
recursive-include src/pretix/plugins/paypal2/static *
|
||||
recursive-include src/pretix/plugins/src/pretixdroid/templates *
|
||||
recursive-include src/pretix/plugins/src/pretixdroid/static *
|
||||
recursive-include src/pretix/plugins/sendmail/templates *
|
||||
recursive-include src/pretix/plugins/statistics/templates *
|
||||
recursive-include src/pretix/plugins/statistics/static *
|
||||
recursive-include src/pretix/plugins/stripe/templates *
|
||||
recursive-include src/pretix/plugins/stripe/static *
|
||||
recursive-include src/pretix/plugins/ticketoutputpdf/templates *
|
||||
recursive-include src/pretix/plugins/ticketoutputpdf/static *
|
||||
recursive-include src/pretix/plugins/badges/templates *
|
||||
recursive-include src/pretix/plugins/badges/static *
|
||||
recursive-include src/pretix/plugins/returnurl/templates *
|
||||
recursive-include src/pretix/plugins/returnurl/static *
|
||||
recursive-include src/pretix/plugins/webcheckin/templates *
|
||||
recursive-include src/pretix/plugins/webcheckin/static *
|
||||
recursive-include src *.cfg
|
||||
recursive-include src *.csv
|
||||
recursive-include src *.gitkeep
|
||||
recursive-include src *.jpg
|
||||
recursive-include src *.json
|
||||
recursive-include src *.py
|
||||
recursive-include src *.svg
|
||||
recursive-include src *.txt
|
||||
recursive-include src Makefile
|
||||
|
||||
recursive-exclude doc *
|
||||
recursive-exclude deployment *
|
||||
recursive-exclude res *
|
||||
@@ -481,3 +481,18 @@ You can configure the maximum file size for uploading various files::
|
||||
; Max upload size for other files in MiB, defaults to 10 MiB
|
||||
; This includes all file upload type order questions
|
||||
max_size_other = 100
|
||||
|
||||
|
||||
GeoIP
|
||||
-----
|
||||
|
||||
pretix can optionally make use of a GeoIP database for some features. It needs a file in ``mmdb`` format, for example
|
||||
`GeoLite2`_ or `GeoAcumen`_::
|
||||
|
||||
[geoip]
|
||||
path=/var/geoipdata/
|
||||
filename_country=GeoLite2-Country.mmdb
|
||||
|
||||
|
||||
.. _GeoAcumen: https://github.com/geoacumen/geoacumen-country
|
||||
.. _GeoLite2: https://dev.maxmind.com/geoip/geolite2-free-geolocation-data
|
||||
@@ -16,7 +16,7 @@ Manual installation
|
||||
You can use ``pip`` to update pretix directly to the development branch. Then, upgrade as usual::
|
||||
|
||||
$ source /var/pretix/venv/bin/activate
|
||||
(venv)$ pip3 install -U "git+https://github.com/pretix/pretix.git#egg=pretix&subdirectory=src"
|
||||
(venv)$ pip3 install -U "git+https://github.com/pretix/pretix.git#egg=pretix"
|
||||
(venv)$ python -m pretix migrate
|
||||
(venv)$ python -m pretix rebuild
|
||||
(venv)$ python -m pretix updatestyles
|
||||
|
||||
@@ -225,4 +225,3 @@ You can get three response codes:
|
||||
"subevent": 23,
|
||||
"checkinlist": 5
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ failed scans.
|
||||
|
||||
The endpoints listed on this page have been added.
|
||||
|
||||
.. versionchanged:: 4.18
|
||||
|
||||
The ``source_type`` parameter has been added.
|
||||
|
||||
.. _`rest-checkin-redeem`:
|
||||
|
||||
Checking a ticket in
|
||||
@@ -28,6 +32,7 @@ Checking a ticket in
|
||||
passed needs to be from a distinct event.
|
||||
|
||||
:<json string secret: Scanned QR code corresponding to the ``secret`` attribute of a ticket.
|
||||
:<json string source_type: Type of source the ``secret`` was obtained form. Defaults to ``"barcode"``.
|
||||
:<json array lists: List of check-in list IDs to search on. No two check-in lists may be from the same event.
|
||||
:<json string type: Send ``"exit"`` for an exit and ``"entry"`` (default) for an entry.
|
||||
:<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used.
|
||||
@@ -72,6 +77,7 @@ Checking a ticket in
|
||||
|
||||
{
|
||||
"secret": "M5BO19XmFwAjLd4nDYUAL9ISjhti0e9q",
|
||||
"source_type": "barcode",
|
||||
"lists": [1],
|
||||
"force": false,
|
||||
"ignore_unpaid": false,
|
||||
@@ -213,8 +219,8 @@ Checking a ticket in
|
||||
* ``revoked`` - Ticket code has been revoked.
|
||||
* ``error`` - Internal error.
|
||||
|
||||
In case of reason ``rules``, there might be an additional response field ``reason_explanation`` with a human-readable
|
||||
description of the violated rules. However, that field can also be missing or be ``null``.
|
||||
In case of reason ``rules`` and ``invalid_time``, there might be an additional response field ``reason_explanation``
|
||||
with a human-readable description of the violated rules. However, that field can also be missing or be ``null``.
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:statuscode 201: no error
|
||||
|
||||
@@ -753,8 +753,8 @@ Order position endpoints
|
||||
* ``ambiguous`` - Multiple tickets match scan, rejected.
|
||||
* ``revoked`` - Ticket code has been revoked.
|
||||
|
||||
In case of reason ``rules``, there might be an additional response field ``reason_explanation`` with a human-readable
|
||||
description of the violated rules. However, that field can also be missing or be ``null``.
|
||||
In case of reason ``rules`` or ``invalid_time``, there might be an additional response field ``reason_explanation``
|
||||
with a human-readable description of the violated rules. However, that field can also be missing or be ``null``.
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
|
||||
@@ -547,6 +547,9 @@ Therefore, we're also not including a list of the options here, but instead reco
|
||||
to see available options. The ``explain=true`` flag enables a verbose mode that provides you with human-readable
|
||||
information about the properties.
|
||||
|
||||
Note that some settings are read-only, e.g. because they can be read on event level but currently only be changed on
|
||||
organizer level.
|
||||
|
||||
.. note:: Please note that this is not a complete representation of all event settings. You will find more settings
|
||||
in the web interface.
|
||||
|
||||
@@ -593,6 +596,7 @@ information about the properties.
|
||||
{
|
||||
"value": "https://pretix.eu",
|
||||
"label": "Imprint URL",
|
||||
"readonly": false,
|
||||
"help_text": "This should point e.g. to a part of your website that has your contact details and legal information."
|
||||
}
|
||||
},
|
||||
@@ -606,6 +610,10 @@ information about the properties.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. versionchanged:: 4.18
|
||||
|
||||
The ``readonly`` flag has been added.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/settings/
|
||||
|
||||
Updates event settings. Note that ``PUT`` is not allowed here, only ``PATCH``.
|
||||
|
||||
@@ -32,6 +32,7 @@ at :ref:`plugin-docs`.
|
||||
membershiptypes
|
||||
memberships
|
||||
giftcards
|
||||
reusablemedia
|
||||
carts
|
||||
teams
|
||||
devices
|
||||
|
||||
@@ -108,6 +108,9 @@ generate_tickets boolean If ``false``,
|
||||
allow_waitinglist boolean If ``false``, no waiting list will be shown for this
|
||||
product when it is sold out.
|
||||
issue_giftcard boolean If ``true``, buying this product will yield a gift card.
|
||||
media_policy string Policy on how to handle reusable media (experimental feature).
|
||||
Possible values are ``null``, ``"new"``, ``"reuse"``, and ``"reuse_or_new"``.
|
||||
media_type string Type of reusable media to work on (experimental feature). See :ref:`rest-reusablemedia` for possible choices.
|
||||
show_quota_left boolean Publicly show how many tickets are still available.
|
||||
If this is ``null``, the event default is used.
|
||||
has_variations boolean Shows whether or not this item has variations.
|
||||
@@ -189,6 +192,10 @@ meta_data object Values set fo
|
||||
|
||||
The ``validity_*`` attributes have been added.
|
||||
|
||||
.. versionchanged:: 4.18
|
||||
|
||||
The ``media_policy`` and ``media_type`` attributes have been added.
|
||||
|
||||
Notes
|
||||
-----
|
||||
|
||||
@@ -244,6 +251,8 @@ Endpoints
|
||||
"admission": false,
|
||||
"personalized": false,
|
||||
"issue_giftcard": false,
|
||||
"media_policy": null,
|
||||
"media_type": null,
|
||||
"meta_data": {},
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
@@ -373,6 +382,8 @@ Endpoints
|
||||
"admission": false,
|
||||
"personalized": false,
|
||||
"issue_giftcard": false,
|
||||
"media_policy": null,
|
||||
"media_type": null,
|
||||
"meta_data": {},
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
@@ -483,6 +494,8 @@ Endpoints
|
||||
"admission": false,
|
||||
"personalized": false,
|
||||
"issue_giftcard": false,
|
||||
"media_policy": null,
|
||||
"media_type": null,
|
||||
"meta_data": {},
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
@@ -580,6 +593,8 @@ Endpoints
|
||||
"admission": false,
|
||||
"personalized": false,
|
||||
"issue_giftcard": false,
|
||||
"media_policy": null,
|
||||
"media_type": null,
|
||||
"meta_data": {},
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
@@ -709,6 +724,8 @@ Endpoints
|
||||
"admission": false,
|
||||
"personalized": false,
|
||||
"issue_giftcard": false,
|
||||
"media_policy": null,
|
||||
"media_type": null,
|
||||
"meta_data": {},
|
||||
"position": 0,
|
||||
"picture": null,
|
||||
|
||||
@@ -910,6 +910,7 @@ Creating orders
|
||||
* ``valid_from`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product)
|
||||
* ``valid_until`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product)
|
||||
* ``requested_valid_from`` (optional, can be set **instead** of ``valid_from`` and ``valid_until`` to signal a user choice for the start time that may or may not be respected)
|
||||
* ``use_reusable_medium`` (optional, causes the new ticket to take over the given reusable medium, identified by its ID)
|
||||
* ``answers``
|
||||
|
||||
* ``question``
|
||||
|
||||
@@ -157,6 +157,7 @@ information about the properties.
|
||||
{
|
||||
"value": "calendar",
|
||||
"label": "Default overview style",
|
||||
"readonly": false,
|
||||
"help_text": "If your event series has more than 50 dates in the future, only the month or week calendar can be used."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -63,6 +63,7 @@ valid_date_max date Maximum value f
|
||||
valid_datetime_min datetime Minimum value for date and time questions (optional)
|
||||
valid_datetime_max datetime Maximum value for date and time questions (optional)
|
||||
valid_file_portrait boolean Turn on file validation for portrait photos
|
||||
valid_string_length_max integer Maximum length for string questions (optional)
|
||||
dependency_question integer Internal ID of a different question. The current
|
||||
question will only be shown if the question given in
|
||||
this attribute is set to the value given in
|
||||
@@ -122,6 +123,7 @@ Endpoints
|
||||
"valid_date_max": null,
|
||||
"valid_datetime_min": null,
|
||||
"valid_datetime_max": null,
|
||||
"valid_string_length_max": null,
|
||||
"valid_file_portrait": false,
|
||||
"dependency_question": null,
|
||||
"dependency_value": null,
|
||||
@@ -201,6 +203,7 @@ Endpoints
|
||||
"valid_datetime_min": null,
|
||||
"valid_datetime_max": null,
|
||||
"valid_file_portrait": false,
|
||||
"valid_string_length_max": null,
|
||||
"dependency_question": null,
|
||||
"dependency_value": null,
|
||||
"dependency_values": [],
|
||||
@@ -302,6 +305,7 @@ Endpoints
|
||||
"valid_datetime_min": null,
|
||||
"valid_datetime_max": null,
|
||||
"valid_file_portrait": false,
|
||||
"valid_string_length_max": null,
|
||||
"options": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -384,6 +388,7 @@ Endpoints
|
||||
"valid_datetime_min": null,
|
||||
"valid_datetime_max": null,
|
||||
"valid_file_portrait": false,
|
||||
"valid_string_length_max": null,
|
||||
"options": [
|
||||
{
|
||||
"id": 1,
|
||||
|
||||
317
doc/api/resources/reusablemedia.rst
Normal file
317
doc/api/resources/reusablemedia.rst
Normal file
@@ -0,0 +1,317 @@
|
||||
.. _`rest-reusablemedia`:
|
||||
|
||||
Reusable media
|
||||
==============
|
||||
|
||||
Reusable media represent things, typically physical tokens like plastic cards or NFC wristbands, which can represent
|
||||
other entities inside the system. For example, a medium can link to an order position or to a gift card and can be used
|
||||
in their place. Later, the medium might be reused for a different ticket.
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
The reusable medium resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the medium
|
||||
type string Type of medium, e.g. ``"barcode"`` or ``"nfc_uid"``.
|
||||
identifier string Unique identifier of the medium. The format depends on the ``type``.
|
||||
active boolean Whether this medium may be used.
|
||||
created datetime Date of creation
|
||||
updated datetime Date of last modification
|
||||
expires datetime Expiry date (or ``null``)
|
||||
customer string Identifier of a customer account this medium belongs to.
|
||||
linked_orderposition integer Internal ID of a ticket this medium is linked to.
|
||||
linked_giftcard integer Internal ID of a gift card this medium is linked to.
|
||||
info object Additional data, content depends on the ``type``. Consider
|
||||
this internal to the system and don't use it for your own data.
|
||||
notes string Internal notes and comments (or ``null``)
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
Existing media types are:
|
||||
|
||||
- ``barcode``
|
||||
- ``nfc_uid``
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/reusablemedia/
|
||||
|
||||
Returns a list of all media issued by a given organizer.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/reusablemedia/ 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,
|
||||
"identifier": "ABCDEFGH",
|
||||
"created": "2021-04-06T13:44:22.809377Z",
|
||||
"updated": "2021-04-06T13:44:22.809377Z",
|
||||
"type": "barcode",
|
||||
"active": True,
|
||||
"expires": None,
|
||||
"customer": None,
|
||||
"linked_orderposition": None,
|
||||
"linked_giftcard": None,
|
||||
"notes": None,
|
||||
"info": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1.
|
||||
:query string identifier: Only show media with the given identifier. Note that you should use the lookup endpoint described below for most use cases.
|
||||
:query string type: Only show media with the given type.
|
||||
:query boolean active: Only show media that are (not) active.
|
||||
:query string customer: Only show media linked to the given customer.
|
||||
:query string created_since: Only show media created since a given date.
|
||||
:query string updated_since: Only show media updated since a given date.
|
||||
:query integer linked_orderposition: Only show media linked to the given ticket.
|
||||
:query integer linked_giftcard: Only show media linked to the given gift card.
|
||||
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
|
||||
field will be shown as a nested value instead of just an ID. The nested objects are identical to
|
||||
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
|
||||
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
|
||||
can be given multiple times.
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/reusablemedia/(id)/
|
||||
|
||||
Returns information on one medium, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/reusablemedia/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,
|
||||
"identifier": "ABCDEFGH",
|
||||
"created": "2021-04-06T13:44:22.809377Z",
|
||||
"updated": "2021-04-06T13:44:22.809377Z",
|
||||
"type": "barcode",
|
||||
"active": True,
|
||||
"expires": None,
|
||||
"customer": None,
|
||||
"linked_orderposition": None,
|
||||
"linked_giftcard": None,
|
||||
"notes": None,
|
||||
"info": {}
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param id: The ``id`` field of the medium to fetch
|
||||
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
|
||||
field will be shown as a nested value instead of just an ID. The nested objects are identical to
|
||||
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
|
||||
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
|
||||
can be given multiple times.
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/reusablemedia/lookup/
|
||||
|
||||
Look up a new reusable medium by its identifier. In some cases, this might lead to the automatic creation of a new
|
||||
medium behind the scenes.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/reusablemedia/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"identifier": "ABCDEFGH",
|
||||
"type": "barcode",
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"identifier": "ABCDEFGH",
|
||||
"created": "2021-04-06T13:44:22.809377Z",
|
||||
"updated": "2021-04-06T13:44:22.809377Z",
|
||||
"type": "barcode",
|
||||
"active": True,
|
||||
"expires": None,
|
||||
"customer": None,
|
||||
"linked_orderposition": None,
|
||||
"linked_giftcard": None,
|
||||
"notes": None,
|
||||
"info": {}
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to look up a medium for
|
||||
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
|
||||
field will be shown as a nested value instead of just an ID. The nested objects are identical to
|
||||
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
|
||||
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
|
||||
can be given multiple times.
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The medium could not be looked up due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/reusablemedia/
|
||||
|
||||
Creates a new reusable medium.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/reusablemedia/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"identifier": "ABCDEFGH",
|
||||
"type": "barcode",
|
||||
"active": True,
|
||||
"expires": None,
|
||||
"customer": None,
|
||||
"linked_orderposition": None,
|
||||
"linked_giftcard": None,
|
||||
"notes": None,
|
||||
"info": {}
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"identifier": "ABCDEFGH",
|
||||
"created": "2021-04-06T13:44:22.809377Z",
|
||||
"updated": "2021-04-06T13:44:22.809377Z",
|
||||
"type": "barcode",
|
||||
"active": True,
|
||||
"expires": None,
|
||||
"customer": None,
|
||||
"linked_orderposition": None,
|
||||
"linked_giftcard": None,
|
||||
"notes": None,
|
||||
"info": {}
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create a medium for
|
||||
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
|
||||
field will be shown as a nested value instead of just an ID. The nested objects are identical to
|
||||
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
|
||||
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
|
||||
can be given multiple times.
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The medium could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/reusablemedia/(id)/
|
||||
|
||||
Update a reusable medium. 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.
|
||||
|
||||
You can change all fields of the resource except the ``id``, ``identifier`` and ``type`` fields.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/reusablemedia/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"linked_orderposition": 13
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"identifier": "ABCDEFGH",
|
||||
"created": "2021-04-06T13:44:22.809377Z",
|
||||
"updated": "2021-04-06T13:44:22.809377Z",
|
||||
"type": "barcode",
|
||||
"active": True,
|
||||
"expires": None,
|
||||
"customer": None,
|
||||
"linked_orderposition": 13,
|
||||
"linked_giftcard": None,
|
||||
"notes": None,
|
||||
"info": {}
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param id: The ``id`` field of the medium to modify
|
||||
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
|
||||
field will be shown as a nested value instead of just an ID. The nested objects are identical to
|
||||
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
|
||||
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
|
||||
can be given multiple times.
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The medium could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
|
||||
@@ -26,6 +26,7 @@ can_create_events boolean
|
||||
can_change_teams boolean
|
||||
can_change_organizer_settings boolean
|
||||
can_manage_customers boolean
|
||||
can_manage_reusable_media boolean
|
||||
can_manage_gift_cards boolean
|
||||
can_change_event_settings boolean
|
||||
can_change_items boolean
|
||||
@@ -36,6 +37,10 @@ can_change_vouchers boolean
|
||||
can_checkin_orders boolean
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: 4.18
|
||||
|
||||
The ``can_manage_reusable_media`` permission has been added.
|
||||
|
||||
Team member resource
|
||||
--------------------
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ The following values for ``action_types`` are valid with pretix core:
|
||||
* ``pretix.event.order.refund.done``
|
||||
* ``pretix.event.order.refund.canceled``
|
||||
* ``pretix.event.order.refund.failed``
|
||||
* ``pretix.event.order.payment.confirmed``
|
||||
* ``pretix.event.order.approved``
|
||||
* ``pretix.event.order.denied``
|
||||
* ``pretix.event.checkin``
|
||||
|
||||
@@ -58,11 +58,11 @@ If you do not have a recent installation of ``nodejs``, install it now::
|
||||
|
||||
To make sure it is on your path variable, close and reopen your terminal. Now, install the Python-level dependencies of pretix::
|
||||
|
||||
cd src/
|
||||
pip3 install -e ".[dev]"
|
||||
|
||||
Next, you need to copy the SCSS files from the source folder to the STATIC_ROOT directory::
|
||||
|
||||
cd src/
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
Then, create the local database::
|
||||
@@ -150,6 +150,13 @@ Add this to your ``src/pretix.cfg``::
|
||||
|
||||
Then execute ``python -m smtpd -n -c DebuggingServer localhost:1025``.
|
||||
|
||||
Working with periodic tasks
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
Periodic tasks (like sendmail rules) are run when an external scheduler (like cron)
|
||||
triggers the ``runperiodic`` command.
|
||||
|
||||
To run periodic tasks, execute ``python manage.py runperiodic``.
|
||||
|
||||
Working with translations
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
If you want to translate new strings that are not yet known to the translation system,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 180 KiB |
@@ -38,27 +38,27 @@ else
|
||||
endif
|
||||
|
||||
"Is the order in status PAID or PENDING\nand is the position not canceled?" --> if "" then
|
||||
-right->[no] "Return error CANCELED"
|
||||
-right->[no && !force] "Return error CANCELED"
|
||||
else
|
||||
-down->[yes] "Is one or more block set on the ticket?"
|
||||
-down->[yes || force] "Is one or more block set on the ticket?"
|
||||
--> if "" then
|
||||
-right->[no] "Return error BLOCKED"
|
||||
-right->[no && !force] "Return error BLOCKED"
|
||||
else
|
||||
-down->[yes] "If this is not an exit, is the valid_from/valid_until\nconstraint on the ticket fulfilled?"
|
||||
-down->[yes || force] "If this is not an exit, is the valid_from/valid_until\nconstraint on the ticket fulfilled?"
|
||||
--> if "" then
|
||||
-right->[no] "Return error INVALID_TIME"
|
||||
-right->[no && !force] "Return error INVALID_TIME"
|
||||
else
|
||||
-down->[yes] "Is the product part of the check-in list?"
|
||||
-down->[yes || force] "Is the product part of the check-in list?"
|
||||
--> if "" then
|
||||
-right->[no] "Return error PRODUCT"
|
||||
-right->[no && !force] "Return error PRODUCT"
|
||||
else
|
||||
-down->[yes] "Is the subevent part of the check-in list?"
|
||||
-down->[yes || force] "Is the subevent part of the check-in list?"
|
||||
--> if "" then
|
||||
-right->[no] "Return error PRODUCT "
|
||||
-right->[no && !force] "Return error PRODUCT "
|
||||
else
|
||||
-down->[yes] "Is the order in status PAID\nor is this a forced upload?"
|
||||
-down->[yes] "Is the order in status PAID?"
|
||||
--> if "" then
|
||||
-right->[no] "Is Order.require_approval set?"
|
||||
-right->[no && !force] "Is Order.require_approval set?"
|
||||
--> if "" then
|
||||
-->[no] "Is Order.valid_if_pending set?"
|
||||
--> if "" then
|
||||
@@ -80,7 +80,7 @@ else
|
||||
-->[yes] "Return error UNPAID "
|
||||
endif
|
||||
else
|
||||
-down->[yes] "Is this an entry or exit?\nIs the upload forced?"
|
||||
-down->[yes || force] "Is this an entry or exit?\nIs the upload forced?"
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
sphinx==6.1.*
|
||||
sphinx==6.2.*
|
||||
jinja2==3.1.*
|
||||
sphinx-rtd-theme
|
||||
sphinxcontrib-httpdomain
|
||||
sphinxcontrib-images
|
||||
sphinxcontrib-jquery
|
||||
sphinxcontrib-spelling==7.*
|
||||
sphinxcontrib-spelling==8.*
|
||||
sphinxemoji
|
||||
pygments-markdown-lexer
|
||||
pyenchant==3.2.*
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
-e ../src/
|
||||
sphinx==6.1.*
|
||||
-e ../
|
||||
sphinx==6.2.*
|
||||
jinja2==3.1.*
|
||||
sphinx-rtd-theme
|
||||
sphinxcontrib-httpdomain
|
||||
sphinxcontrib-images
|
||||
sphinxcontrib-jquery
|
||||
sphinxcontrib-spelling==7.*
|
||||
sphinxcontrib-spelling==8.*
|
||||
sphinxemoji
|
||||
pygments-markdown-lexer
|
||||
pyenchant==3.2.*
|
||||
|
||||
@@ -318,7 +318,10 @@ Currently, the following attributes are understood by pretix itself:
|
||||
|
||||
* If ``data-consent="…"`` is given, the cookie consent mechanism will be initialized with consent for the given cookie
|
||||
providers. All other providers will be disabled, no consent dialog will be shown. This is useful if you already
|
||||
asked the user for consent and don't want them to be asked again. Example: ``data-consent="facebook,google_analytics"``
|
||||
asked the user for consent and don't want them to be asked again. Example: ``data-consent="facebook,google_analytics"``
|
||||
|
||||
When using the pretix-tracking plugin, the following values are supported::
|
||||
``adform, facebook, gosquared, google_ads, google_analytics, hubspot, linkedin, matomo, twitter``
|
||||
|
||||
Any configured pretix plugins might understand more data fields. For example, if the appropriate plugins on pretix
|
||||
Hosted or pretix Enterprise are active, you can pass the following fields:
|
||||
|
||||
186
pyproject.toml
Normal file
186
pyproject.toml
Normal file
@@ -0,0 +1,186 @@
|
||||
[project]
|
||||
name = "pretix"
|
||||
dynamic = ["version"]
|
||||
description = "Reinventing presales, one ticket at a time"
|
||||
readme = "README.rst"
|
||||
requires-python = ">=3.9"
|
||||
license = {file = "LICENSE"}
|
||||
keywords = ["tickets", "web", "shop", "ecommerce"]
|
||||
authors = [
|
||||
{name = "pretix team", email = "support@pretix.eu"},
|
||||
]
|
||||
maintainers = [
|
||||
{name = "pretix team", email = "support@pretix.eu"},
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Other Audience",
|
||||
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
||||
"Environment :: Web Environment",
|
||||
"License :: OSI Approved :: GNU Affero General Public License v3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Framework :: Django :: 3.2",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
# Note that many of these are repeated as build-time dependencies down below -- change them too in case of updates!
|
||||
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab
|
||||
"babel",
|
||||
"BeautifulSoup4==4.12.*",
|
||||
"bleach==5.0.*",
|
||||
"celery==5.2.*",
|
||||
"chardet==5.1.*",
|
||||
"cryptography>=3.4.2",
|
||||
"css-inline==0.8.*",
|
||||
"defusedcsv>=1.1.0",
|
||||
"dj-static",
|
||||
"Django==3.2.*,>=3.2.18",
|
||||
"django-bootstrap3==23.1.*",
|
||||
"django-compressor==4.3.*",
|
||||
"django-countries==7.5.*",
|
||||
"django-filter==23.1",
|
||||
"django-formset-js-improved==0.5.0.3",
|
||||
"django-formtools==2.4",
|
||||
"django-hierarkey==1.1.*",
|
||||
"django-hijack==3.3.*",
|
||||
"django-i18nfield==1.9.*,>=1.9.4",
|
||||
"django-libsass==0.9",
|
||||
"django-localflavor==4.0",
|
||||
"django-markup",
|
||||
"django-mysql",
|
||||
"django-oauth-toolkit==2.2.*",
|
||||
"django-otp==1.1.*",
|
||||
"django-phonenumber-field==7.0.*",
|
||||
"django-redis==5.2.*",
|
||||
"django-scopes==1.2.*",
|
||||
"django-statici18n==2.3.*",
|
||||
"djangorestframework==3.14.*",
|
||||
"dnspython==2.2.*",
|
||||
"drf_ujson2==1.7.*",
|
||||
"geoip2==4.*",
|
||||
"importlib_metadata==6.6.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
||||
"idna",
|
||||
"isoweek",
|
||||
"jsonschema",
|
||||
"kombu==5.2.*",
|
||||
"libsass==0.22.*",
|
||||
"lxml",
|
||||
"markdown==3.4.3", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
|
||||
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
|
||||
"mt-940==4.23.*",
|
||||
"oauthlib==3.2.*",
|
||||
"openpyxl==3.1.*",
|
||||
"packaging",
|
||||
"paypalrestsdk==1.13.*",
|
||||
"paypal-checkout-serversdk==1.0.*",
|
||||
"PyJWT==2.6.*",
|
||||
"phonenumberslite==8.13.*",
|
||||
"Pillow==9.5.*",
|
||||
"protobuf==4.22.*",
|
||||
"psycopg2-binary",
|
||||
"pycountry",
|
||||
"pycparser==2.21",
|
||||
"pycryptodome==3.17.*",
|
||||
"pypdf==3.8.*",
|
||||
"python-bidi==0.4.*", # Support for Arabic in reportlab
|
||||
"python-dateutil==2.8.*",
|
||||
"python-u2flib-server==4.*",
|
||||
"pytz",
|
||||
"pyuca",
|
||||
"qrcode==7.4.*",
|
||||
"redis==4.5.*,>=4.5.4",
|
||||
"reportlab==3.6.*",
|
||||
"requests==2.28.*",
|
||||
"sentry-sdk==1.15.*",
|
||||
"sepaxml==2.6.*",
|
||||
"slimit",
|
||||
"static3==0.7.*",
|
||||
"stripe==5.4.*",
|
||||
"text-unidecode==1.*",
|
||||
"tlds>=2020041600",
|
||||
"tqdm==4.*",
|
||||
"vat_moss_forked==2020.3.20.0.11.0",
|
||||
"vobject==0.9.*",
|
||||
"webauthn==0.4.*",
|
||||
"zeep==4.2.*"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
memcached = ["pylibmc"]
|
||||
mysql = ["mysqlclient"]
|
||||
dev = [
|
||||
"coverage",
|
||||
"coveralls",
|
||||
"django-debug-toolbar==4.0.*",
|
||||
"django-formset-js-improved==0.5.0.3",
|
||||
"django-oauth-toolkit==2.2.*",
|
||||
"flake8==6.0.*",
|
||||
"freezegun",
|
||||
"isort==5.12.*",
|
||||
"oauthlib==3.2.*",
|
||||
"pep8-naming==0.13.*",
|
||||
"potypo",
|
||||
"pycodestyle==2.10.*",
|
||||
"pyflakes==3.0.*",
|
||||
"pytest-cache",
|
||||
"pytest-cov",
|
||||
"pytest-django==4.*",
|
||||
"pytest-mock==3.10.*",
|
||||
"pytest-rerunfailures==11.*",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist==3.2.*",
|
||||
"pytest==7.3.*",
|
||||
"responses",
|
||||
]
|
||||
|
||||
[project.entry-points."distutils.commands"]
|
||||
build = "pretix._build:CustomBuild"
|
||||
build_ext = "pretix._build:CustomBuildExt"
|
||||
|
||||
[build-system]
|
||||
requires = [
|
||||
"setuptools",
|
||||
"setuptools-rust",
|
||||
"wheel",
|
||||
"importlib_metadata",
|
||||
|
||||
# These are runtime dependencies that we unfortunately need to be import in the step that generates
|
||||
# all CSS and JS asset files. We should keep their versions in sync with the definition above.
|
||||
"babel",
|
||||
"Django==3.2.*,>=3.2.18",
|
||||
"django-bootstrap3==23.1.*",
|
||||
"django-compressor==4.3.*",
|
||||
"django-countries==7.5.*",
|
||||
"django-formtools==2.4",
|
||||
"django-hierarkey==1.1.*",
|
||||
"django-i18nfield==1.9.*,>=1.9.4",
|
||||
"django-libsass==0.9",
|
||||
"django-phonenumber-field==7.0.*",
|
||||
"django-statici18n==2.3.*",
|
||||
"djangorestframework==3.14.*",
|
||||
"libsass==0.22.*",
|
||||
"phonenumberslite==8.13.*",
|
||||
"pycountry",
|
||||
"pyuca",
|
||||
"slimit",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
homepage = "https://pretix.eu"
|
||||
documentation = "https://docs.pretix.eu"
|
||||
repository = "https://github.com/pretix/pretix.git"
|
||||
changelog = "https://pretix.eu/about/en/blog/"
|
||||
|
||||
[tool.setuptools]
|
||||
include-package-data = true
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = {attr = "pretix.__version__"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
include = ["pretix*"]
|
||||
namespaces = false
|
||||
40
setup.cfg
Normal file
40
setup.cfg
Normal file
@@ -0,0 +1,40 @@
|
||||
[check-manifest]
|
||||
ignore =
|
||||
env/**
|
||||
doc/*
|
||||
deployment/*
|
||||
res/*
|
||||
src/.update-locales
|
||||
src/Makefile
|
||||
src/manage.py
|
||||
src/pretix/icons/*
|
||||
src/pretix/static.dist/**
|
||||
src/pretix/static/jsi18n/**
|
||||
src/requirements.txt
|
||||
src/requirements/*
|
||||
src/tests/*
|
||||
src/tests/api/*
|
||||
src/tests/base/*
|
||||
src/tests/control/*
|
||||
src/tests/testdummy/*
|
||||
src/tests/templates/*
|
||||
src/tests/presale/*
|
||||
src/tests/doc/*
|
||||
src/tests/helpers/*
|
||||
src/tests/media/*
|
||||
src/tests/multidomain/*
|
||||
src/tests/plugins/*
|
||||
src/tests/plugins/badges/*
|
||||
src/tests/plugins/banktransfer/*
|
||||
src/tests/plugins/paypal/*
|
||||
src/tests/plugins/paypal2/*
|
||||
src/tests/plugins/pretixdroid/*
|
||||
src/tests/plugins/stripe/*
|
||||
src/tests/plugins/sendmail/*
|
||||
src/tests/plugins/ticketoutputpdf/*
|
||||
.*
|
||||
CODE_OF_CONDUCT.md
|
||||
CONTRIBUTING.md
|
||||
Dockerfile
|
||||
SECURITY.md
|
||||
|
||||
26
setup.py
Normal file
26
setup.py
Normal file
@@ -0,0 +1,26 @@
|
||||
#
|
||||
# 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 setuptools
|
||||
|
||||
if __name__ == "__main__":
|
||||
setuptools.setup()
|
||||
@@ -1,33 +0,0 @@
|
||||
include LICENSE
|
||||
include README.rst
|
||||
global-include *.proto
|
||||
recursive-include pretix/static *
|
||||
recursive-include pretix/static.dist *
|
||||
recursive-include pretix/locale *
|
||||
recursive-include pretix/helpers/locale *
|
||||
recursive-include pretix/base/templates *
|
||||
recursive-include pretix/control/templates *
|
||||
recursive-include pretix/presale/templates *
|
||||
recursive-include pretix/plugins/banktransfer/templates *
|
||||
recursive-include pretix/plugins/banktransfer/static *
|
||||
recursive-include pretix/plugins/manualpayment/templates *
|
||||
recursive-include pretix/plugins/manualpayment/static *
|
||||
recursive-include pretix/plugins/paypal/templates *
|
||||
recursive-include pretix/plugins/paypal/static *
|
||||
recursive-include pretix/plugins/paypal2/templates *
|
||||
recursive-include pretix/plugins/paypal2/static *
|
||||
recursive-include pretix/plugins/pretixdroid/templates *
|
||||
recursive-include pretix/plugins/pretixdroid/static *
|
||||
recursive-include pretix/plugins/sendmail/templates *
|
||||
recursive-include pretix/plugins/statistics/templates *
|
||||
recursive-include pretix/plugins/statistics/static *
|
||||
recursive-include pretix/plugins/stripe/templates *
|
||||
recursive-include pretix/plugins/stripe/static *
|
||||
recursive-include pretix/plugins/ticketoutputpdf/templates *
|
||||
recursive-include pretix/plugins/ticketoutputpdf/static *
|
||||
recursive-include pretix/plugins/badges/templates *
|
||||
recursive-include pretix/plugins/badges/static *
|
||||
recursive-include pretix/plugins/returnurl/templates *
|
||||
recursive-include pretix/plugins/returnurl/static *
|
||||
recursive-include pretix/plugins/webcheckin/templates *
|
||||
recursive-include pretix/plugins/webcheckin/static *
|
||||
@@ -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.19.0.dev0"
|
||||
__version__ = "4.20.0.dev0"
|
||||
|
||||
251
src/pretix/_base_settings.py
Normal file
251
src/pretix/_base_settings.py
Normal file
@@ -0,0 +1,251 @@
|
||||
#
|
||||
# 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 os
|
||||
|
||||
import django.conf.locale
|
||||
from pycountry import currencies
|
||||
|
||||
from django.utils.translation import gettext_lazy as _ # NOQA
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
|
||||
|
||||
USE_I18N = True
|
||||
USE_L10N = True
|
||||
USE_TZ = True
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.humanize',
|
||||
'pretix.base',
|
||||
'pretix.control',
|
||||
'pretix.presale',
|
||||
'pretix.multidomain',
|
||||
'pretix.api',
|
||||
'pretix.helpers',
|
||||
'rest_framework',
|
||||
'djangoformsetjs',
|
||||
'compressor',
|
||||
'bootstrap3',
|
||||
'pretix.plugins.banktransfer',
|
||||
'pretix.plugins.stripe',
|
||||
'pretix.plugins.paypal',
|
||||
'pretix.plugins.paypal2',
|
||||
'pretix.plugins.ticketoutputpdf',
|
||||
'pretix.plugins.sendmail',
|
||||
'pretix.plugins.statistics',
|
||||
'pretix.plugins.reports',
|
||||
'pretix.plugins.checkinlists',
|
||||
'pretix.plugins.pretixdroid',
|
||||
'pretix.plugins.badges',
|
||||
'pretix.plugins.manualpayment',
|
||||
'pretix.plugins.returnurl',
|
||||
'pretix.plugins.webcheckin',
|
||||
'django_countries',
|
||||
'oauth2_provider',
|
||||
'phonenumber_field',
|
||||
'statici18n',
|
||||
]
|
||||
|
||||
FORMAT_MODULE_PATH = [
|
||||
'pretix.helpers.formats',
|
||||
]
|
||||
|
||||
ALL_LANGUAGES = [
|
||||
('en', _('English')),
|
||||
('de', _('German')),
|
||||
('de-informal', _('German (informal)')),
|
||||
('ar', _('Arabic')),
|
||||
('zh-hans', _('Chinese (simplified)')),
|
||||
('cs', _('Czech')),
|
||||
('da', _('Danish')),
|
||||
('nl', _('Dutch')),
|
||||
('nl-informal', _('Dutch (informal)')),
|
||||
('fr', _('French')),
|
||||
('fi', _('Finnish')),
|
||||
('gl', _('Galician')),
|
||||
('el', _('Greek')),
|
||||
('it', _('Italian')),
|
||||
('lv', _('Latvian')),
|
||||
('pl', _('Polish')),
|
||||
('pt-pt', _('Portuguese (Portugal)')),
|
||||
('pt-br', _('Portuguese (Brazil)')),
|
||||
('ro', _('Romanian')),
|
||||
('ru', _('Russian')),
|
||||
('es', _('Spanish')),
|
||||
('tr', _('Turkish')),
|
||||
('uk', _('Ukrainian')),
|
||||
]
|
||||
LANGUAGES_OFFICIAL = {
|
||||
'en', 'de', 'de-informal'
|
||||
}
|
||||
LANGUAGES_RTL = {
|
||||
'ar', 'hw'
|
||||
}
|
||||
LANGUAGES_INCUBATING = {
|
||||
'pl', 'fi', 'pt-br', 'gl',
|
||||
}
|
||||
LOCALE_PATHS = [
|
||||
os.path.join(os.path.dirname(__file__), 'locale'),
|
||||
]
|
||||
|
||||
EXTRA_LANG_INFO = {
|
||||
'de-informal': {
|
||||
'bidi': False,
|
||||
'code': 'de-informal',
|
||||
'name': 'German (informal)',
|
||||
'name_local': 'Deutsch',
|
||||
'public_code': 'de',
|
||||
},
|
||||
'nl-informal': {
|
||||
'bidi': False,
|
||||
'code': 'nl-informal',
|
||||
'name': 'Dutch (informal)',
|
||||
'name_local': 'Nederlands',
|
||||
'public_code': 'nl',
|
||||
},
|
||||
'fr': {
|
||||
'bidi': False,
|
||||
'code': 'fr',
|
||||
'name': 'French',
|
||||
'name_local': 'Français'
|
||||
},
|
||||
'lv': {
|
||||
'bidi': False,
|
||||
'code': 'lv',
|
||||
'name': 'Latvian',
|
||||
'name_local': 'Latviešu'
|
||||
},
|
||||
'pt-pt': {
|
||||
'bidi': False,
|
||||
'code': 'pt-pt',
|
||||
'name': 'Portuguese',
|
||||
'name_local': 'Português',
|
||||
},
|
||||
}
|
||||
|
||||
django.conf.locale.LANG_INFO.update(EXTRA_LANG_INFO)
|
||||
|
||||
template_loaders = (
|
||||
'django.template.loaders.filesystem.Loader',
|
||||
'pretix.helpers.template_loaders.AppLoader',
|
||||
)
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [
|
||||
os.path.join(BASE_DIR, 'templates'),
|
||||
],
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.i18n',
|
||||
'django.template.context_processors.media',
|
||||
"django.template.context_processors.request",
|
||||
'django.template.context_processors.static',
|
||||
'django.template.context_processors.tz',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'pretix.base.context.contextprocessor',
|
||||
'pretix.control.context.contextprocessor',
|
||||
'pretix.presale.context.contextprocessor',
|
||||
],
|
||||
'loaders': template_loaders
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
STATIC_ROOT = os.path.join(os.path.dirname(__file__), 'static.dist')
|
||||
|
||||
STATICFILES_FINDERS = (
|
||||
'django.contrib.staticfiles.finders.FileSystemFinder',
|
||||
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
|
||||
'compressor.finders.CompressorFinder',
|
||||
)
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
os.path.join(BASE_DIR, 'pretix/static')
|
||||
] if os.path.exists(os.path.join(BASE_DIR, 'pretix/static')) else []
|
||||
|
||||
STATICI18N_ROOT = os.path.join(BASE_DIR, "pretix/static")
|
||||
|
||||
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
|
||||
|
||||
# if os.path.exists(os.path.join(DATA_DIR, 'static')):
|
||||
# STATICFILES_DIRS.insert(0, os.path.join(DATA_DIR, 'static'))
|
||||
|
||||
COMPRESS_PRECOMPILERS = (
|
||||
('text/x-scss', 'django_libsass.SassCompiler'),
|
||||
('text/vue', 'pretix.helpers.compressor.VueCompiler'),
|
||||
)
|
||||
|
||||
COMPRESS_OFFLINE_CONTEXT = {
|
||||
'basetpl': 'empty.html',
|
||||
}
|
||||
|
||||
COMPRESS_ENABLED = True
|
||||
COMPRESS_OFFLINE = True
|
||||
|
||||
COMPRESS_FILTERS = {
|
||||
'css': (
|
||||
# CssAbsoluteFilter is incredibly slow, especially when dealing with our _flags.scss
|
||||
# However, we don't need it if we consequently use the static() function in Sass
|
||||
# 'compressor.filters.css_default.CssAbsoluteFilter',
|
||||
'compressor.filters.cssmin.rCSSMinFilter',
|
||||
),
|
||||
'js': (
|
||||
'compressor.filters.jsmin.JSMinFilter',
|
||||
)
|
||||
}
|
||||
|
||||
CURRENCIES = list(currencies)
|
||||
CURRENCY_PLACES = {
|
||||
# default is 2
|
||||
'BIF': 0,
|
||||
'CLP': 0,
|
||||
'DJF': 0,
|
||||
'GNF': 0,
|
||||
'JPY': 0,
|
||||
'KMF': 0,
|
||||
'KRW': 0,
|
||||
'MGA': 0,
|
||||
'PYG': 0,
|
||||
'RWF': 0,
|
||||
'VND': 0,
|
||||
'VUV': 0,
|
||||
'XAF': 0,
|
||||
'XOF': 0,
|
||||
'XPF': 0,
|
||||
}
|
||||
|
||||
PRETIX_EMAIL_NONE_VALUE = 'none@well-known.pretix.eu'
|
||||
PRETIX_PRIMARY_COLOR = '#8E44B3'
|
||||
|
||||
# pretix includes caching options for some special situations where full HTML responses are cached. This might be
|
||||
# stressful for some cache setups so it is enabled by default and currently can't be enabled through pretix.cfg
|
||||
CACHE_LARGE_VALUES_ALLOWED = False
|
||||
CACHE_LARGE_VALUES_ALIAS = 'default'
|
||||
74
src/pretix/_build.py
Normal file
74
src/pretix/_build.py
Normal file
@@ -0,0 +1,74 @@
|
||||
#
|
||||
# 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 os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from setuptools.command.build import build
|
||||
from setuptools.command.build_ext import build_ext
|
||||
|
||||
here = os.path.abspath(os.path.dirname(__file__))
|
||||
npm_installed = False
|
||||
|
||||
|
||||
def npm_install():
|
||||
global npm_installed
|
||||
|
||||
if not npm_installed:
|
||||
# keep this in sync with Makefile!
|
||||
node_prefix = os.path.join(here, 'static.dist', 'node_prefix')
|
||||
os.makedirs(node_prefix, exist_ok=True)
|
||||
shutil.copytree(os.path.join(here, 'static', 'npm_dir'), node_prefix, dirs_exist_ok=True)
|
||||
subprocess.check_call('npm install', shell=True, cwd=node_prefix)
|
||||
npm_installed = True
|
||||
|
||||
|
||||
class CustomBuild(build):
|
||||
def run(self):
|
||||
if "PRETIX_DOCKER_BUILD" in os.environ:
|
||||
return # this is a hack to allow calling this file early in our docker build to make use of caching
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pretix._build_settings")
|
||||
os.environ.setdefault("PRETIX_IGNORE_CONFLICTS", "True")
|
||||
import django
|
||||
django.setup()
|
||||
from django.conf import settings
|
||||
from django.core import management
|
||||
|
||||
settings.COMPRESS_ENABLED = True
|
||||
settings.COMPRESS_OFFLINE = True
|
||||
|
||||
npm_install()
|
||||
management.call_command('compilemessages', verbosity=1)
|
||||
management.call_command('compilejsi18n', verbosity=1)
|
||||
management.call_command('collectstatic', verbosity=1, interactive=False)
|
||||
management.call_command('compress', verbosity=1)
|
||||
|
||||
build.run(self)
|
||||
|
||||
|
||||
class CustomBuildExt(build_ext):
|
||||
def run(self):
|
||||
if "PRETIX_DOCKER_BUILD" in os.environ:
|
||||
return # this is a hack to allow calling this file early in our docker build to make use of caching
|
||||
npm_install()
|
||||
build_ext.run(self)
|
||||
48
src/pretix/_build_settings.py
Normal file
48
src/pretix/_build_settings.py
Normal file
@@ -0,0 +1,48 @@
|
||||
#
|
||||
# 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 contains settings that we need at wheel require time. All settings that we only need at runtime are set
|
||||
in settings.py.
|
||||
"""
|
||||
from ._base_settings import * # NOQA
|
||||
|
||||
ENTROPY = {
|
||||
'order_code': 5,
|
||||
'customer_identifier': 7,
|
||||
'ticket_secret': 32,
|
||||
'voucher_code': 16,
|
||||
'giftcard_secret': 12,
|
||||
}
|
||||
|
||||
MAIL_FROM_ORGANIZERS = 'invalid@invalid'
|
||||
FILE_UPLOAD_MAX_SIZE_EMAIL_AUTO_ATTACHMENT = 10
|
||||
FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT = 10
|
||||
FILE_UPLOAD_MAX_SIZE_IMAGE = 10
|
||||
DEFAULT_CURRENCY = 'EUR'
|
||||
SECRET_KEY = "build-time-secret-key"
|
||||
HAS_REDIS = False
|
||||
STATIC_URL = '/static/'
|
||||
HAS_MEMCACHED = False
|
||||
HAS_CELERY = False
|
||||
HAS_GEOIP = False
|
||||
SENTRY_ENABLED = False
|
||||
@@ -81,6 +81,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
|
||||
('POST', 'api-v1:upload'),
|
||||
('POST', 'api-v1:checkinrpc.redeem'),
|
||||
('GET', 'api-v1:checkinrpc.search'),
|
||||
('GET', 'api-v1:reusablemedium-list'),
|
||||
)
|
||||
|
||||
|
||||
@@ -220,6 +221,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
|
||||
('POST', 'api-v1:upload'),
|
||||
('POST', 'api-v1:checkinrpc.redeem'),
|
||||
('GET', 'api-v1:checkinrpc.search'),
|
||||
('POST', 'api-v1:reusablemedium-lookup'),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from rest_framework.exceptions import ValidationError
|
||||
from pretix.api.serializers.event import SubEventSerializer
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.media import MEDIA_TYPES
|
||||
from pretix.base.models import Checkin, CheckinList
|
||||
|
||||
|
||||
@@ -84,6 +85,7 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer):
|
||||
lists = serializers.PrimaryKeyRelatedField(required=True, many=True, queryset=CheckinList.objects.none())
|
||||
secret = serializers.CharField(required=True, allow_null=False)
|
||||
force = serializers.BooleanField(default=False, required=False)
|
||||
source_type = serializers.ChoiceField(choices=[(k, v) for k, v in MEDIA_TYPES.items()], default='barcode')
|
||||
type = serializers.ChoiceField(choices=Checkin.CHECKIN_TYPES, default=Checkin.TYPE_ENTRY)
|
||||
ignore_unpaid = serializers.BooleanField(default=False, required=False)
|
||||
questions_supported = serializers.BooleanField(default=True, required=False)
|
||||
|
||||
@@ -797,6 +797,21 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'logo_show_title',
|
||||
'og_image',
|
||||
'name_scheme',
|
||||
'reusable_media_active',
|
||||
'reusable_media_type_barcode',
|
||||
'reusable_media_type_barcode_identifier_length',
|
||||
'reusable_media_type_nfc_uid',
|
||||
'reusable_media_type_nfc_uid_autocreate_giftcard',
|
||||
'reusable_media_type_nfc_uid_autocreate_giftcard_currency',
|
||||
]
|
||||
readonly_fields = [
|
||||
# These are read-only since they are currently only settable on organizers, not events
|
||||
'reusable_media_active',
|
||||
'reusable_media_type_barcode',
|
||||
'reusable_media_type_barcode_identifier_length',
|
||||
'reusable_media_type_nfc_uid',
|
||||
'reusable_media_type_nfc_uid_autocreate_giftcard',
|
||||
'reusable_media_type_nfc_uid_autocreate_giftcard_currency',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -863,6 +878,8 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
||||
'invoice_address_from_tax_id',
|
||||
'invoice_address_from_vat_id',
|
||||
'name_scheme',
|
||||
'reusable_media_type_barcode',
|
||||
'reusable_media_type_nfc_uid',
|
||||
'system_question_order',
|
||||
]
|
||||
|
||||
|
||||
@@ -244,7 +244,8 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
'grant_membership_duration_like_event', 'grant_membership_duration_days',
|
||||
'grant_membership_duration_months', 'validity_mode', 'validity_fixed_from', 'validity_fixed_until',
|
||||
'validity_dynamic_duration_minutes', 'validity_dynamic_duration_hours', 'validity_dynamic_duration_days',
|
||||
'validity_dynamic_duration_months', 'validity_dynamic_start_choice', 'validity_dynamic_start_choice_day_limit')
|
||||
'validity_dynamic_duration_months', 'validity_dynamic_start_choice', 'validity_dynamic_start_choice_day_limit',
|
||||
'media_policy', 'media_type')
|
||||
read_only_fields = ('has_variations',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -263,6 +264,7 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
|
||||
Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order'))
|
||||
Item.clean_available(data.get('available_from'), data.get('available_until'))
|
||||
Item.clean_media_settings(self.context['event'], data.get('media_policy'), data.get('media_type'), data.get('issue_giftcard'))
|
||||
|
||||
if data.get('personalized') and not data.get('admission'):
|
||||
raise ValidationError(_('Only admission products can currently be personalized.'))
|
||||
@@ -440,7 +442,7 @@ class QuestionSerializer(I18nAwareModelSerializer):
|
||||
'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
|
||||
'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min',
|
||||
'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max',
|
||||
'valid_file_portrait')
|
||||
'valid_string_length_max', 'valid_file_portrait')
|
||||
|
||||
def validate_identifier(self, value):
|
||||
Question._clean_identifier(self.context['event'], value, self.instance)
|
||||
|
||||
128
src/pretix/api/serializers/media.py
Normal file
128
src/pretix/api/serializers/media.py
Normal file
@@ -0,0 +1,128 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.order import OrderPositionSerializer
|
||||
from pretix.api.serializers.organizer import (
|
||||
CustomerSerializer, GiftCardSerializer,
|
||||
)
|
||||
from pretix.base.models import Order, OrderPosition, ReusableMedium
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NestedOrderMiniSerializer(I18nAwareModelSerializer):
|
||||
event = serializers.SlugRelatedField(slug_field='slug', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ['code', 'event']
|
||||
|
||||
|
||||
class NestedOrderPositionSerializer(OrderPositionSerializer):
|
||||
order = NestedOrderMiniSerializer()
|
||||
|
||||
|
||||
class NestedGiftCardSerializer(GiftCardSerializer):
|
||||
|
||||
def to_representation(self, instance):
|
||||
d = super().to_representation(instance)
|
||||
if hasattr(instance, 'cached_value'):
|
||||
d['value'] = str(Decimal(instance.cached_value).quantize(Decimal("0.01")))
|
||||
else:
|
||||
d['value'] = str(Decimal(instance.value).quantize(Decimal("0.01")))
|
||||
return d
|
||||
|
||||
|
||||
class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'):
|
||||
self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True)
|
||||
else:
|
||||
self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
queryset=self.context['organizer'].issued_gift_cards.all()
|
||||
)
|
||||
|
||||
if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'):
|
||||
self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True)
|
||||
else:
|
||||
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']),
|
||||
)
|
||||
|
||||
if 'customer' in self.context['request'].query_params.getlist('expand'):
|
||||
self.fields['customer'] = CustomerSerializer(read_only=True)
|
||||
else:
|
||||
self.fields['customer'] = serializers.SlugRelatedField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
slug_field='identifier',
|
||||
queryset=self.context['organizer'].customers.all()
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
if 'type' in data and 'identifier' in data:
|
||||
qs = self.context['organizer'].reusable_media.filter(
|
||||
identifier=data['identifier'], type=data['type']
|
||||
)
|
||||
if self.instance:
|
||||
qs = qs.exclude(pk=self.instance.pk)
|
||||
if qs.exists():
|
||||
raise ValidationError(
|
||||
{'identifier': _('A medium with the same identifier and type already exists in your organizer account.')}
|
||||
)
|
||||
return data
|
||||
|
||||
class Meta:
|
||||
model = ReusableMedium
|
||||
fields = (
|
||||
'id',
|
||||
'created',
|
||||
'updated',
|
||||
'type',
|
||||
'identifier',
|
||||
'active',
|
||||
'expires',
|
||||
'customer',
|
||||
'linked_orderposition',
|
||||
'linked_giftcard',
|
||||
'info',
|
||||
'notes',
|
||||
)
|
||||
|
||||
|
||||
class MediaLookupInputSerializer(serializers.Serializer):
|
||||
type = serializers.CharField(required=True)
|
||||
identifier = serializers.CharField(required=True)
|
||||
@@ -33,6 +33,7 @@ from django.utils.encoding import force_str
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy
|
||||
from django_countries.fields import Country
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.relations import SlugRelatedField
|
||||
@@ -48,8 +49,8 @@ from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item,
|
||||
ItemVariation, Order, OrderPosition, Question, QuestionAnswer, Seat,
|
||||
SubEvent, TaxRule, Voucher,
|
||||
ItemVariation, Order, OrderPosition, Question, QuestionAnswer,
|
||||
ReusableMedium, Seat, SubEvent, TaxRule, Voucher,
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
|
||||
@@ -356,6 +357,9 @@ class PdfDataSerializer(serializers.Field):
|
||||
def to_representation(self, instance: OrderPosition):
|
||||
res = {}
|
||||
|
||||
if 'event' not in self.context:
|
||||
return {}
|
||||
|
||||
ev = instance.subevent or instance.order.event
|
||||
with language(instance.order.locale, instance.order.event.settings.region):
|
||||
# This needs to have some extra performance improvements to avoid creating hundreds of queries when
|
||||
@@ -784,13 +788,15 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
required=False, allow_null=True)
|
||||
country = CompatibleCountryField(source='*')
|
||||
requested_valid_from = serializers.DateTimeField(required=False, allow_null=True)
|
||||
use_reusable_medium = serializers.PrimaryKeyRelatedField(queryset=ReusableMedium.objects.none(),
|
||||
required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'company', 'street', 'zipcode', 'city', 'country', 'state', 'is_bundled',
|
||||
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher', 'valid_from', 'valid_until',
|
||||
'requested_valid_from')
|
||||
'requested_valid_from', 'use_reusable_medium')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -799,6 +805,9 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
v.required = False
|
||||
v.allow_blank = True
|
||||
v.allow_null = True
|
||||
with scopes_disabled():
|
||||
if 'use_reusable_medium' in self.fields:
|
||||
self.fields['use_reusable_medium'].queryset = ReusableMedium.objects.all()
|
||||
|
||||
def validate_secret(self, secret):
|
||||
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
|
||||
@@ -807,6 +816,13 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
return secret
|
||||
|
||||
def validate_use_reusable_medium(self, m):
|
||||
if m.organizer_id != self.context['event'].organizer_id:
|
||||
raise ValidationError(
|
||||
'The specified medium does not belong to this organizer.'
|
||||
)
|
||||
return m
|
||||
|
||||
def validate_item(self, item):
|
||||
if item.event != self.context['event']:
|
||||
raise ValidationError(
|
||||
@@ -1264,7 +1280,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
pos_data['attendee_name_parts'] = {
|
||||
'_legacy': attendee_name
|
||||
}
|
||||
pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers' and k != '_quotas'})
|
||||
pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers' and k != '_quotas' and k != 'use_reusable_medium'})
|
||||
if simulate:
|
||||
pos.order = order._wrapped
|
||||
else:
|
||||
@@ -1332,6 +1348,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
# Save instances
|
||||
for pos_data in positions_data:
|
||||
answers_data = pos_data.pop('answers', [])
|
||||
use_reusable_medium = pos_data.pop('use_reusable_medium', None)
|
||||
pos = pos_data['__instance']
|
||||
pos._calculate_tax()
|
||||
|
||||
@@ -1370,6 +1387,17 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
answ = pos.answers.create(**answ_data)
|
||||
answ.options.add(*options)
|
||||
|
||||
if use_reusable_medium:
|
||||
use_reusable_medium.linked_orderposition = pos
|
||||
use_reusable_medium.save(update_fields=['linked_orderposition'])
|
||||
use_reusable_medium.log_action(
|
||||
'pretix.reusable_medium.linked_orderposition.changed',
|
||||
data={
|
||||
'by_order': order.code,
|
||||
'linked_orderposition': pos.pk,
|
||||
}
|
||||
)
|
||||
|
||||
if not simulate:
|
||||
for cp in delete_cps:
|
||||
if cp.addon_to_id:
|
||||
|
||||
@@ -183,7 +183,7 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
'id', 'name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
|
||||
'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings',
|
||||
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
|
||||
'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers'
|
||||
'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers', 'can_manage_reusable_media'
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
@@ -333,6 +333,12 @@ class OrganizerSettingsSerializer(SettingsSerializer):
|
||||
'cookie_consent_dialog_text_secondary',
|
||||
'cookie_consent_dialog_button_yes',
|
||||
'cookie_consent_dialog_button_no',
|
||||
'reusable_media_active',
|
||||
'reusable_media_type_barcode',
|
||||
'reusable_media_type_barcode_identifier_length',
|
||||
'reusable_media_type_nfc_uid',
|
||||
'reusable_media_type_nfc_uid_autocreate_giftcard',
|
||||
'reusable_media_type_nfc_uid_autocreate_giftcard_currency',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -36,6 +36,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class SettingsSerializer(serializers.Serializer):
|
||||
default_fields = []
|
||||
readonly_fields = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.changed_data = []
|
||||
@@ -59,8 +60,13 @@ class SettingsSerializer(serializers.Serializer):
|
||||
f.parent = self
|
||||
self.fields[fname] = f
|
||||
|
||||
def validate(self, attrs):
|
||||
return {k: v for k, v in attrs.items() if k not in self.readonly_fields}
|
||||
|
||||
def update(self, instance: HierarkeyProxy, validated_data):
|
||||
for attr, value in validated_data.items():
|
||||
if attr in self.readonly_fields:
|
||||
continue
|
||||
if isinstance(value, FieldFile):
|
||||
# Delete old file
|
||||
fname = instance.get(attr, as_type=File)
|
||||
|
||||
@@ -42,9 +42,9 @@ from rest_framework import routers
|
||||
from pretix.api.views import cart
|
||||
|
||||
from .views import (
|
||||
checkin, device, discount, event, exporters, idempotency, item, oauth,
|
||||
order, organizer, shredders, upload, user, version, voucher, waitinglist,
|
||||
webhooks,
|
||||
checkin, device, discount, event, exporters, idempotency, item, media,
|
||||
oauth, order, organizer, shredders, upload, user, version, voucher,
|
||||
waitinglist, webhooks,
|
||||
)
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
@@ -59,6 +59,7 @@ orga_router.register(r'giftcards', organizer.GiftCardViewSet)
|
||||
orga_router.register(r'customers', organizer.CustomerViewSet)
|
||||
orga_router.register(r'memberships', organizer.MembershipViewSet)
|
||||
orga_router.register(r'membershiptypes', organizer.MembershipTypeViewSet)
|
||||
orga_router.register(r'reusablemedia', media.ReusableMediaViewSet)
|
||||
orga_router.register(r'teams', organizer.TeamViewSet)
|
||||
orga_router.register(r'devices', organizer.DeviceViewSet)
|
||||
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
|
||||
|
||||
@@ -59,7 +59,7 @@ from pretix.api.views.order import OrderPositionFilter
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition,
|
||||
Question, RevokedTicketSecret, TeamAPIToken,
|
||||
Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken,
|
||||
)
|
||||
from pretix.base.services.checkin import (
|
||||
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
|
||||
@@ -396,7 +396,7 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
|
||||
|
||||
def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce,
|
||||
untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported,
|
||||
legacy_url_support=False):
|
||||
source_type='barcode', legacy_url_support=False):
|
||||
if not checkinlists:
|
||||
raise ValidationError('No check-in list passed.')
|
||||
|
||||
@@ -422,6 +422,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
|
||||
common_checkin_args = dict(
|
||||
raw_barcode=raw_barcode,
|
||||
raw_source_type=source_type,
|
||||
type=checkin_type,
|
||||
list=checkinlists[0],
|
||||
datetime=datetime,
|
||||
@@ -433,7 +434,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
raw_barcode_for_checkin = None
|
||||
from_revoked_secret = False
|
||||
|
||||
# 1. Gather a list of positions that could be the one we looking fore, either from their ID, secret or
|
||||
# 1. Gather a list of positions that could be the one we looking for, either from their ID, secret or
|
||||
# parent secret
|
||||
queryset = _checkin_list_position_queryset(checkinlists, pdf_data=pdf_data, ignore_status=True, ignore_products=True).order_by(
|
||||
F('addon_to').asc(nulls_first=True)
|
||||
@@ -457,98 +458,111 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
|
||||
# 2. Handle the "nothing found" case: Either it's really a bogus secret that we don't know (-> error), or it
|
||||
# might be a revoked one that we actually know (-> error, but with better error message and logging and
|
||||
# with respecting the force option).
|
||||
# with respecting the force option), or it's a reusable medium (-> proceed with that)
|
||||
if not op_candidates:
|
||||
revoked_matches = list(RevokedTicketSecret.objects.filter(event_id__in=list_by_event.keys(), secret=raw_barcode))
|
||||
if len(revoked_matches) == 0:
|
||||
checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
|
||||
'datetime': datetime,
|
||||
'type': checkin_type,
|
||||
'list': checkinlists[0].pk,
|
||||
'barcode': raw_barcode,
|
||||
'searched_lists': [cl.pk for cl in checkinlists]
|
||||
}, user=user, auth=auth)
|
||||
try:
|
||||
media = ReusableMedium.objects.select_related('linked_orderposition').active().get(
|
||||
organizer_id=checkinlists[0].event.organizer_id,
|
||||
type=source_type,
|
||||
identifier=raw_barcode,
|
||||
linked_orderposition__isnull=False,
|
||||
)
|
||||
raw_barcode_for_checkin = raw_barcode
|
||||
except ReusableMedium.DoesNotExist:
|
||||
revoked_matches = list(
|
||||
RevokedTicketSecret.objects.filter(event_id__in=list_by_event.keys(), secret=raw_barcode))
|
||||
if len(revoked_matches) == 0:
|
||||
checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
|
||||
'datetime': datetime,
|
||||
'type': checkin_type,
|
||||
'list': checkinlists[0].pk,
|
||||
'barcode': raw_barcode,
|
||||
'searched_lists': [cl.pk for cl in checkinlists]
|
||||
}, user=user, auth=auth)
|
||||
|
||||
for cl in checkinlists:
|
||||
for k, s in cl.event.ticket_secret_generators.items():
|
||||
for cl in checkinlists:
|
||||
for k, s in cl.event.ticket_secret_generators.items():
|
||||
try:
|
||||
parsed = s.parse_secret(raw_barcode)
|
||||
common_checkin_args.update({
|
||||
'raw_item': parsed.item,
|
||||
'raw_variation': parsed.variation,
|
||||
'raw_subevent': parsed.subevent,
|
||||
})
|
||||
except:
|
||||
pass
|
||||
|
||||
Checkin.objects.create(
|
||||
position=None,
|
||||
successful=False,
|
||||
error_reason=Checkin.REASON_INVALID,
|
||||
**common_checkin_args,
|
||||
)
|
||||
|
||||
if force and legacy_url_support and isinstance(auth, Device):
|
||||
# There was a bug in libpretixsync: If you scanned a ticket in offline mode that was
|
||||
# valid at the time but no longer exists at time of upload, the device would retry to
|
||||
# upload the same scan over and over again. Since we can't update all devices quickly,
|
||||
# here's a dirty workaround to make it stop.
|
||||
try:
|
||||
parsed = s.parse_secret(raw_barcode)
|
||||
common_checkin_args.update({
|
||||
'raw_item': parsed.item,
|
||||
'raw_variation': parsed.variation,
|
||||
'raw_subevent': parsed.subevent,
|
||||
})
|
||||
except:
|
||||
brand = auth.software_brand
|
||||
ver = parse(auth.software_version)
|
||||
legacy_mode = (
|
||||
(brand == 'pretixSCANPROXY' and ver < parse('0.0.3')) or
|
||||
(brand == 'pretixSCAN Android' and ver < parse('1.11.2')) or
|
||||
(brand == 'pretixSCAN' and ver < parse('1.11.2'))
|
||||
)
|
||||
if legacy_mode:
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': Checkin.REASON_ALREADY_REDEEMED,
|
||||
'reason_explanation': None,
|
||||
'require_attention': False,
|
||||
'__warning': 'Compatibility hack active due to detected old pretixSCAN version',
|
||||
}, status=400)
|
||||
except: # we don't care e.g. about invalid version numbers
|
||||
pass
|
||||
|
||||
Checkin.objects.create(
|
||||
position=None,
|
||||
successful=False,
|
||||
error_reason=Checkin.REASON_INVALID,
|
||||
**common_checkin_args,
|
||||
)
|
||||
|
||||
if force and legacy_url_support and isinstance(auth, Device):
|
||||
# There was a bug in libpretixsync: If you scanned a ticket in offline mode that was
|
||||
# valid at the time but no longer exists at time of upload, the device would retry to
|
||||
# upload the same scan over and over again. Since we can't update all devices quickly,
|
||||
# here's a dirty workaround to make it stop.
|
||||
try:
|
||||
brand = auth.software_brand
|
||||
ver = parse(auth.software_version)
|
||||
legacy_mode = (
|
||||
(brand == 'pretixSCANPROXY' and ver < parse('0.0.3')) or
|
||||
(brand == 'pretixSCAN Android' and ver < parse('1.11.2')) or
|
||||
(brand == 'pretixSCAN' and ver < parse('1.11.2'))
|
||||
)
|
||||
if legacy_mode:
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': Checkin.REASON_ALREADY_REDEEMED,
|
||||
'reason_explanation': None,
|
||||
'require_attention': False,
|
||||
'__warning': 'Compatibility hack active due to detected old pretixSCAN version',
|
||||
}, status=400)
|
||||
except: # we don't care e.g. about invalid version numbers
|
||||
pass
|
||||
|
||||
return Response({
|
||||
'detail': 'Not found.', # for backwards compatibility
|
||||
'status': 'error',
|
||||
'reason': Checkin.REASON_INVALID,
|
||||
'reason_explanation': None,
|
||||
'require_attention': False,
|
||||
'list': MiniCheckinListSerializer(checkinlists[0]).data,
|
||||
}, status=404)
|
||||
elif revoked_matches and force:
|
||||
op_candidates = [revoked_matches[0].position]
|
||||
if list_by_event[revoked_matches[0].event_id].addon_match:
|
||||
op_candidates += list(revoked_matches[0].position.addons.all())
|
||||
raw_barcode_for_checkin = raw_barcode
|
||||
from_revoked_secret = True
|
||||
return Response({
|
||||
'detail': 'Not found.', # for backwards compatibility
|
||||
'status': 'error',
|
||||
'reason': Checkin.REASON_INVALID,
|
||||
'reason_explanation': None,
|
||||
'require_attention': False,
|
||||
'list': MiniCheckinListSerializer(checkinlists[0]).data,
|
||||
}, status=404)
|
||||
elif revoked_matches and force:
|
||||
op_candidates = [revoked_matches[0].position]
|
||||
if list_by_event[revoked_matches[0].event_id].addon_match:
|
||||
op_candidates += list(revoked_matches[0].position.addons.all())
|
||||
raw_barcode_for_checkin = raw_barcode_for_checkin or raw_barcode
|
||||
from_revoked_secret = True
|
||||
else:
|
||||
op = revoked_matches[0].position
|
||||
op.order.log_action('pretix.event.checkin.revoked', data={
|
||||
'datetime': datetime,
|
||||
'type': checkin_type,
|
||||
'list': list_by_event[revoked_matches[0].event_id].pk,
|
||||
'barcode': raw_barcode
|
||||
}, user=user, auth=auth)
|
||||
common_checkin_args['list'] = list_by_event[revoked_matches[0].event_id]
|
||||
Checkin.objects.create(
|
||||
position=op,
|
||||
successful=False,
|
||||
error_reason=Checkin.REASON_REVOKED,
|
||||
**common_checkin_args
|
||||
)
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': Checkin.REASON_REVOKED,
|
||||
'reason_explanation': None,
|
||||
'require_attention': False,
|
||||
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, revoked_matches[
|
||||
0].event)).data,
|
||||
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
|
||||
}, status=400)
|
||||
else:
|
||||
op = revoked_matches[0].position
|
||||
op.order.log_action('pretix.event.checkin.revoked', data={
|
||||
'datetime': datetime,
|
||||
'type': checkin_type,
|
||||
'list': list_by_event[revoked_matches[0].event_id].pk,
|
||||
'barcode': raw_barcode
|
||||
}, user=user, auth=auth)
|
||||
common_checkin_args['list'] = list_by_event[revoked_matches[0].event_id]
|
||||
Checkin.objects.create(
|
||||
position=op,
|
||||
successful=False,
|
||||
error_reason=Checkin.REASON_REVOKED,
|
||||
**common_checkin_args
|
||||
)
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': Checkin.REASON_REVOKED,
|
||||
'reason_explanation': None,
|
||||
'require_attention': False,
|
||||
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, revoked_matches[0].event)).data,
|
||||
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
|
||||
}, status=400)
|
||||
op_candidates = [media.linked_orderposition] + list(media.linked_orderposition.addons.all())
|
||||
|
||||
# 3. Handle the "multiple options found" case: Except for the unlikely case of a secret being also a valid primary
|
||||
# key on the same list, we're probably dealing with the ``addon_match`` case here and need to figure out
|
||||
@@ -634,6 +648,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
auth=auth,
|
||||
type=checkin_type,
|
||||
raw_barcode=raw_barcode_for_checkin,
|
||||
raw_source_type=source_type,
|
||||
from_revoked_secret=from_revoked_secret,
|
||||
)
|
||||
except RequiredQuestionsError as e:
|
||||
@@ -812,6 +827,7 @@ class CheckinRPCRedeemView(views.APIView):
|
||||
return _redeem_process(
|
||||
checkinlists=s.validated_data['lists'],
|
||||
raw_barcode=s.validated_data['secret'],
|
||||
source_type=s.validated_data['source_type'],
|
||||
answers_data=s.validated_data.get('answers'),
|
||||
datetime=s.validated_data.get('datetime') or now(),
|
||||
force=s.validated_data['force'],
|
||||
|
||||
@@ -542,7 +542,8 @@ class EventSettingsView(views.APIView):
|
||||
fname: {
|
||||
'value': s.data[fname],
|
||||
'label': getattr(field, '_label', fname),
|
||||
'help_text': getattr(field, '_help_text', None)
|
||||
'help_text': getattr(field, '_help_text', None),
|
||||
'readonly': fname in s.readonly_fields,
|
||||
} for fname, field in s.fields.items()
|
||||
})
|
||||
return Response(s.data)
|
||||
|
||||
160
src/pretix/api/views/media.py
Normal file
160
src/pretix/api/views/media.py
Normal file
@@ -0,0 +1,160 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from decimal import Decimal
|
||||
|
||||
import django_filters
|
||||
from django.db import transaction
|
||||
from django.db.models import OuterRef, Prefetch, Subquery, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.timezone import now
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import serializers, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import MethodNotAllowed
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.serializers.media import (
|
||||
MediaLookupInputSerializer, ReusableMediaSerializer,
|
||||
)
|
||||
from pretix.base.media import MEDIA_TYPES
|
||||
from pretix.base.models import (
|
||||
Checkin, GiftCard, GiftCardTransaction, OrderPosition, ReusableMedium,
|
||||
)
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
|
||||
with scopes_disabled():
|
||||
class ReusableMediumFilter(FilterSet):
|
||||
identifier = django_filters.CharFilter(field_name='identifier')
|
||||
type = django_filters.CharFilter(field_name='type')
|
||||
customer = django_filters.CharFilter(field_name='customer__identifier')
|
||||
updated_since = django_filters.IsoDateTimeFilter(field_name='updated', lookup_expr='gte')
|
||||
created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte')
|
||||
|
||||
class Meta:
|
||||
model = ReusableMedium
|
||||
fields = ['identifier', 'type', 'active', 'customer', 'linked_orderposition', 'linked_giftcard']
|
||||
|
||||
|
||||
class ReusableMediaViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ReusableMediaSerializer
|
||||
queryset = ReusableMedium.objects.none()
|
||||
permission = 'can_manage_reusable_media'
|
||||
write_permission = 'can_manage_reusable_media'
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
ordering = ('-updated', '-id')
|
||||
ordering_fields = ('created', 'updated', 'identifier', 'type', 'id')
|
||||
filterset_class = ReusableMediumFilter
|
||||
|
||||
def get_queryset(self):
|
||||
s = GiftCardTransaction.objects.filter(
|
||||
card=OuterRef('pk')
|
||||
).order_by().values('card').annotate(s=Sum('value')).values('s')
|
||||
return self.request.organizer.reusable_media.prefetch_related(
|
||||
Prefetch(
|
||||
'linked_orderposition',
|
||||
queryset=OrderPosition.objects.select_related(
|
||||
'order', 'order__event', 'order__event__organizer', 'seat',
|
||||
).prefetch_related(
|
||||
Prefetch('checkins', queryset=Checkin.objects.all()),
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
)
|
||||
),
|
||||
Prefetch(
|
||||
'linked_giftcard',
|
||||
queryset=GiftCard.objects.annotate(
|
||||
cached_value=Coalesce(Subquery(s), Decimal('0.00'))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['organizer'] = self.request.organizer
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_create(self, serializer):
|
||||
inst = serializer.save(organizer=self.request.organizer)
|
||||
inst.log_action(
|
||||
'pretix.reusable_medium.created',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'id': inst.pk})
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
ReusableMedium.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk)
|
||||
inst = serializer.save(identifier=serializer.instance.identifier, type=serializer.instance.type)
|
||||
inst.log_action(
|
||||
'pretix.reusable_medium.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data,
|
||||
)
|
||||
return inst
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
raise MethodNotAllowed("Media cannot be deleted.")
|
||||
|
||||
@action(methods=["POST"], detail=False)
|
||||
def lookup(self, request, *args, **kwargs):
|
||||
s = MediaLookupInputSerializer(
|
||||
data=request.data,
|
||||
)
|
||||
s.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
m = ReusableMedium.objects.get(
|
||||
type=s.validated_data["type"],
|
||||
identifier=s.validated_data["identifier"],
|
||||
organizer=request.organizer,
|
||||
)
|
||||
s = self.get_serializer(m)
|
||||
return Response({"result": s.data})
|
||||
except ReusableMedium.DoesNotExist:
|
||||
mt = MEDIA_TYPES.get(s.validated_data["type"])
|
||||
if mt:
|
||||
m = mt.handle_unknown(request.organizer, s.validated_data["identifier"], request.user, request.auth)
|
||||
if m:
|
||||
s = self.get_serializer(m)
|
||||
return Response({"result": s.data})
|
||||
|
||||
return Response({"result": None})
|
||||
|
||||
@scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some perforamnce
|
||||
def list(self, request, **kwargs):
|
||||
date = serializers.DateTimeField().to_representation(now())
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
resp = self.get_paginated_response(serializer.data)
|
||||
resp['X-Page-Generated'] = date
|
||||
return resp
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data, headers={'X-Page-Generated': date})
|
||||
@@ -67,8 +67,8 @@ from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedTicket, Checkin, Device, EventMetaValue,
|
||||
Invoice, InvoiceAddress, ItemMetaValue, ItemVariation,
|
||||
ItemVariationMetaValue, Order, OrderFee, OrderPayment, OrderPosition,
|
||||
OrderRefund, Quota, SubEvent, SubEventMetaValue, TaxRule, TeamAPIToken,
|
||||
generate_secret,
|
||||
OrderRefund, Quota, ReusableMedium, SubEvent, SubEventMetaValue, TaxRule,
|
||||
TeamAPIToken, generate_secret,
|
||||
)
|
||||
from pretix.base.models.orders import (
|
||||
BlockedTicketSecret, QuestionAnswer, RevokedTicketSecret,
|
||||
@@ -148,9 +148,13 @@ with scopes_disabled():
|
||||
else:
|
||||
code = Q(code__icontains=Order.normalize_code(u))
|
||||
|
||||
invoice_nos = {u, u.upper()}
|
||||
if u.isdigit():
|
||||
for i in range(2, 12):
|
||||
invoice_nos.add(u.zfill(i))
|
||||
|
||||
matching_invoices = Invoice.objects.filter(
|
||||
Q(invoice_no__iexact=u)
|
||||
| Q(invoice_no__iexact=u.zfill(5))
|
||||
Q(invoice_no__in=invoice_nos)
|
||||
| Q(full_invoice_no__iexact=u)
|
||||
).values_list('order_id', flat=True)
|
||||
|
||||
@@ -162,12 +166,15 @@ with scopes_disabled():
|
||||
)
|
||||
).values('id')
|
||||
|
||||
matching_media = ReusableMedium.objects.filter(identifier=u).values_list('linked_orderposition__order_id', flat=True)
|
||||
|
||||
mainq = (
|
||||
code
|
||||
| Q(email__icontains=u)
|
||||
| Q(invoice_address__name_cached__icontains=u)
|
||||
| Q(invoice_address__company__icontains=u)
|
||||
| Q(pk__in=matching_invoices)
|
||||
| Q(pk__in=matching_media)
|
||||
| Q(comment__icontains=u)
|
||||
| Q(has_pos=True)
|
||||
)
|
||||
@@ -244,7 +251,8 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
Prefetch('subevent', queryset=self.request.event.subevents.prefetch_related(
|
||||
Prefetch('meta_values', to_attr='meta_values_cached', queryset=SubEventMetaValue.objects.select_related('property'))
|
||||
)),
|
||||
Prefetch('addons', opq.select_related('item', 'variation', 'seat'))
|
||||
Prefetch('addons', opq.select_related('item', 'variation', 'seat')),
|
||||
'linked_media',
|
||||
).select_related('seat', 'addon_to', 'addon_to__seat')
|
||||
)
|
||||
else:
|
||||
@@ -313,7 +321,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
@action(detail=True, methods=['POST'])
|
||||
def mark_paid(self, request, **kwargs):
|
||||
order = self.get_object()
|
||||
send_mail = request.data.get('send_email', True)
|
||||
send_mail = request.data.get('send_email', True) if request.data else True
|
||||
|
||||
if order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED):
|
||||
|
||||
@@ -372,7 +380,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def mark_canceled(self, request, **kwargs):
|
||||
send_mail = request.data.get('send_email', True)
|
||||
send_mail = request.data.get('send_email', True) if request.data else True
|
||||
comment = request.data.get('comment', None)
|
||||
cancellation_fee = request.data.get('cancellation_fee', None)
|
||||
if cancellation_fee:
|
||||
@@ -431,7 +439,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def approve(self, request, **kwargs):
|
||||
send_mail = request.data.get('send_email', True)
|
||||
send_mail = request.data.get('send_email', True) if request.data else True
|
||||
|
||||
order = self.get_object()
|
||||
try:
|
||||
@@ -449,7 +457,7 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def deny(self, request, **kwargs):
|
||||
send_mail = request.data.get('send_email', True)
|
||||
send_mail = request.data.get('send_email', True) if request.data else True
|
||||
comment = request.data.get('comment', '')
|
||||
|
||||
order = self.get_object()
|
||||
@@ -639,13 +647,11 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
raise ValidationError(_('One of the selected products is not available in the selected country.'))
|
||||
send_mail = serializer._send_mail
|
||||
order = serializer.instance
|
||||
|
||||
if not order.pk:
|
||||
# Simulation
|
||||
# Simulation -- exit here
|
||||
serializer = SimulatedOrderSerializer(order, context=serializer.context)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
else:
|
||||
prefetch_related_objects([order], self._positions_prefetch(request))
|
||||
serializer = OrderSerializer(order, context=serializer.context)
|
||||
|
||||
order.log_action(
|
||||
'pretix.event.order.placed',
|
||||
@@ -679,6 +685,10 @@ class OrderViewSet(viewsets.ModelViewSet):
|
||||
if gen_invoice:
|
||||
invoice = generate_invoice(order, trigger_pdf=True)
|
||||
|
||||
# Refresh serializer only after running signals
|
||||
prefetch_related_objects([order], self._positions_prefetch(request))
|
||||
serializer = OrderSerializer(order, context=serializer.context)
|
||||
|
||||
if send_mail:
|
||||
free_flow = (
|
||||
payment and order.total == Decimal('0.00') and order.status == Order.STATUS_PAID and
|
||||
@@ -917,6 +927,7 @@ with scopes_disabled():
|
||||
search = django_filters.CharFilter(method='search_qs')
|
||||
|
||||
def search_qs(self, queryset, name, value):
|
||||
matching_media = ReusableMedium.objects.filter(identifier=value).values_list('linked_orderposition', flat=True)
|
||||
return queryset.filter(
|
||||
Q(secret__istartswith=value)
|
||||
| Q(attendee_name_cached__icontains=value)
|
||||
@@ -926,6 +937,7 @@ with scopes_disabled():
|
||||
| Q(order__code__istartswith=value)
|
||||
| Q(order__invoice_address__name_cached__icontains=value)
|
||||
| Q(order__email__icontains=value)
|
||||
| Q(pk__in=matching_media)
|
||||
)
|
||||
|
||||
def has_checkin_qs(self, queryset, name, value):
|
||||
@@ -1005,6 +1017,7 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
|
||||
Prefetch('meta_values', to_attr='meta_values_cached',
|
||||
queryset=SubEventMetaValue.objects.select_related('property'))
|
||||
)),
|
||||
'linked_media',
|
||||
Prefetch('order', self.request.event.orders.select_related('invoice_address').prefetch_related(
|
||||
Prefetch(
|
||||
'positions',
|
||||
@@ -1440,7 +1453,7 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
return order.payments.all()
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
send_mail = request.data.get('send_email', True)
|
||||
send_mail = request.data.get('send_email', True) if request.data else True
|
||||
serializer = OrderPaymentCreateSerializer(data=request.data, context=self.get_serializer_context())
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
@@ -1485,7 +1498,7 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
def confirm(self, request, **kwargs):
|
||||
payment = self.get_object()
|
||||
force = request.data.get('force', False)
|
||||
send_mail = request.data.get('send_email', True)
|
||||
send_mail = request.data.get('send_email', True) if request.data else True
|
||||
|
||||
if payment.state not in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED):
|
||||
return Response({'detail': 'Invalid state of payment'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -457,7 +457,8 @@ class OrganizerSettingsView(views.APIView):
|
||||
fname: {
|
||||
'value': s.data[fname],
|
||||
'label': getattr(field, '_label', fname),
|
||||
'help_text': getattr(field, '_help_text', None)
|
||||
'help_text': getattr(field, '_help_text', None),
|
||||
'readonly': fname in s.readonly_fields,
|
||||
} for fname, field in s.fields.items()
|
||||
})
|
||||
return Response(s.data)
|
||||
|
||||
@@ -256,6 +256,10 @@ def register_default_webhook_events(sender, **kwargs):
|
||||
'pretix.event.order.refund.failed',
|
||||
_('Refund of payment failed'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.payment.confirmed',
|
||||
_('Payment confirmed'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.approved',
|
||||
_('Order approved'),
|
||||
|
||||
@@ -420,7 +420,7 @@ def base_placeholders(sender, **kwargs):
|
||||
'order': 'F8VVL',
|
||||
'secret': '6zzjnumtsx136ddy',
|
||||
'hash': '98kusd8ofsj8dnkd'
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
SimpleFunctionalMailTextPlaceholder(
|
||||
@@ -661,6 +661,11 @@ def base_placeholders(sender, **kwargs):
|
||||
else:
|
||||
concatenation_for_salutation = name_scheme["concatenation"]
|
||||
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
"name_for_salutation", ["waiting_list_entry"],
|
||||
lambda waiting_list_entry: concatenation_for_salutation(waiting_list_entry.name_parts),
|
||||
_("Mr Doe"),
|
||||
))
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
"name_for_salutation", ["position_or_address"],
|
||||
lambda position_or_address: concatenation_for_salutation(get_best_name(position_or_address, parts=True)),
|
||||
@@ -670,6 +675,10 @@ def base_placeholders(sender, **kwargs):
|
||||
for f, l, w in name_scheme['fields']:
|
||||
if f == 'full_name':
|
||||
continue
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
'name_%s' % f, ['waiting_list_entry'], lambda waiting_list_entry, f=f: get_name_parts_localized(waiting_list_entry.name_parts, f),
|
||||
name_scheme['sample'][f]
|
||||
))
|
||||
ph.append(SimpleFunctionalMailTextPlaceholder(
|
||||
'attendee_name_%s' % f, ['position'], lambda position, f=f: get_name_parts_localized(position.attendee_name_parts, f),
|
||||
name_scheme['sample'][f]
|
||||
|
||||
@@ -94,7 +94,7 @@ class EventDataExporter(ListExporter):
|
||||
]
|
||||
|
||||
def get_filename(self):
|
||||
return '{}_events'.format(self.events.first().organizer.slug)
|
||||
return '{}_events'.format(self.organizer.slug)
|
||||
|
||||
|
||||
@receiver(register_multievent_data_exporters, dispatch_uid="multiexporter_eventdata")
|
||||
|
||||
@@ -155,7 +155,7 @@ class InvoiceExporter(InvoiceExporterMixin, BaseExporter):
|
||||
self.progress_callback(counter / total * 100)
|
||||
|
||||
if self.is_multievent:
|
||||
filename = '{}_invoices.zip'.format(self.events.first().organizer.slug)
|
||||
filename = '{}_invoices.zip'.format(self.organizer.slug)
|
||||
else:
|
||||
filename = '{}_invoices.zip'.format(self.event.slug)
|
||||
|
||||
@@ -415,7 +415,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
|
||||
def get_filename(self):
|
||||
if self.is_multievent:
|
||||
return '{}_invoices'.format(self.events.first().organizer.slug)
|
||||
return '{}_invoices'.format(self.organizer.slug)
|
||||
else:
|
||||
return '{}_invoices'.format(self.event.slug)
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ class ItemDataExporter(ListExporter):
|
||||
|
||||
def get_filename(self):
|
||||
if self.is_multievent:
|
||||
return '{}_products'.format(self.events.first().organizer.slug)
|
||||
return '{}_products'.format(self.organizer.slug)
|
||||
return '{}_products'.format(self.event.slug)
|
||||
|
||||
def prepare_xlsx_sheet(self, ws):
|
||||
|
||||
@@ -63,7 +63,7 @@ class MailExporter(BaseExporter):
|
||||
| set(a['attendee_email'] for a in pos if a['attendee_email']))
|
||||
|
||||
if self.is_multievent:
|
||||
return '{}_pretixemails.txt'.format(self.events.first().organizer.slug), 'text/plain', data.encode("utf-8")
|
||||
return '{}_pretixemails.txt'.format(self.organizer.slug), 'text/plain', data.encode("utf-8")
|
||||
else:
|
||||
return '{}_pretixemails.txt'.format(self.event.slug), 'text/plain', data.encode("utf-8")
|
||||
|
||||
|
||||
@@ -688,8 +688,8 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
|
||||
row += [
|
||||
_('Yes') if op.blocked else '',
|
||||
date_format(op.valid_from, 'SHORT_DATETIME_FORMAT') if op.valid_from else '',
|
||||
date_format(op.valid_until, 'SHORT_DATETIME_FORMAT') if op.valid_until else '',
|
||||
date_format(op.valid_from.astimezone(tz), 'SHORT_DATETIME_FORMAT') if op.valid_from else '',
|
||||
date_format(op.valid_until.astimezone(tz), 'SHORT_DATETIME_FORMAT') if op.valid_until else '',
|
||||
]
|
||||
row.append(order.comment)
|
||||
row.append(order.custom_followup_at.strftime("%Y-%m-%d") if order.custom_followup_at else "")
|
||||
@@ -754,7 +754,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
|
||||
def get_filename(self):
|
||||
if self.is_multievent:
|
||||
return '{}_orders'.format(self.events.first().organizer.slug)
|
||||
return '{}_orders'.format(self.organizer.slug)
|
||||
else:
|
||||
return '{}_orders'.format(self.event.slug)
|
||||
|
||||
@@ -880,7 +880,7 @@ class PaymentListExporter(ListExporter):
|
||||
|
||||
def get_filename(self):
|
||||
if self.is_multievent:
|
||||
return '{}_payments'.format(self.events.first().organizer.slug)
|
||||
return '{}_payments'.format(self.organizer.slug)
|
||||
else:
|
||||
return '{}_payments'.format(self.event.slug)
|
||||
|
||||
@@ -1037,7 +1037,7 @@ class GiftcardRedemptionListExporter(ListExporter):
|
||||
|
||||
def get_filename(self):
|
||||
if self.is_multievent:
|
||||
return '{}_giftcardredemptions'.format(self.events.first().organizer.slug)
|
||||
return '{}_giftcardredemptions'.format(self.organizer.slug)
|
||||
else:
|
||||
return '{}_giftcardredemptions'.format(self.event.slug)
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import pytz
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.gis.geoip2 import GeoIP2
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.core.validators import (
|
||||
@@ -91,6 +92,7 @@ from pretix.helpers.countries import (
|
||||
CachedCountries, get_phone_prefixes_sorted_and_localized,
|
||||
)
|
||||
from pretix.helpers.escapejson import escapejson_attr
|
||||
from pretix.helpers.http import get_client_ip
|
||||
from pretix.helpers.i18n import get_format_without_seconds
|
||||
from pretix.presale.signals import question_form_fields
|
||||
|
||||
@@ -351,6 +353,15 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
|
||||
return ""
|
||||
|
||||
|
||||
def guess_country_from_request(request, event):
|
||||
if settings.HAS_GEOIP:
|
||||
g = GeoIP2()
|
||||
res = g.country(get_client_ip(request))
|
||||
if res['country_code'] and len(res['country_code']) == 2:
|
||||
return Country(res['country_code'])
|
||||
return guess_country(event)
|
||||
|
||||
|
||||
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 :)
|
||||
@@ -382,6 +393,12 @@ def guess_phone_prefix(event):
|
||||
return get_phone_prefix(country)
|
||||
|
||||
|
||||
def guess_phone_prefix_from_request(request, event):
|
||||
with language(get_babel_locale()):
|
||||
country = str(guess_country_from_request(request, 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:
|
||||
@@ -564,6 +581,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
:param cartpos: The cart position the form should be for
|
||||
:param event: The event this belongs to
|
||||
"""
|
||||
request = kwargs.pop('request', None)
|
||||
cartpos = self.cartpos = kwargs.pop('cartpos', None)
|
||||
orderpos = self.orderpos = kwargs.pop('orderpos', None)
|
||||
pos = cartpos or orderpos
|
||||
@@ -661,7 +679,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
'autocomplete': 'address-level2',
|
||||
}),
|
||||
)
|
||||
country = (cartpos.country if cartpos else orderpos.country) or guess_country(event)
|
||||
country = (cartpos.country if cartpos else orderpos.country) or guess_country_from_request(request, event)
|
||||
add_fields['country'] = CountryField(
|
||||
countries=CachedCountries
|
||||
).formfield(
|
||||
@@ -747,12 +765,14 @@ class BaseQuestionsForm(forms.Form):
|
||||
elif q.type == Question.TYPE_STRING:
|
||||
field = forms.CharField(
|
||||
label=label, required=required,
|
||||
max_length=q.valid_string_length_max,
|
||||
help_text=help_text,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_TEXT:
|
||||
field = forms.CharField(
|
||||
label=label, required=required,
|
||||
max_length=q.valid_string_length_max,
|
||||
help_text=help_text,
|
||||
widget=forms.Textarea,
|
||||
initial=initial.answer if initial else None,
|
||||
@@ -766,7 +786,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
help_text=help_text,
|
||||
widget=forms.Select,
|
||||
empty_label=' ',
|
||||
initial=initial.answer if initial else (guess_country(event) if required else None),
|
||||
initial=initial.answer if initial else (guess_country_from_request(request, event) if required else None),
|
||||
)
|
||||
elif q.type == Question.TYPE_CHOICE:
|
||||
field = forms.ModelChoiceField(
|
||||
@@ -854,7 +874,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
initial = None
|
||||
|
||||
if not initial:
|
||||
phone_prefix = guess_phone_prefix(event)
|
||||
phone_prefix = guess_phone_prefix_from_request(request, event)
|
||||
if phone_prefix:
|
||||
initial = "+{}.".format(phone_prefix)
|
||||
|
||||
@@ -990,7 +1010,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
|
||||
kwargs.setdefault('initial', {})
|
||||
if not kwargs.get('instance') or not kwargs['instance'].country:
|
||||
kwargs['initial']['country'] = guess_country(self.event)
|
||||
kwargs['initial']['country'] = guess_country_from_request(self.request, self.event)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
if not event.settings.invoice_address_vatid:
|
||||
|
||||
@@ -49,6 +49,7 @@ class Command(BaseCommand):
|
||||
except ImportError:
|
||||
cmd = 'shell'
|
||||
del options['skip_checks']
|
||||
del options['print_sql']
|
||||
|
||||
if options['print_sql']:
|
||||
connection.force_debug_cursor = True
|
||||
|
||||
116
src/pretix/base/media.py
Normal file
116
src/pretix/base/media.py
Normal file
@@ -0,0 +1,116 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from django.db import transaction
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class BaseMediaType:
|
||||
medium_created_by_server = False
|
||||
supports_orderposition = False
|
||||
supports_giftcard = False
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def generate_identifier(self, organizer):
|
||||
if self.medium_created_by_server:
|
||||
raise NotImplementedError()
|
||||
else:
|
||||
raise ValueError("Media type does not allow to generate identifier")
|
||||
|
||||
def is_active(self, organizer):
|
||||
return organizer.settings.get(f'reusable_media_type_{self.identifier}', as_type=bool, default=False)
|
||||
|
||||
def handle_unknown(self, organizer, identifier, user, auth):
|
||||
pass
|
||||
|
||||
def __str__(self):
|
||||
return str(self.verbose_name)
|
||||
|
||||
|
||||
class BarcodePlainMediaType(BaseMediaType):
|
||||
identifier = 'barcode'
|
||||
verbose_name = _('Barcode / QR-Code')
|
||||
medium_created_by_server = True
|
||||
supports_giftcard = False
|
||||
supports_orderposition = True
|
||||
|
||||
def generate_identifier(self, organizer):
|
||||
return get_random_string(
|
||||
length=organizer.settings.reusable_media_type_barcode_identifier_length,
|
||||
# Exclude o,0,1,i to avoid confusion with bad fonts/printers
|
||||
# We use upper case to make collisions with ticket secrets less likely
|
||||
allowed_chars='ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
)
|
||||
|
||||
|
||||
class NfcUidMediaType(BaseMediaType):
|
||||
identifier = 'nfc_uid'
|
||||
verbose_name = _('NFC UID-based')
|
||||
medium_created_by_server = False
|
||||
supports_giftcard = True
|
||||
supports_orderposition = False
|
||||
|
||||
def handle_unknown(self, organizer, identifier, user, auth):
|
||||
from pretix.base.models import GiftCard, ReusableMedium
|
||||
|
||||
if organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard', as_type=bool):
|
||||
if identifier.startswith("08"):
|
||||
# Don't create gift cards for NFC UIDs that start with 08, which represents NFC cards that issue random
|
||||
# UIDs on every read, so they won't be useful.
|
||||
return
|
||||
with transaction.atomic():
|
||||
gc = GiftCard.objects.create(
|
||||
issuer=organizer,
|
||||
expires=organizer.default_gift_card_expiry,
|
||||
currency=organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard_currency'),
|
||||
)
|
||||
m = ReusableMedium.objects.create(
|
||||
type=self.identifier,
|
||||
identifier=identifier,
|
||||
organizer=organizer,
|
||||
active=True,
|
||||
linked_giftcard=gc
|
||||
)
|
||||
m.log_action(
|
||||
'pretix.reusable_medium.created.auto',
|
||||
user=user, auth=auth,
|
||||
)
|
||||
gc.log_action(
|
||||
'pretix.giftcards.created',
|
||||
user=user, auth=auth,
|
||||
)
|
||||
return m
|
||||
|
||||
|
||||
MEDIA_TYPES = {
|
||||
m.identifier: m for m in [
|
||||
BarcodePlainMediaType(),
|
||||
NfcUidMediaType(),
|
||||
]
|
||||
}
|
||||
77
src/pretix/base/migrations/0236_reusable_media.py
Normal file
77
src/pretix/base/migrations/0236_reusable_media.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# Generated by Django 3.2.18 on 2023-02-20 12:46
|
||||
|
||||
import django.core.serializers.json
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.base
|
||||
|
||||
|
||||
def set_can_manage_reusable_media(apps, schema_editor):
|
||||
Team = apps.get_model('pretixbase', 'Team')
|
||||
Team.objects.filter(can_change_organizer_settings=True).update(can_manage_reusable_media=True)
|
||||
Team.objects.filter(can_change_orders=True, all_events=True).update(can_manage_reusable_media=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('pretixbase', '0235_auto_20230316_2023'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='media_policy',
|
||||
field=models.CharField(max_length=16, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='media_type',
|
||||
field=models.CharField(max_length=100, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='can_manage_reusable_media',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReusableMedium',
|
||||
fields=[
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
('type', models.CharField(max_length=100)),
|
||||
('identifier', models.CharField(max_length=200)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('expires', models.DateTimeField(blank=True, null=True)),
|
||||
('info', models.JSONField(default=dict)),
|
||||
('notes', models.TextField(null=True, blank=True)),
|
||||
('customer', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='reusable_media', to='pretixbase.customer')),
|
||||
('linked_giftcard',
|
||||
models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='linked_media',
|
||||
to='pretixbase.giftcard')),
|
||||
('linked_orderposition',
|
||||
models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='linked_media',
|
||||
to='pretixbase.orderposition')),
|
||||
('organizer',
|
||||
models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='reusable_media',
|
||||
to='pretixbase.organizer')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('identifier', 'type', 'organizer'),
|
||||
'unique_together': {('identifier', 'type', 'organizer')},
|
||||
'index_together': {('identifier', 'type', 'organizer'), ('updated', 'id')},
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='checkin',
|
||||
name='raw_source_type',
|
||||
field=models.CharField(max_length=100, null=True),
|
||||
),
|
||||
migrations.RunPython(
|
||||
set_can_manage_reusable_media,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.18 on 2023-04-05 10:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0236_reusable_media'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='valid_string_length_max',
|
||||
field=models.PositiveIntegerField(null=True),
|
||||
),
|
||||
]
|
||||
@@ -40,6 +40,7 @@ from .items import (
|
||||
SubEventItem, SubEventItemVariation, itempicture_upload_to,
|
||||
)
|
||||
from .log import LogEntry
|
||||
from .media import ReusableMedium
|
||||
from .memberships import Membership, MembershipType
|
||||
from .notifications import NotificationSetting
|
||||
from .orders import (
|
||||
|
||||
@@ -44,6 +44,7 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
|
||||
from pretix.base.media import MEDIA_TYPES
|
||||
from pretix.base.models import LoggedModel
|
||||
from pretix.base.models.fields import MultiStringField
|
||||
from pretix.helpers import PostgresWindowFrame
|
||||
@@ -377,6 +378,11 @@ class Checkin(models.Model):
|
||||
# For "raw" scans where we do not know which position they belong to (e.g. scan of signed
|
||||
# barcode that is not in database).
|
||||
raw_barcode = models.TextField(null=True, blank=True)
|
||||
raw_source_type = models.CharField(
|
||||
max_length=100,
|
||||
null=True, blank=True,
|
||||
choices=[(k, v) for k, v in MEDIA_TYPES.items()],
|
||||
)
|
||||
raw_item = models.ForeignKey(
|
||||
'pretixbase.Item',
|
||||
related_name='checkins',
|
||||
|
||||
@@ -142,6 +142,7 @@ class Customer(LoggedModel):
|
||||
self.save()
|
||||
self.all_logentries().update(data={}, shredded=True)
|
||||
self.orders.all().update(customer=None)
|
||||
self.reusable_media.all().update(customer=None)
|
||||
self.memberships.all().update(attendee_name_parts=None)
|
||||
self.attendee_profiles.all().delete()
|
||||
self.invoice_addresses.all().delete()
|
||||
|
||||
@@ -180,7 +180,8 @@ class Device(LoggedModel):
|
||||
'can_view_orders',
|
||||
'can_change_orders',
|
||||
'can_view_vouchers',
|
||||
'can_manage_gift_cards'
|
||||
'can_manage_gift_cards',
|
||||
'can_manage_reusable_media',
|
||||
}
|
||||
|
||||
def get_event_permission_set(self, organizer, event) -> set:
|
||||
|
||||
@@ -45,7 +45,9 @@ 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 (
|
||||
MaxLengthValidator, MinValueValidator, RegexValidator,
|
||||
)
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils import formats
|
||||
@@ -64,6 +66,7 @@ from pretix.base.models.fields import MultiStringField
|
||||
from pretix.base.models.tax import TaxedPrice
|
||||
|
||||
from ...helpers.images import ImageSizeValidator
|
||||
from ..media import MEDIA_TYPES
|
||||
from .event import Event, SubEvent
|
||||
|
||||
|
||||
@@ -368,6 +371,16 @@ class Item(LoggedModel):
|
||||
(VALIDITY_MODE_DYNAMIC, _('Dynamic validity')),
|
||||
)
|
||||
|
||||
MEDIA_POLICY_REUSE = 'reuse'
|
||||
MEDIA_POLICY_NEW = 'new'
|
||||
MEDIA_POLICY_REUSE_OR_NEW = 'reuse_or_new'
|
||||
MEDIA_POLICIES = (
|
||||
(None, _("Don't use re-usable media, use regular one-off tickets")),
|
||||
(MEDIA_POLICY_REUSE, _('Require an existing medium to be re-used')),
|
||||
(MEDIA_POLICY_NEW, _('Require a previously unknown medium to be newly added')),
|
||||
(MEDIA_POLICY_REUSE_OR_NEW, _('Require either an existing or a new medium to be used')),
|
||||
)
|
||||
|
||||
objects = ItemQuerySetManager()
|
||||
|
||||
event = models.ForeignKey(
|
||||
@@ -630,6 +643,29 @@ class Item(LoggedModel):
|
||||
help_text=_('The selected start date may only be this many days in the future.')
|
||||
)
|
||||
|
||||
media_policy = models.CharField(
|
||||
choices=MEDIA_POLICIES,
|
||||
null=True, blank=True, max_length=16,
|
||||
verbose_name=_('Reusable media policy'),
|
||||
help_text=_(
|
||||
'If this product should be stored on a re-usable physical medium, you can attach a physical media policy. '
|
||||
'This is not required for regular tickets, which just use a one-time barcode, but only for products like '
|
||||
'renewable season tickets or re-chargeable gift card wristbands. '
|
||||
'This is an advanced feature that also requires specific configuration of ticketing and printing settings.'
|
||||
)
|
||||
)
|
||||
media_type = models.CharField(
|
||||
max_length=100,
|
||||
null=True, blank=True,
|
||||
choices=[(None, _("Don't use re-usable media, use regular one-off tickets"))] + [(k, v) for k, v in MEDIA_TYPES.items()],
|
||||
verbose_name=_('Reusable media type'),
|
||||
help_text=_(
|
||||
'Select the type of physical medium that should be used for this product. Note that not all media types '
|
||||
'support all types of products, and not all media types are supported across all sales channels or '
|
||||
'check-in processes.'
|
||||
)
|
||||
)
|
||||
|
||||
# !!! Attention: If you add new fields here, also add them to the copying code in
|
||||
# pretix/control/forms/item.py if applicable.
|
||||
|
||||
@@ -801,6 +837,24 @@ class Item(LoggedModel):
|
||||
def has_variations(self):
|
||||
return self.variations.exists()
|
||||
|
||||
@staticmethod
|
||||
def clean_media_settings(event, media_policy, media_type, issue_giftcard):
|
||||
if media_policy:
|
||||
if not media_type:
|
||||
raise ValidationError(_('If you select a reusable media policy, you also need to select a reusable '
|
||||
'media type.'))
|
||||
mt = MEDIA_TYPES[media_type]
|
||||
if not mt.is_active(event.organizer):
|
||||
raise ValidationError(_('The selected media type is not enabled in your organizer settings.'))
|
||||
if not mt.supports_orderposition and not issue_giftcard:
|
||||
raise ValidationError(_('The selected media type does not support usage for tickets currently.'))
|
||||
if not mt.supports_giftcard and issue_giftcard:
|
||||
raise ValidationError(_('The selected media type does not support usage for gift cards currently.'))
|
||||
if issue_giftcard:
|
||||
raise ValidationError(_('You currently cannot create gift cards with a reusable media policy. Instead, '
|
||||
'gift cards for some reusable media types can be created or re-charged directly '
|
||||
'at the POS.'))
|
||||
|
||||
@staticmethod
|
||||
def clean_per_order(min_per_order, max_per_order):
|
||||
if min_per_order is not None and max_per_order is not None:
|
||||
@@ -1488,6 +1542,11 @@ class Question(LoggedModel):
|
||||
valid_datetime_max = models.DateTimeField(null=True, blank=True,
|
||||
verbose_name=_('Maximum value'),
|
||||
help_text=_('Currently not supported in our apps and during check-in'))
|
||||
valid_string_length_max = models.PositiveIntegerField(null=True, blank=True,
|
||||
verbose_name=_('Maximum length'),
|
||||
help_text=_(
|
||||
'Currently not supported in our apps and during check-in'
|
||||
))
|
||||
valid_file_portrait = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_('Validate file to be a portrait'),
|
||||
@@ -1634,6 +1693,12 @@ class Question(LoggedModel):
|
||||
return answer
|
||||
else:
|
||||
raise ValidationError(_('Unknown country code.'))
|
||||
elif self.type in (Question.TYPE_STRING, Question.TYPE_TEXT):
|
||||
if self.valid_string_length_max is not None and len(answer) > self.valid_string_length_max:
|
||||
raise ValidationError(MaxLengthValidator.message % {
|
||||
'limit_value': self.valid_string_length_max,
|
||||
'show_value': len(answer)
|
||||
})
|
||||
|
||||
return answer
|
||||
|
||||
|
||||
125
src/pretix/base/models/media.py
Normal file
125
src/pretix/base/models/media.py
Normal file
@@ -0,0 +1,125 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes import ScopedManager
|
||||
|
||||
from pretix.base.media import MEDIA_TYPES
|
||||
from pretix.base.models import LoggedModel
|
||||
from pretix.base.models.customers import Customer
|
||||
from pretix.base.models.giftcards import GiftCard
|
||||
from pretix.base.models.orders import OrderPosition
|
||||
from pretix.base.models.organizer import Organizer
|
||||
|
||||
|
||||
class ReusableMediumQuerySet(models.QuerySet):
|
||||
|
||||
def active(self):
|
||||
return self.filter(
|
||||
Q(expires__isnull=True) | Q(expires__gte=now()),
|
||||
active=True,
|
||||
)
|
||||
|
||||
|
||||
class ReusableMediumQuerySetManager(ScopedManager(organizer='organizer').__class__):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._queryset_class = ReusableMediumQuerySet
|
||||
|
||||
def active(self):
|
||||
return self.get_queryset().active()
|
||||
|
||||
|
||||
class ReusableMedium(LoggedModel):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
updated = models.DateTimeField(auto_now=True)
|
||||
|
||||
organizer = models.ForeignKey(
|
||||
Organizer,
|
||||
related_name='reusable_media',
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
|
||||
type = models.CharField(
|
||||
verbose_name=pgettext_lazy('reusable_medium', 'Media type'),
|
||||
choices=((k, v) for k, v in MEDIA_TYPES.items()),
|
||||
max_length=100,
|
||||
)
|
||||
identifier = models.CharField(
|
||||
max_length=200,
|
||||
verbose_name=pgettext_lazy('reusable_medium', 'Identifier'),
|
||||
)
|
||||
|
||||
active = models.BooleanField(
|
||||
verbose_name=_('Active'),
|
||||
default=True
|
||||
)
|
||||
expires = models.DateTimeField(
|
||||
verbose_name=_('Expiration date'),
|
||||
null=True, blank=True
|
||||
)
|
||||
|
||||
customer = models.ForeignKey(
|
||||
Customer,
|
||||
null=True, blank=True,
|
||||
related_name='reusable_media',
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_('Customer account'),
|
||||
)
|
||||
linked_orderposition = models.ForeignKey(
|
||||
OrderPosition,
|
||||
null=True, blank=True,
|
||||
related_name='linked_media',
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_('Linked ticket'),
|
||||
)
|
||||
linked_giftcard = models.ForeignKey(
|
||||
GiftCard,
|
||||
null=True, blank=True,
|
||||
related_name='linked_media',
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_('Linked gift card'),
|
||||
)
|
||||
|
||||
info = models.JSONField(
|
||||
default=dict
|
||||
)
|
||||
notes = models.TextField(verbose_name=_('Notes'), null=True, blank=True)
|
||||
|
||||
objects = ReusableMediumQuerySetManager()
|
||||
|
||||
@cached_property
|
||||
def media_type(self):
|
||||
return MEDIA_TYPES[self.type]
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
return self.expires and self.expires > now()
|
||||
|
||||
class Meta:
|
||||
unique_together = (("identifier", "type", "organizer"),)
|
||||
index_together = (("identifier", "type", "organizer"), ("updated", "id"))
|
||||
ordering = "identifier", "type", "organizer"
|
||||
@@ -830,7 +830,7 @@ class Order(LockModel, LoggedModel):
|
||||
@property
|
||||
def is_expired_by_time(self):
|
||||
return (
|
||||
self.status == Order.STATUS_PENDING and self.expires < now()
|
||||
self.status == Order.STATUS_PENDING and not self.require_approval and self.expires < now()
|
||||
and not self.event.settings.get('payment_term_expire_automatically')
|
||||
)
|
||||
|
||||
|
||||
@@ -236,6 +236,8 @@ class Team(LoggedModel):
|
||||
:type can_change_teams: bool
|
||||
:param can_manage_customers: If ``True``, the members can view and change organizer-level customer accounts.
|
||||
:type can_manage_customers: bool
|
||||
:param can_manage_reusable_media: If ``True``, the members can view and change organizer-level reusable media.
|
||||
:type can_manage_reusable_media: bool
|
||||
:param can_change_organizer_settings: If ``True``, the members can change the settings of this organizer account.
|
||||
:type can_change_organizer_settings: bool
|
||||
:param can_change_event_settings: If ``True``, the members can change the settings of the associated events.
|
||||
@@ -277,6 +279,10 @@ class Team(LoggedModel):
|
||||
default=False,
|
||||
verbose_name=_("Can manage customer accounts")
|
||||
)
|
||||
can_manage_reusable_media = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Can manage reusable media")
|
||||
)
|
||||
can_manage_gift_cards = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Can manage gift cards")
|
||||
|
||||
@@ -454,7 +454,7 @@ class Voucher(LoggedModel):
|
||||
|
||||
@staticmethod
|
||||
def clean_voucher_code(data, event, pk):
|
||||
if 'code' in data and Voucher.objects.filter(Q(code__iexact=data['code']) & Q(event=event) & ~Q(pk=pk)).exists():
|
||||
if 'code' in data and Voucher.objects.filter(Q(code__iexact=data['code'].upper()) & Q(event=event) & ~Q(pk=pk)).exists():
|
||||
raise ValidationError(_('A voucher with this code already exists.'))
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -219,18 +219,19 @@ class WaitingListEntry(LoggedModel):
|
||||
self.voucher = v
|
||||
self.save()
|
||||
|
||||
self.send_mail(
|
||||
self.event.settings.mail_subject_waiting_list,
|
||||
self.event.settings.mail_text_waiting_list,
|
||||
get_email_context(
|
||||
event=self.event,
|
||||
waiting_list_entry=self,
|
||||
waiting_list_voucher=v,
|
||||
event_or_subevent=self.subevent or self.event,
|
||||
),
|
||||
user=user,
|
||||
auth=auth,
|
||||
)
|
||||
with language(self.locale, self.event.settings.region):
|
||||
self.send_mail(
|
||||
self.event.settings.mail_subject_waiting_list,
|
||||
self.event.settings.mail_text_waiting_list,
|
||||
get_email_context(
|
||||
event=self.event,
|
||||
waiting_list_entry=self,
|
||||
waiting_list_voucher=v,
|
||||
event_or_subevent=self.subevent or self.event,
|
||||
),
|
||||
user=user,
|
||||
auth=auth,
|
||||
)
|
||||
|
||||
def send_mail(self, subject: Union[str, LazyI18nString], template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any]=None, log_entry_type: str='pretix.waitinglist.email.sent',
|
||||
|
||||
@@ -360,7 +360,7 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"label": _("List of Add-Ons"),
|
||||
"editor_sample": _("Add-on 1\nAdd-on 2"),
|
||||
"evaluate": lambda op, order, ev: "\n".join([
|
||||
'{} - {}'.format(p.item, p.variation) if p.variation else str(p.item)
|
||||
'{} - {}'.format(p.item.name, p.variation.value) if p.variation else str(p.item.name)
|
||||
for p in (
|
||||
op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {})
|
||||
else op.addons.select_related('item', 'variation')
|
||||
@@ -411,7 +411,7 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"label": _("Validity start date"),
|
||||
"editor_sample": _("2017-05-31"),
|
||||
"evaluate": lambda op, order, ev: date_format(
|
||||
now().astimezone(timezone(ev.settings.timezone)),
|
||||
op.valid_from.astimezone(timezone(ev.settings.timezone)),
|
||||
"SHORT_DATE_FORMAT"
|
||||
) if op.valid_from else ""
|
||||
}),
|
||||
@@ -455,6 +455,11 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"TIME_FORMAT"
|
||||
) if op.valid_until else ""
|
||||
}),
|
||||
("medium_identifier", {
|
||||
"label": _("Reusable Medium ID"),
|
||||
"editor_sample": "ABC1234DEF4567",
|
||||
"evaluate": lambda op, order, ev: op.linked_media.all()[0].identifier if op.linked_media.all() else "",
|
||||
}),
|
||||
("seat", {
|
||||
"label": _("Seat: Full name"),
|
||||
"editor_sample": _("Ground floor, Row 3, Seat 4"),
|
||||
@@ -583,10 +588,11 @@ def variables_from_questions(sender, *args, **kwargs):
|
||||
|
||||
|
||||
def _get_attendee_name_part(key, op, order, ev):
|
||||
name_parts = op.attendee_name_parts or (op.addon_to.attendee_name_parts if op.addon_to else {})
|
||||
if isinstance(key, tuple):
|
||||
parts = [_get_attendee_name_part(c[0], op, order, ev) for c in key if not (c[0] == 'salutation' and op.attendee_name_parts.get(c[0], '') == "Mx")]
|
||||
parts = [_get_attendee_name_part(c[0], op, order, ev) for c in key if not (c[0] == 'salutation' and name_parts.get(c[0], '') == "Mx")]
|
||||
return ' '.join(p for p in parts if p)
|
||||
value = op.attendee_name_parts.get(key, '')
|
||||
value = name_parts.get(key, '')
|
||||
if key == 'salutation':
|
||||
return pgettext('person_name_salutation', value)
|
||||
return value
|
||||
@@ -617,7 +623,7 @@ def get_variables(event):
|
||||
v['attendee_name_for_salutation'] = {
|
||||
'label': _("Attendee name for salutation"),
|
||||
'editor_sample': _("Mr Doe"),
|
||||
'evaluate': lambda op, order, ev: concatenation_for_salutation(op.attendee_name_parts or {})
|
||||
'evaluate': lambda op, order, ev: concatenation_for_salutation(op.attendee_name_parts or (op.addon_to.attendee_name_parts if op.addon_to else {}))
|
||||
}
|
||||
|
||||
for key, label, weight in scheme['fields']:
|
||||
@@ -761,6 +767,9 @@ class Renderer:
|
||||
else:
|
||||
content = self._get_text_content(op, order, o)
|
||||
|
||||
if len(content) == 0:
|
||||
return
|
||||
|
||||
level = 'H'
|
||||
if len(content) > 32:
|
||||
level = 'M'
|
||||
|
||||
@@ -24,9 +24,11 @@ import sys
|
||||
from enum import Enum
|
||||
from typing import List
|
||||
|
||||
import importlib_metadata as metadata
|
||||
from django.apps import AppConfig, apps
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from packaging.requirements import Requirement
|
||||
|
||||
|
||||
class PluginType(Enum):
|
||||
@@ -81,12 +83,11 @@ class PluginConfig(AppConfig, metaclass=PluginConfigMeta):
|
||||
raise ImproperlyConfigured("A pretix plugin config should have a PretixPluginMeta inner class.")
|
||||
|
||||
if hasattr(self.PretixPluginMeta, 'compatibility') and not os.environ.get("PRETIX_IGNORE_CONFLICTS") == "True":
|
||||
import pkg_resources
|
||||
try:
|
||||
pkg_resources.require(self.PretixPluginMeta.compatibility)
|
||||
except pkg_resources.VersionConflict as e:
|
||||
req = Requirement(self.PretixPluginMeta.compatibility)
|
||||
requirement_version = metadata.version(req.name)
|
||||
if not req.specifier.contains(requirement_version, prereleases=True):
|
||||
print("Incompatible plugins found!")
|
||||
print("Plugin {} requires you to have {}, but you installed {}.".format(
|
||||
self.name, e.req, e.dist
|
||||
self.name, req, requirement_version
|
||||
))
|
||||
sys.exit(1)
|
||||
|
||||
@@ -52,6 +52,7 @@ from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.media import MEDIA_TYPES
|
||||
from pretix.base.models import (
|
||||
CartPosition, Event, InvoiceAddress, Item, ItemVariation, Seat,
|
||||
SeatCategoryMapping, Voucher,
|
||||
@@ -199,6 +200,8 @@ error_messages = {
|
||||
'seat_multiple': gettext_lazy('You can not select the same seat multiple times.'),
|
||||
'gift_card': gettext_lazy("You entered a gift card instead of a voucher. Gift cards can be entered later on when you're asked for your payment details."),
|
||||
'country_blocked': gettext_lazy('One of the selected products is not available in the selected country.'),
|
||||
'media_usage_not_implemented': gettext_lazy('The configuration of this product requires mapping to a physical '
|
||||
'medium, which is currently not available online.'),
|
||||
}
|
||||
|
||||
|
||||
@@ -394,6 +397,13 @@ class CartManager:
|
||||
if not op.item.is_available() or (op.variation and not op.variation.is_available()):
|
||||
raise CartError(error_messages['unavailable'])
|
||||
|
||||
if op.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
|
||||
mt = MEDIA_TYPES[op.item.media_type]
|
||||
if not mt.medium_created_by_server:
|
||||
raise CartError(error_messages['media_usage_not_implemented'])
|
||||
elif op.item.media_policy == Item.MEDIA_POLICY_REUSE:
|
||||
raise CartError(error_messages['media_usage_not_implemented'])
|
||||
|
||||
if self._sales_channel not in op.item.sales_channels or (op.variation and self._sales_channel not in op.variation.sales_channels):
|
||||
raise CartError(error_messages['unavailable'])
|
||||
|
||||
|
||||
@@ -693,7 +693,7 @@ def _save_answers(op, answers, given_answers):
|
||||
def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False,
|
||||
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
|
||||
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY,
|
||||
raw_barcode=None, from_revoked_secret=False):
|
||||
raw_barcode=None, raw_source_type=None, from_revoked_secret=False):
|
||||
"""
|
||||
Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is
|
||||
not valid at this time.
|
||||
@@ -714,40 +714,53 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
# !!!!!!!!!
|
||||
|
||||
dt = datetime or now()
|
||||
force_used = False
|
||||
|
||||
if op.canceled or op.order.status not in (Order.STATUS_PAID, Order.STATUS_PENDING):
|
||||
raise CheckInError(
|
||||
_('This order position has been canceled.'),
|
||||
'canceled' if canceled_supported else 'unpaid'
|
||||
)
|
||||
if force:
|
||||
force_used = True
|
||||
else:
|
||||
raise CheckInError(
|
||||
_('This order position has been canceled.'),
|
||||
'canceled' if canceled_supported else 'unpaid'
|
||||
)
|
||||
|
||||
if op.blocked:
|
||||
raise CheckInError(
|
||||
_('This ticket has been blocked.'), # todo provide reason
|
||||
'blocked'
|
||||
)
|
||||
if force:
|
||||
force_used = True
|
||||
else:
|
||||
raise CheckInError(
|
||||
_('This ticket has been blocked.'), # todo provide reason
|
||||
'blocked'
|
||||
)
|
||||
|
||||
if type != Checkin.TYPE_EXIT and op.valid_from and op.valid_from > now():
|
||||
raise CheckInError(
|
||||
_('This ticket is only valid after {datetime}.').format(
|
||||
datetime=date_format(op.valid_from, 'SHORT_DATETIME_FORMAT')
|
||||
),
|
||||
'invalid_time',
|
||||
_('This ticket is only valid after {datetime}.').format(
|
||||
datetime=date_format(op.valid_from, 'SHORT_DATETIME_FORMAT')
|
||||
),
|
||||
)
|
||||
if force:
|
||||
force_used = True
|
||||
else:
|
||||
raise CheckInError(
|
||||
_('This ticket is only valid after {datetime}.').format(
|
||||
datetime=date_format(op.valid_from.astimezone(clist.event.timezone), 'SHORT_DATETIME_FORMAT')
|
||||
),
|
||||
'invalid_time',
|
||||
_('This ticket is only valid after {datetime}.').format(
|
||||
datetime=date_format(op.valid_from.astimezone(clist.event.timezone), 'SHORT_DATETIME_FORMAT')
|
||||
),
|
||||
)
|
||||
|
||||
if type != Checkin.TYPE_EXIT and op.valid_until and op.valid_until < now():
|
||||
raise CheckInError(
|
||||
_('This ticket was only valid before {datetime}.').format(
|
||||
datetime=date_format(op.valid_until, 'SHORT_DATETIME_FORMAT')
|
||||
),
|
||||
'invalid_time',
|
||||
_('This ticket was only valid before {datetime}.').format(
|
||||
datetime=date_format(op.valid_until, 'SHORT_DATETIME_FORMAT')
|
||||
),
|
||||
)
|
||||
if force:
|
||||
force_used = True
|
||||
else:
|
||||
raise CheckInError(
|
||||
_('This ticket was only valid before {datetime}.').format(
|
||||
datetime=date_format(op.valid_until.astimezone(clist.event.timezone), 'SHORT_DATETIME_FORMAT')
|
||||
),
|
||||
'invalid_time',
|
||||
_('This ticket was only valid before {datetime}.').format(
|
||||
datetime=date_format(op.valid_until.astimezone(clist.event.timezone), 'SHORT_DATETIME_FORMAT')
|
||||
),
|
||||
)
|
||||
|
||||
# Do this outside of transaction so it is saved even if the checkin fails for some other reason
|
||||
checkin_questions = list(
|
||||
@@ -770,40 +783,57 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
op = opqs.get(pk=op.pk)
|
||||
|
||||
if not clist.all_products and op.item_id not in [i.pk for i in clist.limit_products.all()]:
|
||||
raise CheckInError(
|
||||
_('This order position has an invalid product for this check-in list.'),
|
||||
'product'
|
||||
)
|
||||
elif clist.subevent_id and op.subevent_id != clist.subevent_id:
|
||||
raise CheckInError(
|
||||
_('This order position has an invalid date for this check-in list.'),
|
||||
'product'
|
||||
)
|
||||
elif op.order.status != Order.STATUS_PAID and not force and op.order.require_approval:
|
||||
raise CheckInError(
|
||||
_('This order is not yet approved.'),
|
||||
'unpaid'
|
||||
)
|
||||
elif op.order.status != Order.STATUS_PAID and not force and not op.order.valid_if_pending and not (
|
||||
if force:
|
||||
force_used = True
|
||||
else:
|
||||
raise CheckInError(
|
||||
_('This order position has an invalid product for this check-in list.'),
|
||||
'product'
|
||||
)
|
||||
|
||||
if clist.subevent_id and op.subevent_id != clist.subevent_id:
|
||||
if force:
|
||||
force_used = True
|
||||
else:
|
||||
raise CheckInError(
|
||||
_('This order position has an invalid date for this check-in list.'),
|
||||
'product'
|
||||
)
|
||||
|
||||
if op.order.status != Order.STATUS_PAID and op.order.require_approval:
|
||||
if force:
|
||||
force_used = True
|
||||
else:
|
||||
raise CheckInError(
|
||||
_('This order is not yet approved.'),
|
||||
'unpaid'
|
||||
)
|
||||
elif op.order.status != Order.STATUS_PAID and not op.order.valid_if_pending and not (
|
||||
ignore_unpaid and clist.include_pending and op.order.status == Order.STATUS_PENDING
|
||||
):
|
||||
raise CheckInError(
|
||||
_('This order is not marked as paid.'),
|
||||
'unpaid'
|
||||
)
|
||||
if force:
|
||||
force_used = True
|
||||
else:
|
||||
raise CheckInError(
|
||||
_('This order is not marked as paid.'),
|
||||
'unpaid'
|
||||
)
|
||||
|
||||
if type == Checkin.TYPE_ENTRY and clist.rules and not force:
|
||||
if type == Checkin.TYPE_ENTRY and clist.rules:
|
||||
rule_data = LazyRuleVars(op, clist, dt)
|
||||
logic = _get_logic_environment(op.subevent or clist.event)
|
||||
if not logic.apply(clist.rules, rule_data):
|
||||
reason = _logic_explain(clist.rules, op.subevent or clist.event, rule_data)
|
||||
raise CheckInError(
|
||||
_('Entry not permitted: {explanation}.').format(
|
||||
explanation=reason
|
||||
),
|
||||
'rules',
|
||||
reason=reason
|
||||
)
|
||||
if force:
|
||||
force_used = True
|
||||
else:
|
||||
reason = _logic_explain(clist.rules, op.subevent or clist.event, rule_data)
|
||||
raise CheckInError(
|
||||
_('Entry not permitted: {explanation}.').format(
|
||||
explanation=reason
|
||||
),
|
||||
'rules',
|
||||
reason=reason
|
||||
)
|
||||
|
||||
if require_answers and not force and questions_supported:
|
||||
raise RequiredQuestionsError(
|
||||
@@ -837,9 +867,10 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
device=device,
|
||||
gate=device.gate if device else None,
|
||||
nonce=nonce,
|
||||
forced=force and (not entry_allowed or from_revoked_secret),
|
||||
forced=force and (not entry_allowed or from_revoked_secret or force_used),
|
||||
force_sent=force,
|
||||
raw_barcode=raw_barcode,
|
||||
raw_source_type=raw_source_type,
|
||||
)
|
||||
op.order.log_action('pretix.event.checkin', data={
|
||||
'position': op.id,
|
||||
|
||||
@@ -95,6 +95,18 @@ class SendMailException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def clean_sender_name(sender_name: str) -> str:
|
||||
# Emails with @ in their sender name are rejected by some mailservers (e.g. Microsoft) because it looks like
|
||||
# a phishing attempt.
|
||||
sender_name = sender_name.replace("@", " ")
|
||||
|
||||
# Emails with excessively long sender names are rejected by some mailservers
|
||||
if len(sender_name) > 75:
|
||||
sender_name = sender_name[:75] + "..."
|
||||
|
||||
return sender_name
|
||||
|
||||
|
||||
def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, LazyI18nString],
|
||||
context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None,
|
||||
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None,
|
||||
@@ -196,17 +208,13 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
settings.MAIL_FROM
|
||||
)
|
||||
if event:
|
||||
sender_name = event.settings.mail_from_name or str(event.name)
|
||||
if len(sender_name) > 75:
|
||||
sender_name = sender_name[:75] + "..."
|
||||
sender_name = clean_sender_name(event.settings.mail_from_name or str(event.name))
|
||||
sender = formataddr((sender_name, sender))
|
||||
elif organizer:
|
||||
sender_name = organizer.settings.mail_from_name or str(organizer.name)
|
||||
if len(sender_name) > 75:
|
||||
sender_name = sender_name[:75] + "..."
|
||||
sender_name = clean_sender_name(organizer.settings.mail_from_name or str(organizer.name))
|
||||
sender = formataddr((sender_name, sender))
|
||||
else:
|
||||
sender = formataddr((settings.PRETIX_INSTANCE_NAME, sender))
|
||||
sender = formataddr((clean_sender_name(settings.PRETIX_INSTANCE_NAME), sender))
|
||||
|
||||
subject = raw_subject = str(subject).replace('\n', ' ').replace('\r', '')[:900]
|
||||
signature = ""
|
||||
|
||||
@@ -62,6 +62,7 @@ from pretix.api.models import OAuthApplication
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import get_language_without_region, language
|
||||
from pretix.base.media import MEDIA_TYPES
|
||||
from pretix.base.models import (
|
||||
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Membership,
|
||||
Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
|
||||
@@ -390,9 +391,15 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
|
||||
if order.total == Decimal('0.00'):
|
||||
email_template = order.event.settings.mail_text_order_approved_free
|
||||
email_subject = order.event.settings.mail_subject_order_approved_free
|
||||
email_attendees = order.event.settings.mail_send_order_approved_free_attendee
|
||||
email_attendee_template = order.event.settings.mail_text_order_approved_free_attendee
|
||||
email_attendee_subject = order.event.settings.mail_subject_order_approved_free_attendee
|
||||
else:
|
||||
email_template = order.event.settings.mail_text_order_approved
|
||||
email_subject = order.event.settings.mail_subject_order_approved
|
||||
email_attendees = order.event.settings.mail_send_order_approved_attendee
|
||||
email_attendee_template = order.event.settings.mail_text_order_approved_attendee
|
||||
email_attendee_subject = order.event.settings.mail_subject_order_approved_attendee
|
||||
|
||||
email_context = get_email_context(event=order.event, order=order)
|
||||
try:
|
||||
@@ -405,6 +412,19 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
|
||||
except SendMailException:
|
||||
logger.exception('Order approved email could not be sent')
|
||||
|
||||
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:
|
||||
email_attendee_context = get_email_context(event=order.event, order=order, position=p)
|
||||
try:
|
||||
p.send_mail(
|
||||
email_attendee_subject, email_attendee_template, email_attendee_context,
|
||||
'pretix.event.order.email.order_approved', user,
|
||||
attach_tickets=True,
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Order approved email could not be sent to attendee')
|
||||
|
||||
return order.pk
|
||||
|
||||
|
||||
@@ -1431,7 +1451,7 @@ class OrderChangeManager:
|
||||
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
|
||||
CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff'))
|
||||
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership',
|
||||
'valid_from', 'valid_until'))
|
||||
'valid_from', 'valid_until', 'is_bundled'))
|
||||
SplitOperation = namedtuple('SplitOperation', ('position',))
|
||||
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff'))
|
||||
AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff'))
|
||||
@@ -1661,6 +1681,7 @@ class OrderChangeManager:
|
||||
except TaxRule.SaleNotAllowed:
|
||||
raise OrderError(self.error_messages['tax_rule_country_blocked'])
|
||||
|
||||
is_bundled = False
|
||||
if price is None:
|
||||
raise OrderError(self.error_messages['product_invalid'])
|
||||
if item.variations.exists() and not variation:
|
||||
@@ -1669,7 +1690,10 @@ class OrderChangeManager:
|
||||
raise OrderError(self.error_messages['addon_to_required'])
|
||||
if addon_to:
|
||||
if not item.category or item.category_id not in addon_to.item.addons.values_list('addon_category', flat=True):
|
||||
raise OrderError(self.error_messages['addon_invalid'])
|
||||
if addon_to.item.bundles.filter(bundled_item=item, bundled_variation=variation).exists():
|
||||
is_bundled = True
|
||||
else:
|
||||
raise OrderError(self.error_messages['addon_invalid'])
|
||||
if self.order.event.has_subevents and not subevent:
|
||||
raise OrderError(self.error_messages['subevent_required'])
|
||||
|
||||
@@ -1694,7 +1718,7 @@ class OrderChangeManager:
|
||||
if seat:
|
||||
self._seatdiff.update([seat])
|
||||
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership,
|
||||
valid_from, valid_until))
|
||||
valid_from, valid_until, is_bundled))
|
||||
|
||||
def split(self, position: OrderPosition):
|
||||
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
|
||||
@@ -2225,6 +2249,7 @@ class OrderChangeManager:
|
||||
tax_value=op.price.tax, tax_rule=op.item.tax_rule,
|
||||
positionid=nextposid, subevent=op.subevent, seat=op.seat,
|
||||
used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until,
|
||||
is_bundled=op.is_bundled,
|
||||
)
|
||||
nextposid += 1
|
||||
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
|
||||
@@ -2911,3 +2936,32 @@ def signal_listener_issue_memberships(sender: Event, order: Order, **kwargs):
|
||||
for p in order.positions.all():
|
||||
if p.item.grant_membership_type_id:
|
||||
create_membership(order.customer, p)
|
||||
|
||||
|
||||
@receiver(order_placed, dispatch_uid="pretixbase_order_placed_media")
|
||||
@receiver(order_changed, dispatch_uid="pretixbase_order_changed_media")
|
||||
@transaction.atomic()
|
||||
def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
|
||||
from pretix.base.models import ReusableMedium
|
||||
|
||||
for p in order.positions.all():
|
||||
if p.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
|
||||
mt = MEDIA_TYPES[p.item.media_type]
|
||||
if mt.medium_created_by_server and not p.linked_media.exists():
|
||||
rm = ReusableMedium.objects.create(
|
||||
organizer=sender.organizer,
|
||||
type=p.item.media_type,
|
||||
identifier=mt.generate_identifier(sender.organizer),
|
||||
active=True,
|
||||
customer=order.customer,
|
||||
linked_orderposition=p,
|
||||
)
|
||||
rm.log_action(
|
||||
'pretix.reusable_medium.created',
|
||||
data={
|
||||
'by_order': order.code,
|
||||
'linked_orderposition': p.pk,
|
||||
'active': True,
|
||||
'customer': order.customer_id,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -169,6 +169,84 @@ DEFAULTS = {
|
||||
"was not logged in during the purchase.")
|
||||
)
|
||||
},
|
||||
'reusable_media_active': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Activate re-usable media"),
|
||||
help_text=_("The re-usable media feature allows you to connect tickets and gift cards with physical media "
|
||||
"such as wristbands or chip cards that may be re-used for different tickets or gift cards "
|
||||
"later.")
|
||||
)
|
||||
},
|
||||
'reusable_media_type_barcode': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Active"),
|
||||
)
|
||||
},
|
||||
'reusable_media_type_barcode_identifier_length': {
|
||||
'default': 24,
|
||||
'type': int,
|
||||
'form_class': forms.IntegerField,
|
||||
'serializer_class': serializers.IntegerField,
|
||||
'serializer_kwargs': dict(
|
||||
validators=[
|
||||
MinValueValidator(12),
|
||||
MaxValueValidator(64),
|
||||
]
|
||||
),
|
||||
'form_kwargs': dict(
|
||||
label=_('Length of barcodes'),
|
||||
validators=[
|
||||
MinValueValidator(12),
|
||||
MaxValueValidator(64),
|
||||
],
|
||||
required=True,
|
||||
widget=forms.NumberInput(
|
||||
attrs={
|
||||
'min': '12',
|
||||
'max': '64',
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
'reusable_media_type_nfc_uid': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Active"),
|
||||
)
|
||||
},
|
||||
'reusable_media_type_nfc_uid_autocreate_giftcard': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Automatically create a new gift card if a previously unknown chip is seen"),
|
||||
)
|
||||
},
|
||||
'reusable_media_type_nfc_uid_autocreate_giftcard_currency': {
|
||||
'default': 'EUR',
|
||||
'type': str,
|
||||
'form_class': forms.ChoiceField,
|
||||
'serializer_class': serializers.ChoiceField,
|
||||
'serializer_kwargs': dict(
|
||||
choices=[(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES],
|
||||
),
|
||||
'form_kwargs': dict(
|
||||
choices=[(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES],
|
||||
label=_("Gift card currency"),
|
||||
)
|
||||
},
|
||||
'max_items_per_order': {
|
||||
'default': '10',
|
||||
'type': int,
|
||||
@@ -516,6 +594,7 @@ DEFAULTS = {
|
||||
'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."),
|
||||
max_value=12,
|
||||
required=True,
|
||||
)
|
||||
},
|
||||
@@ -1293,9 +1372,10 @@ DEFAULTS = {
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_class': forms.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Generate tickets for add-on products"),
|
||||
help_text=_('By default, tickets are only issued for products selected individually, not for add-on '
|
||||
'products. With this option, a separate ticket is issued for every add-on product as well.'),
|
||||
label=_("Generate tickets for add-on products and bundled products"),
|
||||
help_text=_('By default, tickets are only issued for products selected individually, not for add-on products '
|
||||
'or bundled products. With this option, a separate ticket is issued for every add-on product '
|
||||
'or bundled product as well.'),
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_ticket_download',
|
||||
'data-checkbox-dependency-visual': 'on'}),
|
||||
)
|
||||
@@ -2195,6 +2275,26 @@ You can select a payment method and perform the payment here:
|
||||
|
||||
{url}
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_send_order_approved_attendee': {
|
||||
'type': bool,
|
||||
'default': 'False'
|
||||
},
|
||||
'mail_subject_order_approved_attendee': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Your event registration: {code}")),
|
||||
},
|
||||
'mail_text_order_approved_attendee': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
|
||||
|
||||
we approved a ticket ordered for you for {event}.
|
||||
|
||||
You can view the details and status of your ticket here:
|
||||
{url}
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
@@ -2212,6 +2312,26 @@ at our event. As you only ordered free products, no payment is required.
|
||||
You can change your order details and view the status of your order at
|
||||
{url}
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
'mail_send_order_approved_free_attendee': {
|
||||
'type': bool,
|
||||
'default': 'False'
|
||||
},
|
||||
'mail_subject_order_approved_free_attendee': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("Your event registration: {code}")),
|
||||
},
|
||||
'mail_text_order_approved_free_attendee': {
|
||||
'type': LazyI18nString,
|
||||
'default': LazyI18nString.from_gettext(gettext_noop("""Hello,
|
||||
|
||||
we approved a ticket ordered for you for {event}.
|
||||
|
||||
You can view the details and status of your ticket here:
|
||||
{url}
|
||||
|
||||
Best regards,
|
||||
Your {event} team"""))
|
||||
},
|
||||
@@ -3416,7 +3536,12 @@ def validate_organizer_settings(organizer, settings_dict):
|
||||
# organizer-settings either.
|
||||
#
|
||||
# N.B.: When actually fleshing out this stub, adding it to the OrganizerUpdateForm should be considered.
|
||||
pass
|
||||
"""
|
||||
if settings_dict.get('reusable_media_type_ntag_pretix1') and settings_dict.get('reusable_media_type_nfc_uid'):
|
||||
raise ValidationError({
|
||||
'reusable_media_type_nfc_uid': _('This needs to be disabled if other NFC-based types are active.')
|
||||
})
|
||||
"""
|
||||
|
||||
|
||||
def global_settings_object(holder):
|
||||
|
||||
@@ -36,6 +36,7 @@ import re
|
||||
import urllib.parse
|
||||
|
||||
import bleach
|
||||
import idna
|
||||
import markdown
|
||||
from bleach import DEFAULT_CALLBACKS
|
||||
from bleach.linkifier import build_email_re, build_url_re
|
||||
@@ -121,6 +122,12 @@ def safelink_callback(attrs, new=False):
|
||||
return attrs
|
||||
|
||||
|
||||
def idna_decode_safe(src):
|
||||
v = idna.decode(src)
|
||||
|
||||
return v
|
||||
|
||||
|
||||
def truelink_callback(attrs, new=False):
|
||||
"""
|
||||
Tries to prevent "phishing" attacks in which a link looks like it points to a safe place but instead
|
||||
@@ -136,20 +143,40 @@ def truelink_callback(attrs, new=False):
|
||||
|
||||
<a href="https://maps.google.com/location/foo">https://maps.google.com</a>
|
||||
"""
|
||||
text = re.sub(r'[^a-zA-Z0-9.\-/_ ]', '', attrs.get('_text')) # clean up link text
|
||||
text = re.sub(r'[^a-zA-Z0-9:.\-/_ ]', '', attrs.get('_text')) # clean up link text
|
||||
url = attrs.get((None, 'href'), '/')
|
||||
href_url = urllib.parse.urlparse(url)
|
||||
strip_http = False
|
||||
if (None, 'href') in attrs and URL_RE.match(text) and href_url.scheme not in ('tel', 'mailto'):
|
||||
if URL_RE.match(attrs.get('_text').strip()): # maybe we cleaned up too much
|
||||
text = attrs.get('_text').strip()
|
||||
# link text looks like a url
|
||||
if text.startswith('//'):
|
||||
text = 'https:' + text
|
||||
elif not text.startswith('http'):
|
||||
strip_http = True
|
||||
text = 'https://' + text
|
||||
|
||||
text_url = urllib.parse.urlparse(text)
|
||||
if text_url.netloc != href_url.netloc or not href_url.path.startswith(href_url.path):
|
||||
|
||||
if href_url.netloc.startswith('xn--'):
|
||||
href_netloc_nice = idna_decode_safe(href_url.netloc)
|
||||
else:
|
||||
href_netloc_nice = href_url.netloc
|
||||
|
||||
if text_url.netloc not in (href_url.netloc, href_netloc_nice) or not href_url.path.startswith(text_url.path):
|
||||
# link text contains an URL that has a different base than the actual URL
|
||||
attrs['_text'] = attrs[None, 'href']
|
||||
text_url = href_url
|
||||
|
||||
if text_url.netloc.startswith('xn--'):
|
||||
# Show punicode nicely
|
||||
text_netloc_nice = idna_decode_safe(text_url.netloc)
|
||||
url = text_url._replace(netloc=text_netloc_nice, scheme=href_url.scheme).geturl()
|
||||
if strip_http:
|
||||
url = url[len(href_url.scheme + '://'):]
|
||||
attrs['_text'] = url
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ class BaseQuestionsViewMixin:
|
||||
kwargs = self.question_form_kwargs(cr)
|
||||
form = self.form_class(event=self.request.event,
|
||||
prefix=cr.id,
|
||||
request=self.request,
|
||||
cartpos=cartpos,
|
||||
orderpos=orderpos,
|
||||
all_optional=self.all_optional,
|
||||
|
||||
@@ -39,7 +39,7 @@ from urllib.parse import urlencode, urlparse
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
|
||||
from django.core.validators import MaxValueValidator
|
||||
from django.db.models import Prefetch, Q, prefetch_related_objects
|
||||
from django.forms import (
|
||||
@@ -457,7 +457,49 @@ class EventUpdateForm(I18nModelForm):
|
||||
}
|
||||
|
||||
|
||||
class EventSettingsForm(SettingsForm):
|
||||
class EventSettingsValidationMixin:
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
settings_dict = self.obj.settings.freeze()
|
||||
settings_dict.update(data)
|
||||
validate_event_settings(self.obj, settings_dict)
|
||||
return data
|
||||
|
||||
def add_error(self, field, error):
|
||||
# Copied from Django, but with improved handling for validation errors on fields that are not part of this form
|
||||
|
||||
if not isinstance(error, ValidationError):
|
||||
error = ValidationError(error)
|
||||
|
||||
if hasattr(error, 'error_dict'):
|
||||
if field is not None:
|
||||
raise TypeError(
|
||||
"The argument `field` must be `None` when the `error` "
|
||||
"argument contains errors for multiple fields."
|
||||
)
|
||||
else:
|
||||
error = error.error_dict
|
||||
else:
|
||||
error = {field or NON_FIELD_ERRORS: error.error_list}
|
||||
|
||||
for field, error_list in error.items():
|
||||
if field != NON_FIELD_ERRORS and field not in self.fields:
|
||||
field = NON_FIELD_ERRORS
|
||||
for e in error_list:
|
||||
e.message = _('A validation error has occurred on a setting that is not part of this form: {error}').format(error=e.message)
|
||||
|
||||
if field not in self.errors:
|
||||
if field == NON_FIELD_ERRORS:
|
||||
self._errors[field] = self.error_class(error_class='nonfield')
|
||||
else:
|
||||
self._errors[field] = self.error_class()
|
||||
self._errors[field].extend(error_list)
|
||||
if field in self.cleaned_data:
|
||||
del self.cleaned_data[field]
|
||||
|
||||
|
||||
class EventSettingsForm(EventSettingsValidationMixin, SettingsForm):
|
||||
timezone = forms.ChoiceField(
|
||||
choices=((a, a) for a in common_timezones),
|
||||
label=_("Event timezone"),
|
||||
@@ -573,13 +615,8 @@ class EventSettingsForm(SettingsForm):
|
||||
return data
|
||||
|
||||
def clean(self):
|
||||
self.cleaned_data = self._resolve_virtual_keys_input(self.cleaned_data)
|
||||
data = super().clean()
|
||||
settings_dict = self.event.settings.freeze()
|
||||
settings_dict.update(data)
|
||||
|
||||
data = self._resolve_virtual_keys_input(data)
|
||||
|
||||
validate_event_settings(self.event, data)
|
||||
return data
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -702,7 +739,7 @@ class CancelSettingsForm(SettingsForm):
|
||||
).format(self.obj.settings.giftcard_expiry_years)
|
||||
|
||||
|
||||
class PaymentSettingsForm(SettingsForm):
|
||||
class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm):
|
||||
auto_fields = [
|
||||
'payment_term_mode',
|
||||
'payment_term_days',
|
||||
@@ -734,13 +771,6 @@ class PaymentSettingsForm(SettingsForm):
|
||||
raise ValidationError(_("This field is required."))
|
||||
return value
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
settings_dict = self.obj.settings.freeze()
|
||||
settings_dict.update(data)
|
||||
validate_event_settings(self.obj, data)
|
||||
return data
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['tax_rate_default'].queryset = self.obj.tax_rules.all()
|
||||
@@ -788,7 +818,7 @@ class ProviderForm(SettingsForm):
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class InvoiceSettingsForm(SettingsForm):
|
||||
class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
|
||||
|
||||
auto_fields = [
|
||||
'invoice_address_asked',
|
||||
@@ -863,13 +893,6 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
)
|
||||
self.fields['invoice_numbers_counter_length'].validators.append(MaxValueValidator(15))
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
settings_dict = self.obj.settings.freeze()
|
||||
settings_dict.update(data)
|
||||
validate_event_settings(self.obj, data)
|
||||
return data
|
||||
|
||||
|
||||
def contains_web_channel_validate(val):
|
||||
if "web" not in val:
|
||||
@@ -1168,6 +1191,24 @@ class MailSettingsForm(SettingsForm):
|
||||
help_text=_("This will only be sent out for non-free orders. Free orders will receive the free order "
|
||||
"template from below instead."),
|
||||
)
|
||||
mail_send_order_approved_attendee = forms.BooleanField(
|
||||
label=_("Send an email to attendees"),
|
||||
help_text=_('If the order contains attendees with email addresses different from the person who orders the '
|
||||
'tickets, the following email will be sent out to the attendees.'),
|
||||
required=False,
|
||||
)
|
||||
mail_subject_order_approved_attendee = I18nFormField(
|
||||
label=_("Subject sent to attendees"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_order_approved_attendee = I18nFormField(
|
||||
label=_("Text sent to attendees"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("This will only be sent out for non-free orders. Free orders will receive the free order "
|
||||
"template from below instead."),
|
||||
)
|
||||
mail_subject_order_approved_free = I18nFormField(
|
||||
label=_("Subject for approved free order"),
|
||||
required=False,
|
||||
@@ -1180,6 +1221,24 @@ class MailSettingsForm(SettingsForm):
|
||||
help_text=_("This will only be sent out for free orders. Non-free orders will receive the non-free order "
|
||||
"template from above instead."),
|
||||
)
|
||||
mail_send_order_approved_free_attendee = forms.BooleanField(
|
||||
label=_("Send an email to attendees"),
|
||||
help_text=_('If the order contains attendees with email addresses different from the person who orders the '
|
||||
'tickets, the following email will be sent out to the attendees.'),
|
||||
required=False,
|
||||
)
|
||||
mail_subject_order_approved_free_attendee = I18nFormField(
|
||||
label=_("Subject sent to attendees"),
|
||||
required=False,
|
||||
widget=I18nTextInput,
|
||||
)
|
||||
mail_text_order_approved_free_attendee = I18nFormField(
|
||||
label=_("Text sent to attendees"),
|
||||
required=False,
|
||||
widget=I18nTextarea,
|
||||
help_text=_("This will only be sent out for free orders. Non-free orders will receive the non-free order "
|
||||
"template from above instead."),
|
||||
)
|
||||
mail_subject_order_denied = I18nFormField(
|
||||
label=_("Subject for denied order"),
|
||||
required=False,
|
||||
|
||||
@@ -256,9 +256,13 @@ class OrderFilterForm(FilterForm):
|
||||
else:
|
||||
code = Q(code__icontains=Order.normalize_code(u))
|
||||
|
||||
invoice_nos = {u, u.upper()}
|
||||
if u.isdigit():
|
||||
for i in range(2, 12):
|
||||
invoice_nos.add(u.zfill(i))
|
||||
|
||||
matching_invoices = Invoice.objects.filter(
|
||||
Q(invoice_no__iexact=u)
|
||||
| Q(invoice_no__iexact=u.zfill(5))
|
||||
Q(invoice_no__in=invoice_nos)
|
||||
| Q(full_invoice_no__iexact=u)
|
||||
).values_list('order_id', flat=True)
|
||||
matching_positions = OrderPosition.objects.filter(
|
||||
@@ -569,6 +573,12 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
|
||||
label=_('Sales channel'),
|
||||
required=False,
|
||||
)
|
||||
checkin_attention = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=FilterNullBooleanSelect,
|
||||
label=_('Requires special attention'),
|
||||
help_text=_('Only matches orders with the attention checkbox set directly for the order, not based on the product.'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -689,6 +699,8 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
|
||||
qs = qs.filter(total=fdata.get('total'))
|
||||
if fdata.get('email_known_to_work') is not None:
|
||||
qs = qs.filter(email_known_to_work=fdata.get('email_known_to_work'))
|
||||
if fdata.get('checkin_attention') is not None:
|
||||
qs = qs.filter(checkin_attention=fdata.get('checkin_attention'))
|
||||
if fdata.get('locale'):
|
||||
qs = qs.filter(locale=fdata.get('locale'))
|
||||
if fdata.get('payment_sum_min') is not None:
|
||||
@@ -996,9 +1008,13 @@ class OrderPaymentSearchFilterForm(forms.Form):
|
||||
if fdata.get('query'):
|
||||
u = fdata.get('query')
|
||||
|
||||
invoice_nos = {u, u.upper()}
|
||||
if u.isdigit():
|
||||
for i in range(2, 12):
|
||||
invoice_nos.add(u.zfill(i))
|
||||
|
||||
matching_invoices = Invoice.objects.filter(
|
||||
Q(invoice_no__iexact=u)
|
||||
| Q(invoice_no__iexact=u.zfill(5))
|
||||
Q(invoice_no__in=invoice_nos)
|
||||
| Q(full_invoice_no__iexact=u)
|
||||
).values_list('order_id', flat=True)
|
||||
|
||||
@@ -1419,6 +1435,62 @@ class CustomerFilterForm(FilterForm):
|
||||
return qs.distinct()
|
||||
|
||||
|
||||
class ReusableMediaFilterForm(FilterForm):
|
||||
orders = {
|
||||
'type': 'type',
|
||||
'identifier': 'identifier',
|
||||
}
|
||||
query = forms.CharField(
|
||||
label=_('Search query'),
|
||||
widget=forms.TextInput(attrs={
|
||||
'placeholder': _('Search query'),
|
||||
'autofocus': 'autofocus'
|
||||
}),
|
||||
required=False
|
||||
)
|
||||
status = forms.ChoiceField(
|
||||
label=_('Status'),
|
||||
required=False,
|
||||
choices=(
|
||||
('', _('All')),
|
||||
('active', _('active')),
|
||||
('disabled', _('disabled')),
|
||||
('expired', _('expired')),
|
||||
)
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.pop('request')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def filter_qs(self, qs):
|
||||
fdata = self.cleaned_data
|
||||
|
||||
if fdata.get('query'):
|
||||
query = fdata.get('query')
|
||||
qs = qs.filter(
|
||||
Q(identifier__icontains=query)
|
||||
| Q(customer__identifier__icontains=query)
|
||||
| Q(customer__external_identifier__istartswith=query)
|
||||
| Q(linked_orderposition__order__code__icontains=query)
|
||||
| Q(linked_giftcard__secret__icontains=query)
|
||||
)
|
||||
|
||||
if fdata.get('status') == 'active':
|
||||
qs = qs.filter(Q(expires__gt=now()) | Q(expires__isnull=False), active=True)
|
||||
elif fdata.get('status') == 'disabled':
|
||||
qs = qs.filter(active=False)
|
||||
elif fdata.get('status') == 'expired':
|
||||
qs = qs.filter(expires__lte=now())
|
||||
|
||||
if fdata.get('ordering'):
|
||||
qs = qs.order_by(self.get_order_by())
|
||||
else:
|
||||
qs = qs.order_by("identifier", "type", "organizer")
|
||||
|
||||
return qs.distinct()
|
||||
|
||||
|
||||
class TeamFilterForm(FilterForm):
|
||||
orders = {
|
||||
'name': 'name',
|
||||
|
||||
@@ -168,6 +168,7 @@ class QuestionForm(I18nModelForm):
|
||||
'valid_date_min',
|
||||
'valid_date_max',
|
||||
'valid_file_portrait',
|
||||
'valid_string_length_max',
|
||||
]
|
||||
widgets = {
|
||||
'valid_datetime_min': SplitDateTimePickerWidget(),
|
||||
@@ -401,6 +402,8 @@ class ItemCreateForm(I18nModelForm):
|
||||
'validity_dynamic_duration_months',
|
||||
'validity_dynamic_start_choice',
|
||||
'validity_dynamic_start_choice_day_limit',
|
||||
'media_type',
|
||||
'media_policy',
|
||||
)
|
||||
for f in fields:
|
||||
setattr(self.instance, f, getattr(src, f))
|
||||
@@ -592,6 +595,10 @@ class ItemUpdateForm(I18nModelForm):
|
||||
del self.fields['grant_membership_duration_days']
|
||||
del self.fields['grant_membership_duration_months']
|
||||
|
||||
if not self.event.settings.reusable_media_active:
|
||||
del self.fields['media_type']
|
||||
del self.fields['media_policy']
|
||||
|
||||
def clean(self):
|
||||
d = super().clean()
|
||||
if d['issue_giftcard']:
|
||||
@@ -635,6 +642,8 @@ class ItemUpdateForm(I18nModelForm):
|
||||
_("The start of validity must be before the end of validity.")
|
||||
)
|
||||
|
||||
Item.clean_media_settings(self.event, d.get('media_policy'), d.get('media_type'), d.get('issue_giftcard'))
|
||||
|
||||
return d
|
||||
|
||||
def clean_picture(self):
|
||||
@@ -693,6 +702,8 @@ class ItemUpdateForm(I18nModelForm):
|
||||
'validity_dynamic_duration_months',
|
||||
'validity_dynamic_start_choice',
|
||||
'validity_dynamic_start_choice_day_limit',
|
||||
'media_policy',
|
||||
'media_type',
|
||||
]
|
||||
field_classes = {
|
||||
'available_from': SplitDateTimeField,
|
||||
|
||||
@@ -41,10 +41,14 @@ from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from django.forms import inlineformset_factory
|
||||
from django.forms.utils import ErrorDict
|
||||
from django.urls import reverse
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes.forms import SafeModelMultipleChoiceField
|
||||
from django_scopes.forms import (
|
||||
SafeModelChoiceField, SafeModelMultipleChoiceField,
|
||||
)
|
||||
from i18nfield.forms import (
|
||||
I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput,
|
||||
)
|
||||
@@ -62,15 +66,18 @@ from pretix.base.forms.questions import (
|
||||
from pretix.base.forms.widgets import SplitDateTimePickerWidget
|
||||
from pretix.base.models import (
|
||||
Customer, Device, EventMetaProperty, Gate, GiftCard, Membership,
|
||||
MembershipType, Organizer, Team,
|
||||
MembershipType, OrderPosition, Organizer, ReusableMedium, Team,
|
||||
)
|
||||
from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider
|
||||
from pretix.base.models.organizer import OrganizerFooterLink
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS
|
||||
from pretix.base.settings import (
|
||||
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_organizer_settings,
|
||||
)
|
||||
from pretix.control.forms import ExtFileField, SplitDateTimeField
|
||||
from pretix.control.forms.event import (
|
||||
SafeEventMultipleChoiceField, multimail_validate,
|
||||
)
|
||||
from pretix.control.forms.widgets import Select2
|
||||
from pretix.multidomain.models import KnownDomain
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
@@ -208,6 +215,7 @@ class TeamForm(forms.ModelForm):
|
||||
fields = ['name', 'all_events', 'limit_events', 'can_create_events',
|
||||
'can_change_teams', 'can_change_organizer_settings',
|
||||
'can_manage_gift_cards', 'can_manage_customers',
|
||||
'can_manage_reusable_media',
|
||||
'can_change_event_settings', 'can_change_items',
|
||||
'can_view_orders', 'can_change_orders', 'can_checkin_orders',
|
||||
'can_view_vouchers', 'can_change_vouchers']
|
||||
@@ -389,6 +397,12 @@ class OrganizerSettingsForm(SettingsForm):
|
||||
'cookie_consent_dialog_text_secondary',
|
||||
'cookie_consent_dialog_button_yes',
|
||||
'cookie_consent_dialog_button_no',
|
||||
'reusable_media_active',
|
||||
'reusable_media_type_barcode',
|
||||
'reusable_media_type_barcode_identifier_length',
|
||||
'reusable_media_type_nfc_uid',
|
||||
'reusable_media_type_nfc_uid_autocreate_giftcard',
|
||||
'reusable_media_type_nfc_uid_autocreate_giftcard_currency',
|
||||
]
|
||||
|
||||
organizer_logo_image = ExtFileField(
|
||||
@@ -431,6 +445,26 @@ class OrganizerSettingsForm(SettingsForm):
|
||||
))
|
||||
for k, v in PERSON_NAME_TITLE_GROUPS.items()
|
||||
]
|
||||
self.fields['reusable_media_active'].label = mark_safe(
|
||||
conditional_escape(self.fields['reusable_media_active'].label) +
|
||||
' ' +
|
||||
'<span class="label label-info">{}</span>'.format(_('experimental'))
|
||||
)
|
||||
self.fields['reusable_media_active'].help_text = mark_safe(
|
||||
conditional_escape(self.fields['reusable_media_active'].help_text) +
|
||||
' ' +
|
||||
'<br/><span class="fa fa-flask"></span> ' +
|
||||
_('This feature is currently in an experimental stage. It only supports very limited use cases and might '
|
||||
'change at any point.')
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
settings_dict = self.obj.settings.freeze()
|
||||
settings_dict.update(data)
|
||||
|
||||
validate_organizer_settings(self.obj, data)
|
||||
return data
|
||||
|
||||
|
||||
class MailSettingsForm(SettingsForm):
|
||||
@@ -626,6 +660,116 @@ class GiftCardUpdateForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class SafeOrderPositionChoiceField(forms.ModelChoiceField):
|
||||
def __init__(self, queryset, **kwargs):
|
||||
queryset = queryset.model.all.none()
|
||||
super().__init__(queryset, **kwargs)
|
||||
|
||||
def label_from_instance(self, op):
|
||||
return f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})'
|
||||
|
||||
|
||||
class ReusableMediumUpdateForm(forms.ModelForm):
|
||||
error_messages = {
|
||||
'duplicate': _("An medium with this type and identifier is already registered."),
|
||||
}
|
||||
|
||||
class Meta:
|
||||
model = ReusableMedium
|
||||
fields = ['active', 'expires', 'customer', 'linked_giftcard', 'linked_orderposition', 'notes']
|
||||
field_classes = {
|
||||
'expires': SplitDateTimeField,
|
||||
'customer': SafeModelChoiceField,
|
||||
'linked_giftcard': SafeModelChoiceField,
|
||||
'linked_orderposition': SafeOrderPositionChoiceField,
|
||||
}
|
||||
widgets = {
|
||||
'expires': SplitDateTimePickerWidget,
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
organizer = self.instance.organizer
|
||||
|
||||
self.fields['linked_orderposition'].queryset = OrderPosition.all.filter(order__event__organizer=organizer).all()
|
||||
self.fields['linked_orderposition'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
'data-select2-url': reverse('control:organizer.ticket_select2', kwargs={
|
||||
'organizer': organizer.slug,
|
||||
}),
|
||||
'data-placeholder': _('Ticket')
|
||||
}
|
||||
)
|
||||
self.fields['linked_orderposition'].widget.choices = self.fields['linked_orderposition'].choices
|
||||
self.fields['linked_orderposition'].required = False
|
||||
|
||||
self.fields['linked_giftcard'].queryset = organizer.issued_gift_cards.all()
|
||||
self.fields['linked_giftcard'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
'data-select2-url': reverse('control:organizer.giftcards.select2', kwargs={
|
||||
'organizer': organizer.slug,
|
||||
}),
|
||||
'data-placeholder': _('Gift card')
|
||||
}
|
||||
)
|
||||
self.fields['linked_giftcard'].widget.choices = self.fields['linked_giftcard'].choices
|
||||
self.fields['linked_giftcard'].required = False
|
||||
|
||||
if organizer.settings.customer_accounts:
|
||||
self.fields['customer'].queryset = organizer.customers.all()
|
||||
self.fields['customer'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
'data-select2-url': reverse('control:organizer.customers.select2', kwargs={
|
||||
'organizer': organizer.slug,
|
||||
}),
|
||||
'data-placeholder': _('Customer')
|
||||
}
|
||||
)
|
||||
self.fields['customer'].widget.choices = self.fields['customer'].choices
|
||||
self.fields['customer'].required = False
|
||||
else:
|
||||
del self.fields['customer']
|
||||
|
||||
def clean(self):
|
||||
identifier = self.cleaned_data.get('identifier')
|
||||
type = self.cleaned_data.get('type')
|
||||
|
||||
if identifier is not None and type is not None:
|
||||
try:
|
||||
self.instance.organizer.reusable_media.exclude(pk=self.instance.pk).get(
|
||||
identifier=identifier,
|
||||
type=type,
|
||||
)
|
||||
except ReusableMedium.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['duplicate'],
|
||||
code='duplicate',
|
||||
)
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
|
||||
class ReusableMediumCreateForm(ReusableMediumUpdateForm):
|
||||
|
||||
class Meta:
|
||||
model = ReusableMedium
|
||||
fields = ['active', 'type', 'identifier', 'expires', 'linked_orderposition', 'linked_giftcard', 'customer', 'notes']
|
||||
field_classes = {
|
||||
'expires': SplitDateTimeField,
|
||||
'customer': SafeModelChoiceField,
|
||||
'linked_giftcard': SafeModelChoiceField,
|
||||
'linked_orderposition': SafeOrderPositionChoiceField,
|
||||
}
|
||||
widgets = {
|
||||
'expires': SplitDateTimePickerWidget,
|
||||
}
|
||||
|
||||
|
||||
class CustomerUpdateForm(forms.ModelForm):
|
||||
error_messages = {
|
||||
'duplicate': _("An account with this email address is already registered."),
|
||||
|
||||
@@ -350,6 +350,8 @@ class VoucherBulkForm(VoucherForm):
|
||||
reader = csv.DictReader(StringIO(raw), dialect=dialect)
|
||||
except csv.Error as e:
|
||||
raise ValidationError(_('CSV parsing failed: {error}.').format(error=str(e)))
|
||||
if len(reader.fieldnames) == 1 and ',' in reader.fieldnames[0]:
|
||||
raise ValidationError(_('CSV input was not recognized to have multiple columns, maybe you have some invalid quoted field in your input.'))
|
||||
if 'email' not in reader.fieldnames:
|
||||
raise ValidationError(_('CSV input needs to contain a field with the header "{header}".').format(header="email"))
|
||||
unknown_fields = [f for f in reader.fieldnames if f not in ('email', 'name', 'tag', 'number')]
|
||||
|
||||
@@ -362,6 +362,10 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.customer.anonymized': _('The account has been disabled and anonymized.'),
|
||||
'pretix.customer.password.resetrequested': _('A new password has been requested.'),
|
||||
'pretix.customer.password.set': _('A new password has been set.'),
|
||||
'pretix.reusable_medium.created': _('The reusable medium has been created.'),
|
||||
'pretix.reusable_medium.created.auto': _('The reusable medium has been created automatically.'),
|
||||
'pretix.reusable_medium.changed': _('The reusable medium has been changed.'),
|
||||
'pretix.reusable_medium.linked_orderposition.changed': _('The medium has been connected to a new ticket.'),
|
||||
'pretix.email.error': _('Sending of an email has failed.'),
|
||||
'pretix.event.comment': _('The event\'s internal comment has been updated.'),
|
||||
'pretix.event.canceled': _('The event has been canceled.'),
|
||||
|
||||
@@ -80,7 +80,7 @@ class PermissionMiddleware:
|
||||
"user.settings.2fa.disable",
|
||||
"user.settings.2fa.regenemergency",
|
||||
"user.settings.2fa.confirm.totp",
|
||||
"user.settings.2fa.confirm.u2f",
|
||||
"user.settings.2fa.confirm.webauthn",
|
||||
"user.settings.2fa.delete",
|
||||
"auth.logout",
|
||||
"user.reauth"
|
||||
|
||||
@@ -578,6 +578,16 @@ def get_organizer_navigation(request):
|
||||
'children': children,
|
||||
})
|
||||
|
||||
if request.organizer.settings.reusable_media_active:
|
||||
nav.append({
|
||||
'label': _('Reusable media'),
|
||||
'url': reverse('control:organizer.reusable_media', kwargs={
|
||||
'organizer': request.organizer.slug
|
||||
}),
|
||||
'icon': 'key',
|
||||
'active': 'organizer.reusable_medi' in url.url_name,
|
||||
})
|
||||
|
||||
if 'can_change_organizer_settings' in request.orgapermset:
|
||||
nav.append({
|
||||
'label': _('Devices'),
|
||||
|
||||
@@ -13,20 +13,25 @@
|
||||
{% elif payment_info.payment_type == "terminal_zvt" %}
|
||||
<dt>{% trans "Payment provider" %}</dt>
|
||||
<dd>{% trans "ZVT Terminal" %}</dd>
|
||||
<dt>{% trans "Trace number" context "terminal_zvt" %}</dt>
|
||||
<dd>{{ payment_info.payment_data.traceNumber }}</dd>
|
||||
<dt>{% trans "Payment type" context "terminal_zvt" %}</dt>
|
||||
<dd>{{ payment_info.payment_data.paymentType }}</dd>
|
||||
<dt>{% trans "Additional text" context "terminal_zvt" %}</dt>
|
||||
<dd>{{ payment_info.payment_data.additionalText }}</dd>
|
||||
<dt>{% trans "Turnover number" context "terminal_zvt" %}</dt>
|
||||
<dd>{{ payment_info.payment_data.turnoverNumber }}</dd>
|
||||
<dt>{% trans "Receipt number" context "terminal_zvt" %}</dt>
|
||||
<dd>{{ payment_info.payment_data.receiptNumber }}</dd>
|
||||
<dt>{% trans "Card type" context "terminal_zvt" %}</dt>
|
||||
<dd>{{ payment_info.payment_data.cardName|default_if_none:payment_info.payment_data.cardType }}</dd>
|
||||
<dt>{% trans "Card expiration" context "terminal_zvt" %}</dt>
|
||||
<dd>{{ payment_info.payment_data.expiry }}</dd>
|
||||
{% if payment_info.payment_data.source == "manual" %}
|
||||
<dt>{% trans "Confirmation mode" context "terminal_zvt" %}</dt>
|
||||
<dd>{{ payment_info.payment_data.source }}</dd>
|
||||
{% else %}
|
||||
<dt>{% trans "Trace number" context "terminal_zvt" %}</dt>
|
||||
<dd>{{ payment_info.payment_data.traceNumber }}</dd>
|
||||
<dt>{% trans "Payment type" context "terminal_zvt" %}</dt>
|
||||
<dd>{{ payment_info.payment_data.paymentType }}</dd>
|
||||
<dt>{% trans "Additional text" context "terminal_zvt" %}</dt>
|
||||
<dd>{{ payment_info.payment_data.additionalText }}</dd>
|
||||
<dt>{% trans "Turnover number" context "terminal_zvt" %}</dt>
|
||||
<dd>{{ payment_info.payment_data.turnoverNumber }}</dd>
|
||||
<dt>{% trans "Receipt number" context "terminal_zvt" %}</dt>
|
||||
<dd>{{ payment_info.payment_data.receiptNumber }}</dd>
|
||||
<dt>{% trans "Card type" context "terminal_zvt" %}</dt>
|
||||
<dd>{{ payment_info.payment_data.cardName|default_if_none:payment_info.payment_data.cardType }}</dd>
|
||||
<dt>{% trans "Card expiration" context "terminal_zvt" %}</dt>
|
||||
<dd>{{ payment_info.payment_data.expiry }}</dd>
|
||||
{% endif %}
|
||||
{% elif payment_info.payment_type == "sumup" %}
|
||||
<dt>{% trans "Payment provider" %}</dt>
|
||||
<dd>SumUp</dd>
|
||||
|
||||
@@ -16,6 +16,42 @@
|
||||
{% endif %}
|
||||
</small>
|
||||
</h1>
|
||||
<div class="helper-space-below">
|
||||
{% trans "Shop URL:" %}
|
||||
<span id="shop_url" class="text-muted">{% humanabseventurl request.event "presale:event.index" %}</span>
|
||||
<button type="button" class="btn btn-default btn-xs btn-clipboard js-only" data-clipboard-target="#shop_url">
|
||||
<i class="fa fa-clipboard" aria-hidden="true"></i>
|
||||
<span class="sr-only">{% trans "Copy to clipboard" %}</span>
|
||||
</button>
|
||||
<div class="btn-group helper-display-inline-block">
|
||||
<button type="button" class="btn btn-default btn-xs dropdown-toggle" data-toggle="dropdown" title="{% trans "Create QR code" %}" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fa fa-qrcode" aria-hidden="true"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li>
|
||||
<a href="{% url "control:event.qrcode" event=request.event.slug organizer=request.organizer.slug filetype="png" %}" target="_blank" download>
|
||||
{% blocktrans with filetype="PNG" %}Download QR code as {{ filetype }} image{% endblocktrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url "control:event.qrcode" event=request.event.slug organizer=request.organizer.slug filetype="svg" %}" target="_blank" download>
|
||||
{% blocktrans with filetype="SVG" %}Download QR code as {{ filetype }} image{% endblocktrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url "control:event.qrcode" event=request.event.slug organizer=request.organizer.slug filetype="jpeg" %}" target="_blank" download>
|
||||
{% blocktrans with filetype="JPG" %}Download QR code as {{ filetype }} image{% endblocktrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url "control:event.qrcode" event=request.event.slug organizer=request.organizer.slug filetype="gif" %}" target="_blank" download>
|
||||
{% blocktrans with filetype="GIF" %}Download QR code as {{ filetype }} image{% endblocktrans %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{% if has_overpaid_orders %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_download_tickets_reminder items="mail_days_download_reminder,mail_subject_download_reminder,mail_text_download_reminder,mail_send_download_reminder_attendee,mail_subject_download_reminder_attendee,mail_text_download_reminder_attendee,mail_sales_channel_download_reminder" exclude="mail_days_download_reminder,mail_send_download_reminder_attendee,mail_sales_channel_download_reminder" %}
|
||||
|
||||
{% 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_subject_order_placed_require_approval,mail_text_order_placed_require_approval,mail_subject_order_approved,mail_text_order_approved,mail_subject_order_approved_free,mail_text_order_approved_free,mail_subject_order_denied,mail_text_order_denied" %}
|
||||
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_require_approval items="mail_subject_order_placed_require_approval,mail_text_order_placed_require_approval,mail_subject_order_approved,mail_text_order_approved,mail_send_order_approved_attendee,mail_subject_order_approved_attendee,mail_text_order_approved_attendee,mail_subject_order_approved_free,mail_text_order_approved_free,mail_send_order_approved_free_attendee,mail_subject_order_approved_free_attendee,mail_text_order_approved_free_attendee,mail_subject_order_denied,mail_text_order_denied" exclude="mail_send_order_approved_attendee,mail_send_order_approved_free_attendee"%}
|
||||
</div>
|
||||
<h4>{% trans "Attachments" %}</h4>
|
||||
{% bootstrap_field form.mail_attachment_new_order layout="control" %}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_form_errors sform %}
|
||||
<div class="tabbed-form">
|
||||
<fieldset>
|
||||
<legend>{% trans "Basics" %}</legend>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{% load formset_tags %}
|
||||
{% block inside %}
|
||||
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
|
||||
{% bootstrap_form_errors form layout="control" %}
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-lg-10">
|
||||
@@ -178,6 +179,12 @@
|
||||
<fieldset>
|
||||
<legend>{% trans "Tickets & Badges" %}</legend>
|
||||
{% bootstrap_field form.generate_tickets layout="control" %}
|
||||
{% if form.media_policy %}
|
||||
{% bootstrap_field form.media_policy layout="control" %}
|
||||
{% endif %}
|
||||
{% if form.media_type %}
|
||||
{% bootstrap_field form.media_type layout="control" %}
|
||||
{% endif %}
|
||||
{% for f in plugin_forms %}
|
||||
{% if f.is_layouts %}
|
||||
{% bootstrap_form f layout="control" %}
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
{% bootstrap_field form.valid_datetime_min layout="control" %}
|
||||
{% bootstrap_field form.valid_datetime_max layout="control" %}
|
||||
</div>
|
||||
<div id="valid-string">
|
||||
{% bootstrap_field form.valid_string_length_max layout="control" %}
|
||||
</div>
|
||||
<div id="valid-file">
|
||||
{% bootstrap_field form.valid_file_portrait layout="control" %}
|
||||
</div>
|
||||
|
||||
@@ -364,7 +364,7 @@
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% for line in items.positions %}
|
||||
<div class="row-fluid product-row {% if line.canceled %}pos-canceled{% endif %}">
|
||||
<div class="row-fluid product-row {% if line.canceled %}pos-canceled{% endif %} {% if line.item.require_approval and order.require_approval and order.status == 'n' %}bg-warning{% endif %}">
|
||||
<div class="col-md-9 col-xs-6">
|
||||
{% if line.addon_to %}
|
||||
<span class="addon-signifier">+</span>
|
||||
@@ -462,6 +462,14 @@
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for m in line.linked_media.all %}
|
||||
<div class="cart-icon-details">
|
||||
<dd>
|
||||
<span class="fa fa-key fa-fw" aria-hidden="true"></span>
|
||||
<a href="{% url "control:organizer.reusable_medium" organizer=request.organizer.slug pk=m.pk %}">{{ m.identifier }}</a> <span class="text-muted">({{ m.get_type_display }})</span>
|
||||
</dd>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not line.canceled %}
|
||||
<div class="position-buttons">
|
||||
{% if line.generate_ticket %}
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
<script type="text/json" data-replace-with-qr>{{ qrdata|safe }}</script><br>
|
||||
{% trans "If your app/device does not support scanning a QR code, you can also enter the following information:" %}
|
||||
<br>
|
||||
<strong>{% trans "System URL:" %}</strong> {{ settings.SITE_URL }}<br>
|
||||
<strong>{% trans "Token:" %}</strong> {{ device.initialization_token }}
|
||||
<strong>{% trans "System URL:" %}</strong> <code>{{ settings.SITE_URL }}</code><br>
|
||||
<strong>{% trans "Token:" %}</strong> <code>{{ device.initialization_token }}</code>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@@ -200,6 +200,70 @@
|
||||
{% bootstrap_field sform.cookie_consent_dialog_button_yes layout="control" %}
|
||||
{% bootstrap_field sform.cookie_consent_dialog_button_no layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Reusable media" %}</legend>
|
||||
{% bootstrap_field sform.reusable_media_active layout="control" %}
|
||||
<div data-display-dependency="#{{ sform.reusable_media_active.id_for_label }}">
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">{% trans "Barcode media" %}</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
A "barcode medium" can be any printed or digital representation of a barcode.
|
||||
The medium will initially be created through the sale of a product that has a
|
||||
media policy requiring such a medium as well as a ticket or badge layout that
|
||||
includes the "Reusable Medium ID" as a QR code. Later, the same barcode may
|
||||
be re-used during the sale of a different product.
|
||||
{% endblocktrans %}
|
||||
{% blocktrans trimmed %}
|
||||
Barcode media can currently only be connected to tickets.
|
||||
{% endblocktrans %}
|
||||
{% blocktrans trimmed %}
|
||||
This subsequent reuse of the barcode is currently only supported during POS sales.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% bootstrap_field sform.reusable_media_type_barcode layout="control" %}
|
||||
<div data-display-dependency="#{{ sform.reusable_media_type_barcode.id_for_label }}">
|
||||
{% bootstrap_field sform.reusable_media_type_barcode_identifier_length layout="control" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">{% trans "NFC UID-based" %}</h4>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
This medium type can work with almost any type of NFC chip. With this
|
||||
option, only the UID of the NFC chip is used for identification.
|
||||
{% endblocktrans %}
|
||||
{% blocktrans trimmed %}
|
||||
NFC media can currently only be connected to gift cards.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p class="help-block">
|
||||
<span class="fa fa-warning text-warning"></span>
|
||||
{% blocktrans trimmed %}
|
||||
This method does not provide a high level of protection against abuse since it
|
||||
is possible for malicious users to clone someone's chip with the same UID.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% bootstrap_field sform.reusable_media_type_nfc_uid layout="control" %}
|
||||
<div data-display-dependency="#{{ sform.reusable_media_type_nfc_uid.id_for_label }}">
|
||||
{% bootstrap_field sform.reusable_media_type_nfc_uid_autocreate_giftcard layout="control" %}
|
||||
<div data-display-dependency="#{{ sform.reusable_media_type_nfc_uid_autocreate_giftcard.id_for_label }}">
|
||||
{% bootstrap_field sform.reusable_media_type_nfc_uid_autocreate_giftcard_currency layout="control" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Invoices" %}</legend>
|
||||
{% bootstrap_field sform.invoice_regenerate_allowed layout="control" %}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load urlreplace %}
|
||||
{% load bootstrap3 %}
|
||||
{% load money %}
|
||||
{% block title %}{% trans "Reusable media" %}{% endblock %}
|
||||
{% block inner %}
|
||||
<h1>
|
||||
{% trans "Reusable media" %}
|
||||
</h1>
|
||||
{% if media|length == 0 and not filter_form.filtered %}
|
||||
<div class="empty-collection">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
No media have been created yet.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<a href="{% url "control:organizer.reusable_medium.create" organizer=request.organizer.slug %}"
|
||||
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new medium" %}</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{% trans "Filter" %}</h3>
|
||||
</div>
|
||||
<form class="panel-body filter-form" action="" method="get">
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.query layout='inline' %}
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-6 col-xs-12">
|
||||
{% bootstrap_field filter_form.status layout='inline' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<button class="btn btn-primary btn-lg" type="submit">
|
||||
<span class="fa fa-filter"></span>
|
||||
{% trans "Filter" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<p>
|
||||
<a href="{% url "control:organizer.reusable_medium.create" organizer=request.organizer.slug %}"
|
||||
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new medium" %}</a>
|
||||
</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Identifier" context "reusable_media" %}
|
||||
<a href="?{% url_replace request 'ordering' '-identifier' %}"><i
|
||||
class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'identifier' %}"><i class="fa fa-caret-up"></i></a>
|
||||
</th>
|
||||
<th>{% trans "Media type" context "reusable_media" %}
|
||||
<a href="?{% url_replace request 'ordering' '-email' %}"><i class="fa fa-caret-down"></i></a>
|
||||
<a href="?{% url_replace request 'ordering' 'email' %}"><i class="fa fa-caret-up"></i></a></th>
|
||||
<th>{% trans "Connections" context "reusable_media" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in media %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url "control:organizer.reusable_medium" organizer=request.organizer.slug pk=m.pk %}">
|
||||
{% if not m.active %}<strike>{% endif %}
|
||||
<strong>{{ m.identifier }}</strong>
|
||||
{% if not m.active %}</strike>{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ m.get_type_display }}
|
||||
</td>
|
||||
<td>
|
||||
{% if m.customer %}
|
||||
<span class="helper-display-block">
|
||||
<span class="fa fa-user fa-fw"></span>
|
||||
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=m.customer.identifier %}">
|
||||
{{ m.customer }}
|
||||
</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if m.linked_orderposition %}
|
||||
<span class="helper-display-block">
|
||||
<span class="fa fa-ticket fa-fw"></span>
|
||||
<a href="{% url "control:event.order" event=m.linked_orderposition.order.event.slug organizer=request.organizer.slug code=m.linked_orderposition.order.code %}">
|
||||
{{ m.linked_orderposition.order.code }}</a>-{{ m.linked_orderposition.positionid }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if m.linked_giftcard %}
|
||||
<span class="helper-display-block">
|
||||
<span class="fa fa-credit-card fa-fw"></span>
|
||||
<a href="{% url "control:organizer.giftcard" organizer=request.organizer.slug giftcard=m.linked_giftcard.id %}">
|
||||
{{ m.linked_giftcard.secret }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<a href="{% url "control:organizer.reusable_medium" organizer=request.organizer.slug pk=m.pk %}"
|
||||
class="btn btn-default btn-sm" data-toggle="tooltip" title="{% trans "Details" %}">
|
||||
<i class="fa fa-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,93 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load money %}
|
||||
{% block title %}
|
||||
{% blocktrans trimmed with id=medium.identifier context "reusable_media" %}
|
||||
Medium {{ id }}
|
||||
{% endblocktrans %}
|
||||
{% endblock %}
|
||||
{% block inner %}
|
||||
<h1>
|
||||
{% blocktrans trimmed with id=medium.identifier context "reusable_media" %}
|
||||
Medium {{ id }}
|
||||
{% endblocktrans %}
|
||||
</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-xs-12">
|
||||
<div class="panel panel-primary items">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{% trans "Details" %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<dl class="dl-horizontal">
|
||||
<dt>{% trans "Media type" context "reusable_media" %}</dt>
|
||||
<dd>{{ medium.get_type_display }}</dd>
|
||||
<dt>{% trans "Identifier" context "reusable_media" %}</dt>
|
||||
<dd><code>{{ medium.identifier }}</code></dd>
|
||||
<dt>{% trans "Status" %}</dt>
|
||||
<dd>
|
||||
{% if not medium.active %}
|
||||
{% trans "disabled" %}
|
||||
{% elif medium.is_expired %}
|
||||
{% trans "expired" %}
|
||||
{% else %}
|
||||
{% trans "active" %}
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>{% trans "Connections" context "reusable_media" %}</dt>
|
||||
<dd>
|
||||
{% if medium.customer %}
|
||||
<span class="helper-display-block">
|
||||
<span class="fa fa-user fa-fw"></span>
|
||||
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=medium.customer.identifier %}">
|
||||
{{ medium.customer }}
|
||||
</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if medium.linked_orderposition %}
|
||||
<span class="helper-display-block">
|
||||
<span class="fa fa-ticket fa-fw"></span>
|
||||
<a href="{% url "control:event.order" event=medium.linked_orderposition.order.event.slug organizer=request.organizer.slug code=medium.linked_orderposition.order.code %}">
|
||||
{{ medium.linked_orderposition.order.code }}</a>-{{ medium.linked_orderposition.positionid }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if medium.linked_giftcard %}
|
||||
<span class="helper-display-block">
|
||||
<span class="fa fa-credit-card fa-fw"></span>
|
||||
<a href="{% url "control:organizer.giftcard" organizer=request.organizer.slug giftcard=medium.linked_giftcard.id %}">
|
||||
{{ medium.linked_giftcard.secret }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% if medium.notes %}
|
||||
<dt>{% trans "Notes" %}</dt>
|
||||
<dd>{{ medium.notes }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</form>
|
||||
<div class="text-right">
|
||||
<a href="{% url "control:organizer.reusable_medium.edit" organizer=request.organizer.slug pk=medium.pk %}"
|
||||
class="btn btn-default">
|
||||
<i class="fa fa-edit"></i> {% trans "Edit" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-12">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{% trans "Medium history" context "reusable_media" %}
|
||||
</h3>
|
||||
</div>
|
||||
{% include "pretixcontrol/includes/logs.html" with obj=medium %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user