forked from CGM_Public/pretix_original
Compare commits
114 Commits
v2.1.0
...
release/2.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bb2f30e66 | ||
|
|
9a8d23f582 | ||
|
|
f37d12e056 | ||
|
|
334ffc0be7 | ||
|
|
03f0da4ee6 | ||
|
|
fbbd6eebc0 | ||
|
|
584ced87db | ||
|
|
901953d988 | ||
|
|
8c34a47138 | ||
|
|
0fe3db634c | ||
|
|
d8d838fc4f | ||
|
|
9b94a1b3b2 | ||
|
|
479abc1a65 | ||
|
|
1a17ba13ca | ||
|
|
371c42b738 | ||
|
|
ed85394845 | ||
|
|
a9a684a456 | ||
|
|
d7d7792a4a | ||
|
|
c09587f5d3 | ||
|
|
23f719381c | ||
|
|
d74d39d6e9 | ||
|
|
5f2cf8d3ef | ||
|
|
1843799345 | ||
|
|
bd838b3b7c | ||
|
|
c2d03f5e6b | ||
|
|
74e8e73877 | ||
|
|
8830dc8f78 | ||
|
|
ac877a7c0d | ||
|
|
0a442e712b | ||
|
|
4477f8001e | ||
|
|
152b94428f | ||
|
|
5390b0b191 | ||
|
|
97de8cea08 | ||
|
|
cd465c1aad | ||
|
|
449dea41a8 | ||
|
|
0b1a6e4745 | ||
|
|
e49061e28c | ||
|
|
18cb29b425 | ||
|
|
994ff23719 | ||
|
|
15d077df6e | ||
|
|
b490aa7f5d | ||
|
|
ca6b3badde | ||
|
|
1f200271af | ||
|
|
894a60d016 | ||
|
|
4a2219134b | ||
|
|
7d38fc5c03 | ||
|
|
ef5de187b9 | ||
|
|
a1c424266b | ||
|
|
557b4b7b6f | ||
|
|
98be21253d | ||
|
|
e5a04ada94 | ||
|
|
9b8b3090e6 | ||
|
|
e622c3948d | ||
|
|
94be46ffdb | ||
|
|
7039374588 | ||
|
|
0a5347c08b | ||
|
|
87f3318431 | ||
|
|
2557a8e4ec | ||
|
|
aff7094cb0 | ||
|
|
5a29b4bf70 | ||
|
|
e618183b49 | ||
|
|
a18236b12d | ||
|
|
b5da4e89a6 | ||
|
|
1da2737427 | ||
|
|
032fdadc3c | ||
|
|
8ae3ff3fe6 | ||
|
|
b8669503fa | ||
|
|
863165caaa | ||
|
|
b885f30789 | ||
|
|
461b62bd51 | ||
|
|
23776db3b6 | ||
|
|
19e91a6c7c | ||
|
|
6f40325d3f | ||
|
|
1987bff4b1 | ||
|
|
5aa0d55d47 | ||
|
|
a28196e930 | ||
|
|
c55387819d | ||
|
|
c8cc527aee | ||
|
|
a39b207ad5 | ||
|
|
ea63b50f2e | ||
|
|
b101251aa4 | ||
|
|
c9ba72ebc5 | ||
|
|
4a1c3088a9 | ||
|
|
a480ca1142 | ||
|
|
a928fbfafe | ||
|
|
3bf3ff1ee2 | ||
|
|
9647cc6cf2 | ||
|
|
df2d8925ed | ||
|
|
7a945daefc | ||
|
|
409e77cf2f | ||
|
|
552f99a63b | ||
|
|
0842311451 | ||
|
|
4d4b498636 | ||
|
|
d08cc12240 | ||
|
|
237442872e | ||
|
|
16983826fb | ||
|
|
e60ff6b777 | ||
|
|
3a0ef3760c | ||
|
|
bc0bc78219 | ||
|
|
d3137505a1 | ||
|
|
a2acd336eb | ||
|
|
6e4750336b | ||
|
|
ddefeeaf02 | ||
|
|
250e0a930d | ||
|
|
51c6d60760 | ||
|
|
db513b21f8 | ||
|
|
ab336678ce | ||
|
|
3eea4d6945 | ||
|
|
d091d3fd17 | ||
|
|
fc71f484ad | ||
|
|
bd772bf900 | ||
|
|
14db654681 | ||
|
|
a85b96ea89 | ||
|
|
c2b5e876bc |
@@ -11,7 +11,6 @@ fi
|
||||
|
||||
if [ "$PRETIX_CONFIG_FILE" == "tests/travis_postgres.cfg" ]; then
|
||||
psql -c 'create database travis_ci_test;' -U postgres
|
||||
pip3 install -Ur src/requirements/postgres.txt
|
||||
fi
|
||||
|
||||
if [ "$1" == "style" ]; then
|
||||
@@ -43,7 +42,7 @@ if [ "$1" == "tests" ]; then
|
||||
cd src
|
||||
python manage.py check
|
||||
make all compress
|
||||
py.test --reruns 5 -n 2 tests
|
||||
py.test --reruns 5 -n 3 tests
|
||||
fi
|
||||
if [ "$1" == "tests-cov" ]; then
|
||||
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
language: python
|
||||
sudo: false
|
||||
install:
|
||||
- pip install -U pip wheel setuptools==28.6.1
|
||||
- pip install -U pip wheel setuptools
|
||||
script:
|
||||
- bash .travis.sh $JOB
|
||||
cache:
|
||||
@@ -18,8 +18,6 @@ matrix:
|
||||
env: JOB=tests-cov PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
|
||||
- python: 3.6
|
||||
env: JOB=style
|
||||
- python: 3.5
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg
|
||||
- python: 3.6
|
||||
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
|
||||
- python: 3.6
|
||||
@@ -32,6 +30,7 @@ matrix:
|
||||
env: JOB=translation-spelling
|
||||
addons:
|
||||
postgresql: "9.4"
|
||||
mariadb: '10.3'
|
||||
apt:
|
||||
packages:
|
||||
- enchant
|
||||
|
||||
@@ -30,7 +30,7 @@ RUN chmod +x /usr/local/bin/pretix && \
|
||||
pip3 install -U pip wheel setuptools && \
|
||||
cd /pretix/src && \
|
||||
rm -f pretix.cfg && \
|
||||
pip3 install -r requirements.txt -r requirements/mysql.txt -r requirements/postgres.txt \
|
||||
pip3 install -r requirements.txt -r requirements/mysql.txt \
|
||||
-r requirements/memcached.txt -r requirements/redis.txt gunicorn && \
|
||||
mkdir -p data && \
|
||||
chown -R pretixuser:pretixuser /pretix /data data && \
|
||||
|
||||
@@ -26,7 +26,7 @@ installation guides):
|
||||
* `Docker`_
|
||||
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for
|
||||
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
|
||||
* A `MySQL`_ or `PostgreSQL`_ database server
|
||||
* A `PostgreSQL`_, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
|
||||
* A `redis`_ server
|
||||
|
||||
We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to
|
||||
@@ -36,6 +36,9 @@ Linux and firewalls, we recommend that you start with `ufw`_.
|
||||
SSL certificates can be obtained for free these days. We also *do not* provide support for HTTP-only
|
||||
installations except for evaluation purposes.
|
||||
|
||||
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
|
||||
**MariaDB 10.2.7 or newer**.
|
||||
|
||||
On this guide
|
||||
-------------
|
||||
|
||||
@@ -58,7 +61,7 @@ Next, we need a database and a database user. We can create these with any kind
|
||||
our database's shell, e.g. for MySQL::
|
||||
|
||||
$ mysql -u root -p
|
||||
mysql> CREATE DATABASE pretix DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_general_ci;
|
||||
mysql> CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
mysql> GRANT ALL PRIVILEGES ON pretix.* TO pretix@'localhost' IDENTIFIED BY '*********';
|
||||
mysql> FLUSH PRIVILEGES;
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ To use pretix, you will need the following things:
|
||||
|
||||
.. warning:: Do not ever use SQLite in production. It will break.
|
||||
|
||||
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
|
||||
**MariaDB 10.2.7 or newer**.
|
||||
|
||||
* A **reverse proxy**. pretix needs to deliver some static content to your users (e.g. CSS, images, ...). While pretix
|
||||
is capable of doing this, having this handled by a proper web server like **nginx** or **Apache** will be much
|
||||
faster. Also, you need a proxying web server in front to provide SSL encryption.
|
||||
|
||||
@@ -23,7 +23,7 @@ installation guides):
|
||||
|
||||
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for
|
||||
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
|
||||
* A `MySQL`_ or `PostgreSQL`_ database server
|
||||
* A `PostgreSQL`_, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server
|
||||
* A `redis`_ server
|
||||
|
||||
We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to
|
||||
@@ -33,6 +33,9 @@ Linux and firewalls, we recommend that you start with `ufw`_.
|
||||
SSL certificates can be obtained for free these days. We also *do not* provide support for HTTP-only
|
||||
installations except for evaluation purposes.
|
||||
|
||||
.. warning:: We recommend **PostgreSQL**. If you go for MySQL, make sure you run **MySQL 5.7 or newer** or
|
||||
**MariaDB 10.2.7 or newer**.
|
||||
|
||||
Unix user
|
||||
---------
|
||||
|
||||
@@ -50,7 +53,7 @@ Having the database server installed, we still need a database and a database us
|
||||
of database managing tool or directly on our database's shell, e.g. for MySQL::
|
||||
|
||||
$ mysql -u root -p
|
||||
mysql> CREATE DATABASE pretix DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_general_ci;
|
||||
mysql> CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
mysql> GRANT ALL PRIVILEGES ON pretix.* TO pretix@'localhost' IDENTIFIED BY '*********';
|
||||
mysql> FLUSH PRIVILEGES;
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ Field specific input errors include the name of the offending fields as keys in
|
||||
|
||||
{"amount": ["A valid integer is required."], "description": ["This field may not be blank."]}
|
||||
|
||||
If you see errors of type ``429 Too Many Requests``, you should read our documentation on :ref:`rest-ratelimit`.
|
||||
|
||||
Data types
|
||||
----------
|
||||
|
||||
@@ -16,3 +16,5 @@ in functionality over time.
|
||||
fundamentals
|
||||
auth
|
||||
resources/index
|
||||
ratelimit
|
||||
webhooks
|
||||
|
||||
@@ -166,6 +166,42 @@ endpoint to revoke it.
|
||||
If you want to revoke your client secret, you can generate a new one in the list of your managed applications in the
|
||||
pretix user interface.
|
||||
|
||||
Fetching the user profile
|
||||
-------------------------
|
||||
|
||||
If you need the user's meta data, you can fetch it here:
|
||||
|
||||
.. http:get:: /api/v1/me
|
||||
|
||||
Returns the profile of the authenticated user
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/me HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Authorization: Bearer i3ytqTSRWsKp16fqjekHXa4tdM4qNC
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
email: "admin@localhost",
|
||||
fullname: "John Doe",
|
||||
locale: "de",
|
||||
timezone: "Europe/Berlin"
|
||||
}
|
||||
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
|
||||
.. _OAuth2: https://en.wikipedia.org/wiki/OAuth
|
||||
.. _OAuth2 Simplified: https://aaronparecki.com/oauth-2-simplified/
|
||||
.. _HTTP Basic authentication: https://en.wikipedia.org/wiki/Basic_access_authentication
|
||||
|
||||
31
doc/api/ratelimit.rst
Normal file
31
doc/api/ratelimit.rst
Normal file
@@ -0,0 +1,31 @@
|
||||
.. _`rest-ratelimit`:
|
||||
|
||||
Rate limiting
|
||||
=============
|
||||
|
||||
.. note:: This page only applies to the pretix Hosted service at pretix.eu. APIs of custom pretix installations do not
|
||||
enforce any rate limiting by default.
|
||||
|
||||
All authenticated requests to pretix' API are rate limited. If you exceed the limits, you will receive a response
|
||||
with HTTP status code ``429 Too Many Requests``. This response will have a ``Retry-After`` header, containing the number
|
||||
of seconds you are supposed to wait until you try again. We expect that all API clients respect this. If you continue
|
||||
to burst requests after a ``429`` status code, we might get in touch with you or, in extreme cases, disable your API
|
||||
access.
|
||||
|
||||
Currently, the following rate limits apply:
|
||||
|
||||
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== =================================================================================
|
||||
Authentication method Rate limit
|
||||
===================================== =================================================================================
|
||||
:ref:`rest-deviceauth` 360 requests per minute per device
|
||||
:ref:`rest-tokenauth` 360 requests per minute per organizer account
|
||||
:ref:`rest-oauth` 360 requests per minute per combination of accessed organizer and OAuth application
|
||||
Session authentication *Not an officially supported authentication method for external access*
|
||||
===================================== =================================================================================
|
||||
|
||||
If you require a higher rate limit, please get in touch at support@pretix.eu and tell us about your use case, we are
|
||||
sure we can work something out.
|
||||
@@ -25,6 +25,7 @@ item integer ID of the item
|
||||
variation integer ID of the variation (or ``null``)
|
||||
price money (string) Price of this position
|
||||
attendee_name string Specified attendee name for this position (or ``null``)
|
||||
attendee_name_parts object of strings Composition of attendee name (i.e. first name, last name, …)
|
||||
attendee_email string Specified attendee email address for this position (or ``null``)
|
||||
voucher integer Internal ID of the voucher used for this position (or ``null``)
|
||||
addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
|
||||
@@ -78,6 +79,7 @@ Cart position endpoints
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": null,
|
||||
"attendee_name_parts": {},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"addon_to": null,
|
||||
@@ -122,6 +124,7 @@ Cart position endpoints
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": null,
|
||||
"attendee_name_parts": {},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"addon_to": null,
|
||||
@@ -175,7 +178,7 @@ Cart position endpoints
|
||||
* ``item``
|
||||
* ``variation`` (optional)
|
||||
* ``price``
|
||||
* ``attendee_name`` (optional)
|
||||
* ``attendee_name`` **or** ``attendee_name_parts`` (optional)
|
||||
* ``attendee_email`` (optional)
|
||||
* ``subevent`` (optional)
|
||||
* ``expires`` (optional)
|
||||
@@ -199,7 +202,10 @@ Cart position endpoints
|
||||
"item": 1,
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_name_parts": {
|
||||
"given_name": "Peter",
|
||||
"family_name": "Miller"
|
||||
},
|
||||
"attendee_email": null,
|
||||
"answers": [
|
||||
{
|
||||
|
||||
@@ -371,6 +371,9 @@ Order position endpoints
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_name_parts": {
|
||||
"full_name": "Peter",
|
||||
},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"tax_rate": "0.00",
|
||||
@@ -466,6 +469,9 @@ Order position endpoints
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_name_parts": {
|
||||
"full_name": "Peter",
|
||||
},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"tax_rate": "0.00",
|
||||
|
||||
@@ -21,3 +21,4 @@ Resources and endpoints
|
||||
checkinlists
|
||||
waitinglist
|
||||
carts
|
||||
webhooks
|
||||
|
||||
@@ -46,6 +46,7 @@ invoice_address object Invoice address
|
||||
for orders created before pretix 1.7, do not rely on
|
||||
it).
|
||||
├ name string Customer name
|
||||
├ name_parts object of strings Customer name decomposition
|
||||
├ street string Customer street
|
||||
├ zipcode string Customer ZIP code
|
||||
├ city string Customer city
|
||||
@@ -137,6 +138,7 @@ item integer ID of the purch
|
||||
variation integer ID of the purchased variation (or ``null``)
|
||||
price money (string) Price of this position
|
||||
attendee_name string Specified attendee name for this position (or ``null``)
|
||||
attendee_name_parts object of strings Decomposition of attendee name (i.e. given name, family name)
|
||||
attendee_email string Specified attendee email address for this position (or ``null``)
|
||||
voucher integer Internal ID of the voucher used for this position (or ``null``)
|
||||
tax_rate decimal (string) VAT rate applied for this position
|
||||
@@ -278,6 +280,7 @@ List of all orders
|
||||
"is_business": True,
|
||||
"company": "Sample company",
|
||||
"name": "John Doe",
|
||||
"name_parts": {"full_name": "John Doe"},
|
||||
"street": "Test street 12",
|
||||
"zipcode": "12345",
|
||||
"city": "Testington",
|
||||
@@ -295,6 +298,9 @@ List of all orders
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_name_parts": {
|
||||
"full_name": "Peter",
|
||||
},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"tax_rate": "0.00",
|
||||
@@ -410,6 +416,7 @@ Fetching individual orders
|
||||
"company": "Sample company",
|
||||
"is_business": True,
|
||||
"name": "John Doe",
|
||||
"name_parts": {"full_name": "John Doe"},
|
||||
"street": "Test street 12",
|
||||
"zipcode": "12345",
|
||||
"city": "Testington",
|
||||
@@ -427,6 +434,9 @@ Fetching individual orders
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_name_parts": {
|
||||
"full_name": "Peter",
|
||||
},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"tax_rate": "0.00",
|
||||
@@ -601,7 +611,7 @@ Creating orders
|
||||
|
||||
* ``company``
|
||||
* ``is_business``
|
||||
* ``name``
|
||||
* ``name`` **or** ``name_parts``
|
||||
* ``street``
|
||||
* ``zipcode``
|
||||
* ``city``
|
||||
@@ -615,7 +625,7 @@ Creating orders
|
||||
* ``item``
|
||||
* ``variation``
|
||||
* ``price``
|
||||
* ``attendee_name``
|
||||
* ``attendee_name`` **or** ``attendee_name_parts``
|
||||
* ``attendee_email``
|
||||
* ``secret`` (optional)
|
||||
* ``addon_to`` (optional, see below)
|
||||
@@ -664,7 +674,7 @@ Creating orders
|
||||
"invoice_address": {
|
||||
"is_business": False,
|
||||
"company": "Sample company",
|
||||
"name": "John Doe",
|
||||
"name_parts": {"full_name": "John Doe"},
|
||||
"street": "Sesam Street 12",
|
||||
"zipcode": "12345",
|
||||
"city": "Sample City",
|
||||
@@ -678,7 +688,9 @@ Creating orders
|
||||
"item": 1,
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_name_parts": {
|
||||
"full_name": "Peter"
|
||||
},
|
||||
"attendee_email": null,
|
||||
"addon_to": null,
|
||||
"answers": [
|
||||
@@ -1075,6 +1087,9 @@ List of all order positions
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_name_parts": {
|
||||
"full_name": "Peter"
|
||||
},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"tax_rate": "0.00",
|
||||
@@ -1172,6 +1187,9 @@ Fetching individual positions
|
||||
"variation": null,
|
||||
"price": "23.00",
|
||||
"attendee_name": "Peter",
|
||||
"attendee_name_parts": {
|
||||
"full_name": "Peter",
|
||||
},
|
||||
"attendee_email": null,
|
||||
"voucher": null,
|
||||
"tax_rate": "0.00",
|
||||
|
||||
@@ -231,6 +231,76 @@ Endpoints
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
|
||||
:statuscode 409: The server was unable to acquire a lock and could not process your request. You can try again after a short waiting period.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/vouchers/batch_create/
|
||||
|
||||
Creates multiple new vouchers atomically.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/vouchers/batch_create/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 408
|
||||
|
||||
[
|
||||
{
|
||||
"code": "43K6LKM37FBVR2YG",
|
||||
"max_usages": 1,
|
||||
"valid_until": null,
|
||||
"block_quota": false,
|
||||
"allow_ignore_quota": false,
|
||||
"price_mode": "set",
|
||||
"value": "12.00",
|
||||
"item": 1,
|
||||
"variation": null,
|
||||
"quota": null,
|
||||
"tag": "testvoucher",
|
||||
"comment": "",
|
||||
"subevent": null
|
||||
},
|
||||
{
|
||||
"code": "ASDKLJCYXCASDASD",
|
||||
"max_usages": 1,
|
||||
"valid_until": null,
|
||||
"block_quota": false,
|
||||
"allow_ignore_quota": false,
|
||||
"price_mode": "set",
|
||||
"value": "12.00",
|
||||
"item": 1,
|
||||
"variation": null,
|
||||
"quota": null,
|
||||
"tag": "testvoucher",
|
||||
"comment": "",
|
||||
"subevent": null
|
||||
},
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"code": "43K6LKM37FBVR2YG",
|
||||
…
|
||||
}, …
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create a vouchers for
|
||||
:param event: The ``slug`` field of the event to create a vouchers for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The vouchers could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
|
||||
:statuscode 409: The server was unable to acquire a lock and could not process your request. You can try again after a short waiting period.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/vouchers/(id)/
|
||||
|
||||
Update a voucher. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
|
||||
|
||||
243
doc/api/resources/webhooks.rst
Normal file
243
doc/api/resources/webhooks.rst
Normal file
@@ -0,0 +1,243 @@
|
||||
.. _`rest-webhooks`:
|
||||
|
||||
Webhooks
|
||||
========
|
||||
|
||||
.. note:: This page is about how to modify webhook settings themselves through the REST API. If you just want to know
|
||||
how webhooks work, go here: :ref:`webhooks`
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
The webhook resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the webhook
|
||||
enabled boolean If ``False``, this webhook will not receive any notifications
|
||||
target_url string The URL to call
|
||||
all_events boolean If ``True``, this webhook will receive notifications
|
||||
on all events of this organizer
|
||||
limit_events list of strings If ``all_events`` is ``False``, this is a list of
|
||||
event slugs this webhook is active for
|
||||
action_types list of strings A list of action type filters that limit the
|
||||
notifications sent to this webhook. See below for
|
||||
valid values
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
The following values for ``action_types`` are valid with pretix core:
|
||||
|
||||
* ``pretix.event.order.placed``
|
||||
* ``pretix.event.order.paid``
|
||||
* ``pretix.event.order.canceled``
|
||||
* ``pretix.event.order.expired``
|
||||
* ``pretix.event.order.modified``
|
||||
* ``pretix.event.order.contact.changed``
|
||||
* ``pretix.event.order.changed.*``
|
||||
* ``pretix.event.order.refund.created.externally``
|
||||
* ``pretix.event.order.refunded``
|
||||
* ``pretix.event.order.approved``
|
||||
* ``pretix.event.order.denied``
|
||||
* ``pretix.event.checkin``
|
||||
* ``pretix.event.checkin.reverted``
|
||||
|
||||
Installed plugins might register more valid values.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/webhooks/
|
||||
|
||||
Returns a list of all webhooks within a given organizer.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/webhooks/ 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": 2,
|
||||
"enabled": true,
|
||||
"target_url": "https://httpstat.us/200",
|
||||
"all_events": false,
|
||||
"limit_events": ["democon"],
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||
: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)/webhooks/(id)/
|
||||
|
||||
Returns information on one webhook, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/webhooks/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": 2,
|
||||
"enabled": true,
|
||||
"target_url": "https://httpstat.us/200",
|
||||
"all_events": false,
|
||||
"limit_events": ["democon"],
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param id: The ``id`` field of the webhook 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:post:: /api/v1/organizers/(organizer)/webhooks/
|
||||
|
||||
Creates a new webhook
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/webhooks/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content: application/json
|
||||
|
||||
{
|
||||
"enabled": true,
|
||||
"target_url": "https://httpstat.us/200",
|
||||
"all_events": false,
|
||||
"limit_events": ["democon"],
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 3,
|
||||
"enabled": true,
|
||||
"target_url": "https://httpstat.us/200",
|
||||
"all_events": false,
|
||||
"limit_events": ["democon"],
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create a webhook for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The webhook 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)/webhooks/(id)/
|
||||
|
||||
Update a webhook. 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`` field.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/webhooks/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"enabled": false
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"enabled": false,
|
||||
"target_url": "https://httpstat.us/200",
|
||||
"all_events": false,
|
||||
"limit_events": ["democon"],
|
||||
"action_types": ["pretix.event.order.modified", "pretix.event.order.changed.*"]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param id: The ``id`` field of the webhook to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The webhook 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.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/webhook/(id)/
|
||||
|
||||
Delete a webhook. Currently, this will not delete but just disable the webhook.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/webhooks/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param id: The ``id`` field of the webhook to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource.
|
||||
108
doc/api/webhooks.rst
Normal file
108
doc/api/webhooks.rst
Normal file
@@ -0,0 +1,108 @@
|
||||
.. _`webhooks`:
|
||||
|
||||
Webhooks
|
||||
========
|
||||
|
||||
pretix can send webhook calls to notify your application of any changes that happen inside pretix. This is especially
|
||||
useful for everything triggered by an actual user, such as a new ticket sale or the arrival of a payment.
|
||||
|
||||
You can register any number of webhook URLs that pretix will notify any time one of the supported events occurs inside
|
||||
your organizer account. A great example use case of webhooks would be to add the buyer to your mailing list every time
|
||||
a new order comes in.
|
||||
|
||||
Configuring webhooks
|
||||
--------------------
|
||||
|
||||
You can find the list of your active webhooks in the "Webhook" section of your organizer account:
|
||||
|
||||
.. thumbnail:: ../screens/organizer/webhook_list.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
Click "Create webhook" if you want to add a new URL. You will then be able to enter the URL pretix shall call for
|
||||
notifications. You need to select any number of notification types that you want to receive and you can optionally
|
||||
filter the events you want to receive notifications for.
|
||||
|
||||
.. thumbnail:: ../screens/organizer/webhook_edit.png
|
||||
:align: center
|
||||
:class: screenshot
|
||||
|
||||
You can also configure webhooks :ref:`through the API itself <rest-webhooks>`.
|
||||
|
||||
Receiving webhooks
|
||||
------------------
|
||||
|
||||
Creating a webhook endpoint on your server is no different from creating any other page on your website. If your
|
||||
website is written in PHP, you might just create a new ``.php`` file on your server; if you use a web framework like
|
||||
Symfony or Django, you would just create a new route with the desired URL.
|
||||
|
||||
We will call your URL with a HTTP ``POST`` request with a ``JSON`` body. In PHP, you can parse this like this::
|
||||
|
||||
$input = @file_get_contents('php://input');
|
||||
$event_json = json_decode($input);
|
||||
// Do something with $event_json
|
||||
|
||||
In Django, you would create a view like this::
|
||||
|
||||
def my_webhook_view(request):
|
||||
event_json = json.loads(request.body)
|
||||
# Do something with event_json
|
||||
return HttpResponse(status=200)
|
||||
|
||||
More samples for the language of your choice are easy to find online.
|
||||
|
||||
The exact body of the request varies by notification type, but for the main types included with pretix core, such as
|
||||
those related to changes of an order, it will look like this::
|
||||
|
||||
{
|
||||
"notification_id": 123455,
|
||||
"organizer": "acmecorp",
|
||||
"event": "democon",
|
||||
"code": "ABC23",
|
||||
"action": "pretix.event.order.placed"
|
||||
}
|
||||
|
||||
Notifications regarding a check-in will contain more details like ``orderposition_id``
|
||||
and ``checkin_list``.
|
||||
|
||||
.. warning:: You should not trust data supplied to your webhook, but only use it as a trigger to fetch updated data.
|
||||
Anyone could send data there if they guess the correct URL and you won't be able to tell. Therefore, we
|
||||
only include the minimum amount of data necessary for you to fetch the changed objects from our
|
||||
:ref:`rest-api` in an authenticated way.
|
||||
|
||||
If you want to further prevent others from accessing your webhook URL, you can also use `Basic authentication`_ and
|
||||
supply the URL to us in the format of ``https://username:password@domain.com/path/``.
|
||||
We recommend that you use HTTPS for your webhook URL and might require it in the future. If HTTPS is used, we require
|
||||
that a valid certificate is in use.
|
||||
|
||||
.. note:: If you use a web framework that makes use of automatic CSRF protection, this protection might prevent us
|
||||
from calling your webhook URL. In this case, we recommend that you turn of CSRF protection selectively
|
||||
for that route. In Django, you can do this by putting the ``@csrf_exempt`` decorator on your view. In
|
||||
Rails, you can pass an ``except`` parameter to ``protect_from_forgery``.
|
||||
|
||||
|
||||
Responding to a webhook
|
||||
-----------------------
|
||||
|
||||
If you successfully received a webhook call, your endpoint should return a HTTP status code between ``200`` and ``299``.
|
||||
If any other status code is returned, we will assume you did not receive the call. This does mean that any redirection
|
||||
or ``304 Not Modified`` response will be treated as a failure. pretix will not follow any ``301`` or ``302`` redirect
|
||||
headers and pretix will ignore all other information in your response headers or body.
|
||||
|
||||
If we do not receive a status code in the range of ``200`` and ``299``, pretix will retry to deliver for up to three
|
||||
days with an exponential back off. Therefore, we recommend that you implement your endpoint in a way where calling it
|
||||
multiple times for the same event due to a perceived error does not do any harm.
|
||||
|
||||
There is only one exception: If status code ``410 Gone`` is returned, we will assume the
|
||||
endpoint does not exist any more and automatically disable the webhook.
|
||||
|
||||
.. note:: If you use a self-hosted version of pretix (i.e. not our SaaS offering at pretix.eu) and you did not
|
||||
configure a background task queue, failed webhooks will not be retried.
|
||||
|
||||
Debugging webhooks
|
||||
------------------
|
||||
|
||||
If you want to debug your webhooks, you can view a log of all sent notifications and the responses of your server for
|
||||
30 days right next to your configuration.
|
||||
|
||||
.. _Basic authentication: https://en.wikipedia.org/wiki/Basic_access_authentication
|
||||
@@ -64,6 +64,8 @@ The provider class
|
||||
|
||||
.. autoattribute:: settings_form_fields
|
||||
|
||||
.. automethod:: settings_form_clean
|
||||
|
||||
.. automethod:: settings_content_render
|
||||
|
||||
.. automethod:: is_allowed
|
||||
|
||||
BIN
doc/screens/organizer/webhook_edit.png
Normal file
BIN
doc/screens/organizer/webhook_edit.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
BIN
doc/screens/organizer/webhook_list.png
Normal file
BIN
doc/screens/organizer/webhook_list.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -89,6 +89,7 @@ regex
|
||||
renderer
|
||||
renderers
|
||||
reportlab
|
||||
SaaS
|
||||
screenshot
|
||||
selectable
|
||||
serializers
|
||||
@@ -105,6 +106,7 @@ subevent
|
||||
subevents
|
||||
submodule
|
||||
subpath
|
||||
Symfony
|
||||
systemd
|
||||
testutils
|
||||
timestamp
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
[run]
|
||||
source = pretix
|
||||
omit = */migrations/*,*/urls.py,*/tests/*,*/testdummy/*,*/admin.py,pretix/wsgi.py,pretix/settings.py
|
||||
|
||||
[report]
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
def __str__
|
||||
der __repr__
|
||||
if settings.DEBUG
|
||||
NOQA
|
||||
NotImplementedError
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.1.0"
|
||||
__version__ = "2.2.0"
|
||||
|
||||
@@ -5,5 +5,8 @@ class PretixApiConfig(AppConfig):
|
||||
name = 'pretix.api'
|
||||
label = 'pretixapi'
|
||||
|
||||
def ready(self):
|
||||
from . import signals, webhooks # noqa
|
||||
|
||||
|
||||
default_app_config = 'pretix.api.PretixApiConfig'
|
||||
|
||||
@@ -10,7 +10,10 @@ def custom_exception_handler(exc, context):
|
||||
if isinstance(exc, LockTimeoutException):
|
||||
response = Response(
|
||||
{'detail': 'The server was too busy to process your request. Please try again.'},
|
||||
status=status.HTTP_409_CONFLICT
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
headers={
|
||||
'Retry-After': 5
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Generated by Django 2.1.1 on 2018-11-07 10:46
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0102_auto_20181017_0024'),
|
||||
('pretixapi', '0002_auto_20180604_1120'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WebHook',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('enabled', models.BooleanField(default=True, verbose_name='Enable webhook')),
|
||||
('target_url', models.URLField(verbose_name='Target URL')),
|
||||
('all_events', models.BooleanField(default=False, verbose_name='All events (including newly created ones)')),
|
||||
('limit_events', models.ManyToManyField(blank=True, to='pretixbase.Event', verbose_name='Limit to events')),
|
||||
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Organizer')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WebHookCall',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('datetime', models.DateTimeField(auto_now_add=True)),
|
||||
('target_url', models.URLField()),
|
||||
('is_retry', models.BooleanField(default=False)),
|
||||
('execution_time', models.FloatField(null=True)),
|
||||
('return_code', models.PositiveIntegerField(default=0)),
|
||||
('payload', models.TextField()),
|
||||
('response_body', models.TextField()),
|
||||
('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixapi.WebHook')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WebHookEventListener',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('action_type', models.CharField(max_length=255)),
|
||||
('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixapi.WebHook')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='webhookcall',
|
||||
name='success',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='webhook',
|
||||
name='all_events',
|
||||
field=models.BooleanField(default=True, verbose_name='All events (including newly created ones)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='webhook',
|
||||
name='organizer',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webhooks', to='pretixbase.Organizer'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='webhookcall',
|
||||
name='webhook',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='calls', to='pretixapi.WebHook'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='webhookeventlistener',
|
||||
name='webhook',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='listeners', to='pretixapi.WebHook'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='webhookcall',
|
||||
name='action_type',
|
||||
field=models.CharField(default='', max_length=255),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@@ -68,3 +68,41 @@ class OAuthRefreshToken(AbstractRefreshToken):
|
||||
OAuthAccessToken, on_delete=models.SET_NULL, blank=True, null=True,
|
||||
related_name="refresh_token"
|
||||
)
|
||||
|
||||
|
||||
class WebHook(models.Model):
|
||||
organizer = models.ForeignKey('pretixbase.Organizer', on_delete=models.CASCADE, related_name='webhooks')
|
||||
enabled = models.BooleanField(default=True, verbose_name=_("Enable webhook"))
|
||||
target_url = models.URLField(verbose_name=_("Target URL"))
|
||||
all_events = models.BooleanField(default=True, verbose_name=_("All events (including newly created ones)"))
|
||||
limit_events = models.ManyToManyField('pretixbase.Event', verbose_name=_("Limit to events"), blank=True)
|
||||
|
||||
@property
|
||||
def action_types(self):
|
||||
return [
|
||||
l.action_type for l in self.listeners.all()
|
||||
]
|
||||
|
||||
|
||||
class WebHookEventListener(models.Model):
|
||||
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='listeners')
|
||||
action_type = models.CharField(max_length=255)
|
||||
|
||||
class Meta:
|
||||
ordering = ("action_type",)
|
||||
|
||||
|
||||
class WebHookCall(models.Model):
|
||||
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='calls')
|
||||
datetime = models.DateTimeField(auto_now_add=True)
|
||||
target_url = models.URLField()
|
||||
action_type = models.CharField(max_length=255)
|
||||
is_retry = models.BooleanField(default=False)
|
||||
execution_time = models.FloatField(null=True)
|
||||
return_code = models.PositiveIntegerField(default=0)
|
||||
success = models.BooleanField(default=False)
|
||||
payload = models.TextField()
|
||||
response_body = models.TextField()
|
||||
|
||||
class Meta:
|
||||
ordering = ("-datetime",)
|
||||
|
||||
@@ -19,18 +19,19 @@ class CartPositionSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = CartPosition
|
||||
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
|
||||
'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
|
||||
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
|
||||
'attendee_email', 'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
|
||||
'answers',)
|
||||
|
||||
|
||||
class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
answers = AnswerCreateSerializer(many=True, required=False)
|
||||
expires = serializers.DateTimeField(required=False)
|
||||
attendee_name = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = CartPosition
|
||||
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
|
||||
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'subevent', 'expires', 'includes_tax', 'answers',)
|
||||
|
||||
def create(self, validated_data):
|
||||
@@ -65,6 +66,11 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
quota.name
|
||||
)
|
||||
)
|
||||
attendee_name = validated_data.pop('attendee_name', '')
|
||||
if attendee_name and not validated_data.get('attendee_name_parts'):
|
||||
validated_data['attendee_name_parts'] = {
|
||||
'_legacy': attendee_name
|
||||
}
|
||||
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
|
||||
|
||||
for answ_data in answers_data:
|
||||
@@ -118,4 +124,8 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError(
|
||||
'You cannot specify a variation for this item.'
|
||||
)
|
||||
if data.get('attendee_name') and data.get('attendee_name_parts'):
|
||||
raise ValidationError(
|
||||
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
|
||||
)
|
||||
return data
|
||||
|
||||
@@ -35,11 +35,12 @@ class CompatibleCountryField(serializers.Field):
|
||||
|
||||
class InvoiceAddressSerializer(I18nAwareModelSerializer):
|
||||
country = CompatibleCountryField(source='*')
|
||||
name = serializers.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = InvoiceAddress
|
||||
fields = ('last_modified', 'is_business', 'company', 'name', 'street', 'zipcode', 'city', 'country', 'vat_id',
|
||||
'vat_id_validated', 'internal_reference')
|
||||
fields = ('last_modified', 'is_business', 'company', 'name', 'name_parts', 'street', 'zipcode', 'city', 'country',
|
||||
'vat_id', 'vat_id_validated', 'internal_reference')
|
||||
read_only_fields = ('last_modified', 'vat_id_validated')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -48,6 +49,15 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
|
||||
v.required = False
|
||||
v.allow_blank = True
|
||||
|
||||
def validate(self, data):
|
||||
if data.get('name') and data.get('name_parts'):
|
||||
raise ValidationError(
|
||||
{'name': ['Do not specify name if you specified name_parts.']}
|
||||
)
|
||||
if data.get('name_parts') and '_scheme' not in data.get('name_parts'):
|
||||
data['name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
|
||||
return data
|
||||
|
||||
|
||||
class AnswerQuestionIdentifierField(serializers.Field):
|
||||
def to_representation(self, instance: QuestionAnswer):
|
||||
@@ -158,9 +168,9 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
|
||||
'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'downloads',
|
||||
'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data')
|
||||
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
|
||||
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
|
||||
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -305,10 +315,11 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
answers = AnswerCreateSerializer(many=True, required=False)
|
||||
addon_to = serializers.IntegerField(required=False, allow_null=True)
|
||||
secret = serializers.CharField(required=False)
|
||||
attendee_name = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
|
||||
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'secret', 'addon_to', 'subevent', 'answers')
|
||||
|
||||
def validate_secret(self, secret):
|
||||
@@ -359,6 +370,12 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError(
|
||||
{'variation': ['You cannot specify a variation for this item.']}
|
||||
)
|
||||
if data.get('attendee_name') and data.get('attendee_name_parts'):
|
||||
raise ValidationError(
|
||||
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
|
||||
)
|
||||
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
|
||||
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
|
||||
return data
|
||||
|
||||
|
||||
@@ -464,7 +481,13 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
payment_info = validated_data.pop('payment_info', '{}')
|
||||
|
||||
if 'invoice_address' in validated_data:
|
||||
ia = InvoiceAddress(**validated_data.pop('invoice_address'))
|
||||
iadata = validated_data.pop('invoice_address')
|
||||
name = iadata.pop('name', '')
|
||||
if name and not iadata.get('name_parts'):
|
||||
iadata['name_parts'] = {
|
||||
'_legacy': name
|
||||
}
|
||||
ia = InvoiceAddress(**iadata)
|
||||
else:
|
||||
ia = None
|
||||
|
||||
@@ -555,6 +578,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
for pos_data in positions_data:
|
||||
answers_data = pos_data.pop('answers', [])
|
||||
addon_to = pos_data.pop('addon_to', None)
|
||||
attendee_name = pos_data.pop('attendee_name', '')
|
||||
if attendee_name and not pos_data.get('attendee_name_parts'):
|
||||
pos_data['attendee_name_parts'] = {
|
||||
'_legacy': attendee_name
|
||||
}
|
||||
pos = OrderPosition(**pos_data)
|
||||
pos.order = order
|
||||
pos._calculate_tax()
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.models import Voucher
|
||||
|
||||
|
||||
class VoucherListSerializer(serializers.ListSerializer):
|
||||
def create(self, validated_data):
|
||||
codes = set()
|
||||
errs = []
|
||||
err = False
|
||||
for voucher_data in validated_data:
|
||||
if voucher_data['code'] in codes:
|
||||
err = True
|
||||
errs.append({'code': ['Duplicate voucher code in request.']})
|
||||
else:
|
||||
codes.add(voucher_data['code'])
|
||||
errs.append({})
|
||||
if err:
|
||||
raise ValidationError(errs)
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class VoucherSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Voucher
|
||||
@@ -9,6 +29,7 @@ class VoucherSerializer(I18nAwareModelSerializer):
|
||||
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
|
||||
'tag', 'comment', 'subevent')
|
||||
read_only_fields = ('id', 'redeemed')
|
||||
list_serializer_class = VoucherListSerializer
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
71
src/pretix/api/serializers/webhooks.py
Normal file
71
src/pretix/api/serializers/webhooks.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework import serializers
|
||||
|
||||
from pretix.api.models import WebHook
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.webhooks import get_all_webhook_events
|
||||
from pretix.base.models import Event
|
||||
|
||||
|
||||
class EventRelatedField(serializers.SlugRelatedField):
|
||||
def get_queryset(self):
|
||||
return self.context['organizer'].events.all()
|
||||
|
||||
|
||||
class ActionTypesField(serializers.Field):
|
||||
def to_representation(self, instance: WebHook):
|
||||
return instance.action_types
|
||||
|
||||
def to_internal_value(self, data):
|
||||
types = get_all_webhook_events()
|
||||
for d in data:
|
||||
if d not in types:
|
||||
raise ValidationError('Invalid action type "%s".' % d)
|
||||
return {'action_types': data}
|
||||
|
||||
|
||||
class WebHookSerializer(I18nAwareModelSerializer):
|
||||
limit_events = EventRelatedField(
|
||||
slug_field='slug',
|
||||
queryset=Event.objects.none(),
|
||||
many=True
|
||||
)
|
||||
action_types = ActionTypesField(source='*')
|
||||
|
||||
class Meta:
|
||||
model = WebHook
|
||||
fields = ('id', 'enabled', 'target_url', 'all_events', 'limit_events', 'action_types')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
full_data.update(data)
|
||||
|
||||
for event in full_data.get('limit_events'):
|
||||
if self.context['organizer'] != event.organizer:
|
||||
raise ValidationError('One or more events do not belong to this organizer.')
|
||||
|
||||
if full_data.get('limit_events') and full_data.get('all_events'):
|
||||
raise ValidationError('You can set either limit_events or all_events.')
|
||||
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
action_types = validated_data.pop('action_types')
|
||||
inst = super().create(validated_data)
|
||||
for l in action_types:
|
||||
inst.listeners.create(action_type=l)
|
||||
return inst
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
action_types = validated_data.pop('action_types', None)
|
||||
instance = super().update(instance, validated_data)
|
||||
if action_types is not None:
|
||||
current_listeners = set(instance.listeners.values_list('action_type', flat=True))
|
||||
new_listeners = set(action_types)
|
||||
for l in current_listeners - new_listeners:
|
||||
instance.listeners.filter(action_type=l).delete()
|
||||
for l in new_listeners - current_listeners:
|
||||
instance.listeners.create(action_type=l)
|
||||
return instance
|
||||
21
src/pretix/api/signals.py
Normal file
21
src/pretix/api/signals.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.dispatch import Signal, receiver
|
||||
from django.utils.timezone import now
|
||||
|
||||
from pretix.api.models import WebHookCall
|
||||
from pretix.base.signals import periodic_task
|
||||
|
||||
register_webhook_events = Signal(
|
||||
providing_args=[]
|
||||
)
|
||||
"""
|
||||
This signal is sent out to get all known webhook events. Receivers should return an
|
||||
instance of a subclass of pretix.api.webhooks.WebhookEvent or a list of such
|
||||
instances.
|
||||
"""
|
||||
|
||||
|
||||
@receiver(periodic_task)
|
||||
def cleanup_webhook_logs(sender, **kwargs):
|
||||
WebHookCall.objects.filter(datetime__lte=now() - timedelta(days=30)).delete()
|
||||
@@ -7,8 +7,8 @@ from rest_framework import routers
|
||||
from pretix.api.views import cart
|
||||
|
||||
from .views import (
|
||||
checkin, device, event, item, oauth, order, organizer, voucher,
|
||||
waitinglist,
|
||||
checkin, device, event, item, oauth, order, organizer, user, voucher,
|
||||
waitinglist, webhooks,
|
||||
)
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
@@ -17,6 +17,7 @@ router.register(r'organizers', organizer.OrganizerViewSet)
|
||||
orga_router = routers.DefaultRouter()
|
||||
orga_router.register(r'events', event.EventViewSet)
|
||||
orga_router.register(r'subevents', event.SubEventViewSet)
|
||||
orga_router.register(r'webhooks', webhooks.WebHookViewSet)
|
||||
|
||||
event_router = routers.DefaultRouter()
|
||||
event_router.register(r'subevents', event.SubEventViewSet)
|
||||
@@ -71,4 +72,5 @@ urlpatterns = [
|
||||
url(r"^device/update$", device.UpdateView.as_view(), name="device.update"),
|
||||
url(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"),
|
||||
url(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"),
|
||||
url(r"^me$", user.MeView.as_view(), name="user.me"),
|
||||
]
|
||||
|
||||
@@ -154,7 +154,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = OrderPositionSerializer
|
||||
queryset = OrderPosition.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
|
||||
ordering = ('attendee_name', 'positionid')
|
||||
ordering = ('attendee_name_cached', 'positionid')
|
||||
ordering_fields = (
|
||||
'order__code', 'order__datetime', 'positionid', 'attendee_name',
|
||||
'last_checked_in', 'order__email',
|
||||
@@ -162,11 +162,11 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
ordering_custom = {
|
||||
'attendee_name': {
|
||||
'_order': F('display_name').asc(nulls_first=True),
|
||||
'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')
|
||||
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')
|
||||
},
|
||||
'-attendee_name': {
|
||||
'_order': F('display_name').desc(nulls_last=True),
|
||||
'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')
|
||||
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')
|
||||
},
|
||||
'last_checked_in': {
|
||||
'_order': FixedOrderBy(F('last_checked_in'), nulls_first=True),
|
||||
@@ -244,7 +244,9 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
ignore_unpaid=ignore_unpaid,
|
||||
nonce=nonce,
|
||||
datetime=dt,
|
||||
questions_supported=self.request.data.get('questions_supported', True)
|
||||
questions_supported=self.request.data.get('questions_supported', True),
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
)
|
||||
except RequiredQuestionsError as e:
|
||||
return Response({
|
||||
|
||||
@@ -83,6 +83,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
)
|
||||
self.get_object().cartposition_set.all().delete()
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import datetime
|
||||
import django_filters
|
||||
import pytz
|
||||
from django.db import transaction
|
||||
from django.db.models import Prefetch, Q
|
||||
from django.db.models.functions import Concat
|
||||
from django.db.models import F, Prefetch, Q
|
||||
from django.db.models.functions import Coalesce, Concat
|
||||
from django.http import FileResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.timezone import make_aware, now
|
||||
@@ -60,7 +60,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
queryset = Order.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
ordering = ('datetime',)
|
||||
ordering_fields = ('datetime', 'code', 'status')
|
||||
ordering_fields = ('datetime', 'code', 'status', 'last_modified')
|
||||
filterset_class = OrderFilter
|
||||
lookup_field = 'code'
|
||||
permission = 'can_view_orders'
|
||||
@@ -251,7 +251,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
)
|
||||
|
||||
order.status = Order.STATUS_PENDING
|
||||
order.save()
|
||||
order.save(update_fields=['status'])
|
||||
order.log_action(
|
||||
'pretix.event.order.unpaid',
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
@@ -373,17 +373,17 @@ class OrderPositionFilter(FilterSet):
|
||||
def search_qs(self, queryset, name, value):
|
||||
return queryset.filter(
|
||||
Q(secret__istartswith=value)
|
||||
| Q(attendee_name__icontains=value)
|
||||
| Q(addon_to__attendee_name__icontains=value)
|
||||
| Q(attendee_name_cached__icontains=value)
|
||||
| Q(addon_to__attendee_name_cached__icontains=value)
|
||||
| Q(order__code__istartswith=value)
|
||||
| Q(order__invoice_address__name__icontains=value)
|
||||
| Q(order__invoice_address__name_cached__icontains=value)
|
||||
)
|
||||
|
||||
def has_checkin_qs(self, queryset, name, value):
|
||||
return queryset.filter(checkins__isnull=not value)
|
||||
|
||||
def attendee_name_qs(self, queryset, name, value):
|
||||
return queryset.filter(Q(attendee_name__iexact=value) | Q(addon_to__attendee_name__iexact=value))
|
||||
return queryset.filter(Q(attendee_name_cached__iexact=value) | Q(addon_to__attendee_name_cached__iexact=value))
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
@@ -409,6 +409,16 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
|
||||
filterset_class = OrderPositionFilter
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
ordering_custom = {
|
||||
'attendee_name': {
|
||||
'_order': F('display_name').asc(nulls_first=True),
|
||||
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')
|
||||
},
|
||||
'-attendee_name': {
|
||||
'_order': F('display_name').asc(nulls_last=True),
|
||||
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')
|
||||
},
|
||||
}
|
||||
|
||||
def get_queryset(self):
|
||||
return OrderPosition.objects.filter(order__event=self.request.event).prefetch_related(
|
||||
@@ -556,7 +566,7 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
payment.order.event.subevents.filter(
|
||||
id__in=payment.order.positions.values_list('subevent_id', flat=True))
|
||||
)
|
||||
payment.order.save()
|
||||
payment.order.save(update_fields=['status', 'expires'])
|
||||
return Response(OrderRefundSerializer(r).data, status=status.HTTP_200_OK)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
@@ -622,7 +632,7 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
refund.order.event.subevents.filter(
|
||||
id__in=refund.order.positions.values_list('subevent_id', flat=True))
|
||||
)
|
||||
refund.order.save()
|
||||
refund.order.save(update_fields=['status', 'expires'])
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
|
||||
16
src/pretix/api/views/user.py
Normal file
16
src/pretix/api/views/user.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
|
||||
class MeView(APIView):
|
||||
authentication_classes = (SessionAuthentication, OAuth2Authentication)
|
||||
|
||||
def get(self, request, format=None):
|
||||
return Response({
|
||||
'email': request.user.email,
|
||||
'fullname': request.user.fullname,
|
||||
'locale': request.user.locale,
|
||||
'timezone': request.user.timezone
|
||||
})
|
||||
@@ -1,11 +1,16 @@
|
||||
import contextlib
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Q
|
||||
from django.utils.timezone import now
|
||||
from django_filters.rest_framework import (
|
||||
BooleanFilter, DjangoFilterBackend, FilterSet,
|
||||
)
|
||||
from rest_framework import viewsets
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import list_route
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.serializers.voucher import VoucherSerializer
|
||||
from pretix.base.models import Voucher
|
||||
@@ -41,8 +46,29 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
||||
def get_queryset(self):
|
||||
return self.request.event.vouchers.all()
|
||||
|
||||
def _predict_quota_check(self, data, instance):
|
||||
# This method predicts if Voucher.clean_quota_needs_checking
|
||||
# *migh* later require a quota check. It is only approximate
|
||||
# and returns True a little too often. The point is to avoid
|
||||
# locks when we know we won't need them.
|
||||
if 'allow_ignore_quota' in data and data.get('allow_ignore_quota'):
|
||||
return False
|
||||
if instance and 'allow_ignore_quota' not in data and instance.allow_ignore_quota:
|
||||
return False
|
||||
|
||||
if 'block_quota' in data and not data.get('block_quota'):
|
||||
return False
|
||||
if instance and 'block_quota' not in data and not instance.block_quota:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
with request.event.lock():
|
||||
if self._predict_quota_check(request.data, None):
|
||||
lockfn = request.event.lock
|
||||
else:
|
||||
lockfn = contextlib.suppress # noop context manager
|
||||
with lockfn():
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
@@ -60,7 +86,11 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
||||
return ctx
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
with request.event.lock():
|
||||
if self._predict_quota_check(request.data, self.get_object()):
|
||||
lockfn = request.event.lock
|
||||
else:
|
||||
lockfn = contextlib.suppress # noop context manager
|
||||
with lockfn():
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
@@ -82,3 +112,24 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
||||
auth=self.request.auth,
|
||||
)
|
||||
super().perform_destroy(instance)
|
||||
|
||||
@list_route(methods=['POST'])
|
||||
def batch_create(self, request, *args, **kwargs):
|
||||
if any(self._predict_quota_check(d, None) for d in request.data):
|
||||
lockfn = request.event.lock
|
||||
else:
|
||||
lockfn = contextlib.suppress # noop context manager
|
||||
with lockfn():
|
||||
serializer = self.get_serializer(data=request.data, many=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
serializer.save(event=self.request.event)
|
||||
for i in serializer.instance:
|
||||
i.log_action(
|
||||
'pretix.voucher.added',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data
|
||||
)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
49
src/pretix/api/views/webhooks.py
Normal file
49
src/pretix/api/views/webhooks.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from rest_framework import viewsets
|
||||
|
||||
from pretix.api.models import WebHook
|
||||
from pretix.api.serializers.webhooks import WebHookSerializer
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
|
||||
|
||||
class WebHookViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = WebHookSerializer
|
||||
queryset = WebHook.objects.none()
|
||||
permission = 'can_change_organizer_settings'
|
||||
write_permission = 'can_change_organizer_settings'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.organizer.webhooks.prefetch_related('listeners')
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['organizer'] = self.request.organizer
|
||||
return ctx
|
||||
|
||||
def perform_create(self, serializer):
|
||||
inst = serializer.save(organizer=self.request.organizer)
|
||||
self.request.organizer.log_action(
|
||||
'pretix.webhook.created',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'id': inst.pk})
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
inst = serializer.save(organizer=self.request.organizer)
|
||||
self.request.organizer.log_action(
|
||||
'pretix.webhook.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
|
||||
)
|
||||
return inst
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
self.request.organizer.log_action(
|
||||
'pretix.webhook.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={'id': instance.pk, 'enabled': False}
|
||||
)
|
||||
instance.enabled = False
|
||||
instance.save(update_fields=['enabled'])
|
||||
252
src/pretix/api/webhooks.py
Normal file
252
src/pretix/api/webhooks.py
Normal file
@@ -0,0 +1,252 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
import requests
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from requests import RequestException
|
||||
|
||||
from pretix.api.models import WebHook, WebHookCall, WebHookEventListener
|
||||
from pretix.api.signals import register_webhook_events
|
||||
from pretix.base.models import LogEntry
|
||||
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
|
||||
from pretix.celery_app import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_ALL_EVENTS = None
|
||||
|
||||
|
||||
class WebhookEvent:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def __repr__(self):
|
||||
return '<WebhookEvent: {}>'.format(self.action_type)
|
||||
|
||||
@property
|
||||
def action_type(self) -> str:
|
||||
"""
|
||||
The action_type string that this notification handles, for example
|
||||
``"pretix.event.order.paid"``. Only one notification type should be registered
|
||||
per action type.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
@property
|
||||
def verbose_name(self) -> str:
|
||||
"""
|
||||
A human-readable name of this notification type.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
def build_payload(self, logentry: LogEntry) -> dict:
|
||||
"""
|
||||
This is the main function that you should override. It is supposed to turn a log entry
|
||||
object into a dictionary that can be used as the webhook payload.
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
|
||||
def get_all_webhook_events():
|
||||
global _ALL_EVENTS
|
||||
|
||||
if _ALL_EVENTS:
|
||||
return _ALL_EVENTS
|
||||
|
||||
types = OrderedDict()
|
||||
for recv, ret in register_webhook_events.send(None):
|
||||
if isinstance(ret, (list, tuple)):
|
||||
for r in ret:
|
||||
types[r.action_type] = r
|
||||
else:
|
||||
types[ret.action_type] = ret
|
||||
_ALL_EVENTS = types
|
||||
return types
|
||||
|
||||
|
||||
class ParametrizedOrderWebhookEvent(WebhookEvent):
|
||||
def __init__(self, action_type, verbose_name):
|
||||
self._action_type = action_type
|
||||
self._verbose_name = verbose_name
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def action_type(self):
|
||||
return self._action_type
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return self._verbose_name
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
order = logentry.content_object
|
||||
|
||||
return {
|
||||
'notification_id': logentry.pk,
|
||||
'organizer': order.event.organizer.slug,
|
||||
'event': order.event.slug,
|
||||
'code': order.code,
|
||||
'action': logentry.action_type,
|
||||
}
|
||||
|
||||
|
||||
class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
d = super().build_payload(logentry)
|
||||
d['orderposition_id'] = logentry.parsed_data.get('position')
|
||||
d['orderposition_positionid'] = logentry.parsed_data.get('positionid')
|
||||
d['checkin_list'] = logentry.parsed_data.get('list')
|
||||
d['first_checkin'] = logentry.parsed_data.get('first_checkin')
|
||||
|
||||
|
||||
@receiver(register_webhook_events, dispatch_uid="base_register_default_webhook_events")
|
||||
def register_default_webhook_events(sender, **kwargs):
|
||||
return (
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.placed',
|
||||
_('New order placed'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.paid',
|
||||
_('Order marked as paid'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.canceled',
|
||||
_('Order canceled'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.expired',
|
||||
_('Order expired'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.modified',
|
||||
_('Order information changed'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.contact.changed',
|
||||
_('Order contact address changed'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.changed.*',
|
||||
_('Order changed'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.refund.created.externally',
|
||||
_('External refund of payment'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.refunded',
|
||||
_('Order refunded'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.approved',
|
||||
_('Order approved'),
|
||||
),
|
||||
ParametrizedOrderWebhookEvent(
|
||||
'pretix.event.order.denied',
|
||||
_('Order denied'),
|
||||
),
|
||||
ParametrizedOrderPositionWebhookEvent(
|
||||
'pretix.event.checkin',
|
||||
_('Ticket checked in'),
|
||||
),
|
||||
ParametrizedOrderPositionWebhookEvent(
|
||||
'pretix.event.checkin.reverted',
|
||||
_('Ticket check-in reverted'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.task(base=TransactionAwareTask)
|
||||
def notify_webhooks(logentry_id: int):
|
||||
logentry = LogEntry.all.get(id=logentry_id)
|
||||
|
||||
if not logentry.organizer:
|
||||
return # We need to know the organizer
|
||||
|
||||
types = get_all_webhook_events()
|
||||
notification_type = None
|
||||
typepath = logentry.action_type
|
||||
while not notification_type and '.' in typepath:
|
||||
notification_type = types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
|
||||
typepath = typepath.rsplit('.', 1)[0]
|
||||
|
||||
if not notification_type:
|
||||
return # Ignore, no webhooks for this event type
|
||||
|
||||
# All webhooks that registered for this notification
|
||||
event_listener = WebHookEventListener.objects.filter(
|
||||
webhook=OuterRef('pk'),
|
||||
action_type=notification_type.action_type
|
||||
)
|
||||
|
||||
webhooks = WebHook.objects.annotate(has_el=Exists(event_listener)).filter(
|
||||
organizer=logentry.organizer,
|
||||
has_el=True,
|
||||
enabled=True
|
||||
)
|
||||
if logentry.event_id:
|
||||
webhooks = webhooks.filter(
|
||||
Q(all_events=True) | Q(limit_events__pk=logentry.event_id)
|
||||
)
|
||||
|
||||
for wh in webhooks:
|
||||
send_webhook.apply_async(args=(logentry_id, notification_type.action_type, wh.pk))
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=9)
|
||||
def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
|
||||
# 9 retries with 2**(2*x) timing is roughly 72 hours
|
||||
logentry = LogEntry.all.get(id=logentry_id)
|
||||
webhook = WebHook.objects.get(id=webhook_id)
|
||||
|
||||
types = get_all_webhook_events()
|
||||
event_type = types.get(action_type)
|
||||
if not event_type or not webhook.enabled:
|
||||
return # Ignore, e.g. plugin not installed
|
||||
|
||||
payload = event_type.build_payload(logentry)
|
||||
t = time.time()
|
||||
|
||||
try:
|
||||
try:
|
||||
resp = requests.post(
|
||||
webhook.target_url,
|
||||
json=payload,
|
||||
allow_redirects=False
|
||||
)
|
||||
WebHookCall.objects.create(
|
||||
webhook=webhook,
|
||||
action_type=logentry.action_type,
|
||||
target_url=webhook.target_url,
|
||||
is_retry=self.request.retries > 0,
|
||||
execution_time=time.time() - t,
|
||||
return_code=resp.status_code,
|
||||
payload=json.dumps(payload),
|
||||
response_body=resp.text[:1024 * 1024],
|
||||
success=200 <= resp.status_code <= 299
|
||||
)
|
||||
if resp.status_code == 410:
|
||||
webhook.enabled = False
|
||||
webhook.save()
|
||||
elif resp.status_code > 299:
|
||||
raise self.retry(countdown=2 ** (self.request.retries * 2))
|
||||
except RequestException as e:
|
||||
WebHookCall.objects.create(
|
||||
webhook=webhook,
|
||||
action_type=logentry.action_type,
|
||||
target_url=webhook.target_url,
|
||||
is_retry=self.request.retries > 0,
|
||||
execution_time=time.time() - t,
|
||||
return_code=0,
|
||||
payload=json.dumps(payload),
|
||||
response_body=str(e)[:1024 * 1024]
|
||||
)
|
||||
raise self.retry(countdown=2 ** (self.request.retries * 2))
|
||||
except MaxRetriesExceededError:
|
||||
pass
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from smtplib import SMTPRecipientsRefused, SMTPSenderRefused
|
||||
from smtplib import SMTPResponseException
|
||||
|
||||
import bleach
|
||||
import markdown
|
||||
@@ -23,16 +23,14 @@ class CustomSMTPBackend(EmailBackend):
|
||||
try:
|
||||
self.open()
|
||||
self.connection.ehlo_or_helo_if_needed()
|
||||
self.connection.rcpt("test@example.org")
|
||||
(code, resp) = self.connection.mail(from_addr, [])
|
||||
if code != 250:
|
||||
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
||||
raise SMTPSenderRefused(code, resp, from_addr)
|
||||
senderrs = {}
|
||||
raise SMTPResponseException(code, resp)
|
||||
(code, resp) = self.connection.rcpt('test@example.com')
|
||||
if (code != 250) and (code != 251):
|
||||
logger.warn('Error testing mail settings, code %d, resp: %s' % (code, resp))
|
||||
raise SMTPRecipientsRefused(senderrs)
|
||||
raise SMTPResponseException(code, resp)
|
||||
finally:
|
||||
self.close()
|
||||
|
||||
@@ -97,7 +95,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
|
||||
@property
|
||||
def template_name(self):
|
||||
raise NotImplemented
|
||||
raise NotImplementedError()
|
||||
|
||||
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order) -> str:
|
||||
body_md = bleach.linkify(markdown_compile(plain_body))
|
||||
|
||||
@@ -27,7 +27,7 @@ class InvoiceExporter(BaseExporter):
|
||||
qs = qs.annotate(
|
||||
has_payment_with_provider=Exists(
|
||||
OrderPayment.objects.filter(
|
||||
Q(order=OuterRef('pk')) & Q(provider=form_data.get('payment_provider'))
|
||||
Q(order=OuterRef('order_id')) & Q(provider=form_data.get('payment_provider'))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ from django.utils.translation import ugettext as _, ugettext_lazy
|
||||
|
||||
from pretix.base.models import InvoiceAddress, Order, OrderPosition
|
||||
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
|
||||
from ..exporter import BaseExporter
|
||||
from ..signals import register_data_exporters
|
||||
@@ -74,7 +75,14 @@ class OrderListExporter(BaseExporter):
|
||||
|
||||
headers = [
|
||||
_('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
|
||||
_('Company'), _('Name'), _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
|
||||
_('Company'), _('Name'),
|
||||
]
|
||||
name_scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
headers.append(label)
|
||||
headers += [
|
||||
_('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
|
||||
_('Date of last payment'), _('Fees'), _('Order locale')
|
||||
]
|
||||
|
||||
@@ -118,6 +126,13 @@ class OrderListExporter(BaseExporter):
|
||||
row += [
|
||||
order.invoice_address.company,
|
||||
order.invoice_address.name,
|
||||
]
|
||||
if len(name_scheme['fields']) > 1:
|
||||
for k, label, w in name_scheme['fields']:
|
||||
row.append(
|
||||
order.invoice_address.name_parts.get(k, '')
|
||||
)
|
||||
row += [
|
||||
order.invoice_address.street,
|
||||
order.invoice_address.zipcode,
|
||||
order.invoice_address.city,
|
||||
@@ -126,7 +141,7 @@ class OrderListExporter(BaseExporter):
|
||||
order.invoice_address.vat_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
row += ['', '', '', '', '', '', '']
|
||||
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
|
||||
|
||||
row += [
|
||||
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -8,6 +9,7 @@ import vat_moss.id
|
||||
from django import forms
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from pretix.base.forms.widgets import (
|
||||
@@ -16,6 +18,8 @@ from pretix.base.forms.widgets import (
|
||||
)
|
||||
from pretix.base.models import InvoiceAddress, Question
|
||||
from pretix.base.models.tax import EU_COUNTRIES
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.control.forms import SplitDateTimeField
|
||||
from pretix.helpers.i18n import get_format_without_seconds
|
||||
from pretix.presale.signals import question_form_fields
|
||||
@@ -23,6 +27,103 @@ from pretix.presale.signals import question_form_fields
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NamePartsWidget(forms.MultiWidget):
|
||||
widget = forms.TextInput
|
||||
|
||||
def __init__(self, scheme: dict, field: forms.Field, attrs=None):
|
||||
widgets = []
|
||||
self.scheme = scheme
|
||||
self.field = field
|
||||
for fname, label, size in self.scheme['fields']:
|
||||
a = copy.copy(attrs) or {}
|
||||
a['data-fname'] = fname
|
||||
widgets.append(self.widget(attrs=a))
|
||||
super().__init__(widgets, attrs)
|
||||
|
||||
def decompress(self, value):
|
||||
if value is None:
|
||||
return None
|
||||
data = []
|
||||
for i, field in enumerate(self.scheme['fields']):
|
||||
fname, label, size = field
|
||||
data.append(value.get(fname, ""))
|
||||
if '_legacy' in value and not data[-1]:
|
||||
data[-1] = value.get('_legacy', '')
|
||||
return data
|
||||
|
||||
def render(self, name: str, value, attrs=None, renderer=None) -> str:
|
||||
if not isinstance(value, list):
|
||||
value = self.decompress(value)
|
||||
output = []
|
||||
final_attrs = self.build_attrs(attrs or dict())
|
||||
if 'required' in final_attrs:
|
||||
del final_attrs['required']
|
||||
id_ = final_attrs.get('id', None)
|
||||
for i, widget in enumerate(self.widgets):
|
||||
try:
|
||||
widget_value = value[i]
|
||||
except (IndexError, TypeError):
|
||||
widget_value = None
|
||||
if id_:
|
||||
final_attrs = dict(
|
||||
final_attrs,
|
||||
id='%s_%s' % (id_, i),
|
||||
title=self.scheme['fields'][i][1],
|
||||
placeholder=self.scheme['fields'][i][1],
|
||||
)
|
||||
final_attrs['data-size'] = self.scheme['fields'][i][2]
|
||||
output.append(widget.render(name + '_%s' % i, widget_value, final_attrs, renderer=renderer))
|
||||
return mark_safe(self.format_output(output))
|
||||
|
||||
def format_output(self, rendered_widgets) -> str:
|
||||
return '<div class="nameparts-form-group">%s</div>' % ''.join(rendered_widgets)
|
||||
|
||||
|
||||
class NamePartsFormField(forms.MultiValueField):
|
||||
widget = NamePartsWidget
|
||||
|
||||
def compress(self, data_list) -> dict:
|
||||
data = {}
|
||||
data['_scheme'] = self.scheme_name
|
||||
for i, value in enumerate(data_list):
|
||||
data[self.scheme['fields'][i][0]] = value or ''
|
||||
return data
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
fields = []
|
||||
defaults = {
|
||||
'widget': self.widget,
|
||||
'max_length': kwargs.pop('max_length', None),
|
||||
}
|
||||
self.scheme_name = kwargs.pop('scheme')
|
||||
self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name)
|
||||
self.one_required = kwargs.get('required', True)
|
||||
require_all_fields = kwargs.pop('require_all_fields', False)
|
||||
kwargs['required'] = False
|
||||
kwargs['widget'] = (kwargs.get('widget') or self.widget)(
|
||||
scheme=self.scheme, field=self, **kwargs.pop('widget_kwargs', {})
|
||||
)
|
||||
defaults.update(**kwargs)
|
||||
for fname, label, size in self.scheme['fields']:
|
||||
defaults['label'] = label
|
||||
field = forms.CharField(**defaults)
|
||||
field.part_name = fname
|
||||
fields.append(field)
|
||||
super().__init__(
|
||||
fields=fields, require_all_fields=False, *args, **kwargs
|
||||
)
|
||||
self.require_all_fields = require_all_fields
|
||||
self.required = self.one_required
|
||||
|
||||
def clean(self, value) -> dict:
|
||||
value = super().clean(value)
|
||||
if self.one_required and (not value or not any(v for v in value)):
|
||||
raise forms.ValidationError(self.error_messages['required'], code='required')
|
||||
if self.require_all_fields and not all(v for v in value):
|
||||
raise forms.ValidationError(self.error_messages['incomplete'], code='required')
|
||||
return value
|
||||
|
||||
|
||||
class BaseQuestionsForm(forms.Form):
|
||||
"""
|
||||
This form class is responsible for asking order-related questions. This includes
|
||||
@@ -47,10 +148,12 @@ class BaseQuestionsForm(forms.Form):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if item.admission and event.settings.attendee_names_asked:
|
||||
self.fields['attendee_name'] = forms.CharField(
|
||||
max_length=255, required=event.settings.attendee_names_required,
|
||||
self.fields['attendee_name_parts'] = NamePartsFormField(
|
||||
max_length=255,
|
||||
required=event.settings.attendee_names_required,
|
||||
scheme=event.settings.name_scheme,
|
||||
label=_('Attendee name'),
|
||||
initial=(cartpos.attendee_name if cartpos else orderpos.attendee_name),
|
||||
initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts),
|
||||
)
|
||||
if item.admission and event.settings.attendee_emails_asked:
|
||||
self.fields['attendee_email'] = forms.EmailField(
|
||||
@@ -67,6 +170,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
else:
|
||||
initial = None
|
||||
tz = pytz.timezone(event.settings.timezone)
|
||||
help_text = rich_text(q.help_text)
|
||||
if q.type == Question.TYPE_BOOLEAN:
|
||||
if q.required:
|
||||
# For some reason, django-bootstrap3 does not set the required attribute
|
||||
@@ -82,7 +186,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
|
||||
field = forms.BooleanField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
help_text=help_text,
|
||||
initial=initialbool, widget=widget,
|
||||
)
|
||||
elif q.type == Question.TYPE_NUMBER:
|
||||
@@ -95,13 +199,13 @@ class BaseQuestionsForm(forms.Form):
|
||||
elif q.type == Question.TYPE_STRING:
|
||||
field = forms.CharField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
help_text=help_text,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_TEXT:
|
||||
field = forms.CharField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
help_text=help_text,
|
||||
widget=forms.Textarea,
|
||||
initial=initial.answer if initial else None,
|
||||
)
|
||||
@@ -109,7 +213,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
field = forms.ModelChoiceField(
|
||||
queryset=q.options,
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
help_text=help_text,
|
||||
widget=forms.Select,
|
||||
empty_label='',
|
||||
initial=initial.options.first() if initial else None,
|
||||
@@ -118,35 +222,35 @@ class BaseQuestionsForm(forms.Form):
|
||||
field = forms.ModelMultipleChoiceField(
|
||||
queryset=q.options,
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
help_text=help_text,
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
initial=initial.options.all() if initial else None,
|
||||
)
|
||||
elif q.type == Question.TYPE_FILE:
|
||||
field = forms.FileField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
help_text=help_text,
|
||||
initial=initial.file if initial else None,
|
||||
widget=UploadedFileWidget(position=pos, event=event, answer=initial),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATE:
|
||||
field = forms.DateField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
help_text=help_text,
|
||||
initial=dateutil.parser.parse(initial.answer).date() if initial and initial.answer else None,
|
||||
widget=DatePickerWidget(),
|
||||
)
|
||||
elif q.type == Question.TYPE_TIME:
|
||||
field = forms.TimeField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
help_text=help_text,
|
||||
initial=dateutil.parser.parse(initial.answer).time() if initial and initial.answer else None,
|
||||
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATETIME:
|
||||
field = SplitDateTimeField(
|
||||
label=q.question, required=q.required,
|
||||
help_text=q.help_text,
|
||||
help_text=help_text,
|
||||
initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None,
|
||||
widget=SplitDateTimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
|
||||
)
|
||||
@@ -170,13 +274,12 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = InvoiceAddress
|
||||
fields = ('is_business', 'company', 'name', 'street', 'zipcode', 'city', 'country', 'vat_id',
|
||||
fields = ('is_business', 'company', 'name_parts', 'street', 'zipcode', 'city', 'country', 'vat_id',
|
||||
'internal_reference')
|
||||
widgets = {
|
||||
'is_business': BusinessBooleanRadio,
|
||||
'street': forms.Textarea(attrs={'rows': 2, 'placeholder': _('Street and Number')}),
|
||||
'company': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
|
||||
'name': forms.TextInput(attrs={}),
|
||||
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
|
||||
'internal_reference': forms.TextInput,
|
||||
}
|
||||
@@ -191,15 +294,13 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not event.settings.invoice_address_vatid:
|
||||
del self.fields['vat_id']
|
||||
|
||||
if not event.settings.invoice_address_required:
|
||||
for k, f in self.fields.items():
|
||||
f.required = False
|
||||
f.widget.is_required = False
|
||||
if 'required' in f.widget.attrs:
|
||||
del f.widget.attrs['required']
|
||||
|
||||
if event.settings.invoice_name_required:
|
||||
self.fields['name'].required = True
|
||||
elif event.settings.invoice_address_company_required:
|
||||
self.initial['is_business'] = True
|
||||
|
||||
@@ -210,18 +311,34 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
del self.fields['company'].widget.attrs['data-display-dependency']
|
||||
if 'vat_id' in self.fields:
|
||||
del self.fields['vat_id'].widget.attrs['data-display-dependency']
|
||||
else:
|
||||
|
||||
self.fields['name_parts'] = NamePartsFormField(
|
||||
max_length=255,
|
||||
required=event.settings.invoice_name_required,
|
||||
scheme=event.settings.name_scheme,
|
||||
label=_('Name'),
|
||||
initial=(self.instance.name_parts if self.instance else self.instance.name_parts),
|
||||
)
|
||||
if event.settings.invoice_address_required and not event.settings.invoice_address_company_required:
|
||||
self.fields['name_parts'].widget.attrs['data-required-if'] = '#id_is_business_0'
|
||||
self.fields['name_parts'].widget.attrs['data-no-required-attr'] = '1'
|
||||
self.fields['company'].widget.attrs['data-required-if'] = '#id_is_business_1'
|
||||
self.fields['name'].widget.attrs['data-required-if'] = '#id_is_business_0'
|
||||
|
||||
def clean(self):
|
||||
data = self.cleaned_data
|
||||
if not data.get('name') and not data.get('company') and self.event.settings.invoice_address_required:
|
||||
raise ValidationError(_('You need to provide either a company name or your name.'))
|
||||
if not data.get('is_business'):
|
||||
data['company'] = ''
|
||||
if self.event.settings.invoice_address_required:
|
||||
if data.get('is_business') and not data.get('company'):
|
||||
raise ValidationError(_('You need to provide a company name.'))
|
||||
if not data.get('is_business') and not data.get('name_parts'):
|
||||
raise ValidationError(_('You need to provide your name.'))
|
||||
|
||||
if 'vat_id' in self.changed_data or not data.get('vat_id'):
|
||||
self.instance.vat_id_validated = False
|
||||
|
||||
self.instance.name_parts = data.get('name_parts')
|
||||
|
||||
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
|
||||
pass
|
||||
elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'):
|
||||
@@ -233,7 +350,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
country_code, normalized_id, company_name = result
|
||||
self.instance.vat_id_validated = True
|
||||
self.instance.vat_id = normalized_id
|
||||
except vat_moss.errors.InvalidError:
|
||||
except (vat_moss.errors.InvalidError, ValueError):
|
||||
raise ValidationError(_('This VAT ID is not valid. Please re-check your input.'))
|
||||
except vat_moss.errors.WebServiceUnavailableError:
|
||||
logger.exception('VAT ID checking failed for country {}'.format(data.get('country')))
|
||||
|
||||
@@ -192,8 +192,15 @@ class ThumbnailingImageReader(ImageReader):
|
||||
size=(int(width * dpi / 72), int(height * dpi / 72)),
|
||||
resample=BICUBIC
|
||||
)
|
||||
self._data = None
|
||||
return width, height
|
||||
|
||||
def _jpeg_fh(self):
|
||||
# Bypass a reportlab-internal optimization that falls back to the original
|
||||
# file handle if the file is a JPEG, and therefore does not respect the
|
||||
# (smaller) size of the modified image.
|
||||
return None
|
||||
|
||||
|
||||
class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
identifier = 'classic'
|
||||
@@ -216,7 +223,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
p.drawOn(canvas, 25 * mm, (297 - 52) * mm - p_size[1])
|
||||
|
||||
def _draw_invoice_from(self, canvas):
|
||||
p = Paragraph(self.invoice.invoice_from.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
|
||||
p = Paragraph(self.invoice.full_invoice_from.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
|
||||
p.wrapOn(canvas, 70 * mm, 50 * mm)
|
||||
p_size = p.wrap(70 * mm, 50 * mm)
|
||||
p.drawOn(canvas, 25 * mm, (297 - 17) * mm - p_size[1])
|
||||
@@ -323,7 +330,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
return txt
|
||||
|
||||
if not self.invoice.event.has_subevents:
|
||||
if self.invoice.event.settings.show_date_to:
|
||||
if self.invoice.event.settings.show_date_to and self.invoice.event.date_to:
|
||||
p_str = (
|
||||
shorten(self.invoice.event.name) + '\n' + pgettext('invoice', '{from_date}\nuntil {to_date}').format(
|
||||
from_date=self.invoice.event.get_date_from_display(),
|
||||
|
||||
@@ -28,7 +28,8 @@ class Migration(migrations.Migration):
|
||||
('password', models.CharField(verbose_name='password', max_length=128)),
|
||||
('last_login', models.DateTimeField(verbose_name='last login', blank=True, null=True)),
|
||||
('is_superuser', models.BooleanField(verbose_name='superuser status', default=False, help_text='Designates that this user has all permissions without explicitly assigning them.')),
|
||||
('email', models.EmailField(max_length=254, blank=True, unique=True, verbose_name='E-mail', null=True, db_index=True)),
|
||||
('email', models.EmailField(max_length=191, blank=True, unique=True, verbose_name='E-mail', null=True,
|
||||
db_index=True)),
|
||||
('givenname', models.CharField(verbose_name='Given name', max_length=255, blank=True, null=True)),
|
||||
('familyname', models.CharField(verbose_name='Family name', max_length=255, blank=True, null=True)),
|
||||
('is_active', models.BooleanField(verbose_name='Is active', default=True)),
|
||||
|
||||
79
src/pretix/base/migrations/0100_auto_20181023_2300.py
Normal file
79
src/pretix/base/migrations/0100_auto_20181023_2300.py
Normal file
@@ -0,0 +1,79 @@
|
||||
# Generated by Django 2.1 on 2018-10-23 23:00
|
||||
|
||||
import django_countries.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0099_auto_20180912_1035'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_from_city',
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_from_country',
|
||||
field=django_countries.fields.CountryField(max_length=2, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_from_name',
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_from_tax_id',
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_from_vat_id',
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_from_zipcode',
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_city',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_company',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_country',
|
||||
field=django_countries.fields.CountryField(max_length=2, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_name',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_street',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_vat_id',
|
||||
field=models.TextField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='invoice_to_zipcode',
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
]
|
||||
18
src/pretix/base/migrations/0101_auto_20181025_2255.py
Normal file
18
src/pretix/base/migrations/0101_auto_20181025_2255.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.1 on 2018-10-25 22:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0100_auto_20181023_2300'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='reverse_charge',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
96
src/pretix/base/migrations/0102_auto_20181017_0024.py
Normal file
96
src/pretix/base/migrations/0102_auto_20181017_0024.py
Normal file
@@ -0,0 +1,96 @@
|
||||
# Generated by Django 2.1 on 2018-10-17 00:24
|
||||
|
||||
import jsonfallback.fields
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import migrations
|
||||
from django_mysql.checks import mysql_connections
|
||||
from django_mysql.utils import connection_is_mariadb
|
||||
|
||||
|
||||
def set_attendee_name_parts(apps, schema_editor):
|
||||
OrderPosition = apps.get_model('pretixbase', 'OrderPosition') # noqa
|
||||
for op in OrderPosition.objects.exclude(attendee_name_cached=None).exclude(
|
||||
attendee_name_cached__isnull=True).iterator():
|
||||
op.attendee_name_parts = {'_legacy': op.attendee_name_cached}
|
||||
op.save(update_fields=['attendee_name_parts'])
|
||||
CartPosition = apps.get_model('pretixbase', 'CartPosition') # noqa
|
||||
for op in CartPosition.objects.exclude(attendee_name_cached=None).exclude(
|
||||
attendee_name_cached__isnull=True).iterator():
|
||||
op.attendee_name_parts = {'_legacy': op.attendee_name_cached}
|
||||
op.save(update_fields=['attendee_name_parts'])
|
||||
InvoiceAddress = apps.get_model('pretixbase', 'InvoiceAddress') # noqa
|
||||
for ia in InvoiceAddress.objects.exclude(name_cached=None).exclude(
|
||||
name_cached__isnull=True).iterator():
|
||||
ia.name_parts = {'_legacy': ia.name_cached}
|
||||
ia.save(update_fields=['name_parts'])
|
||||
|
||||
|
||||
def check_mysqlversion(apps, schema_editor):
|
||||
errors = []
|
||||
any_conn_works = False
|
||||
conns = list(mysql_connections())
|
||||
found = 'Unknown version'
|
||||
for alias, conn in conns:
|
||||
if connection_is_mariadb(conn) and hasattr(conn, 'mysql_version'):
|
||||
if conn.mysql_version >= (10, 2, 7):
|
||||
any_conn_works = True
|
||||
else:
|
||||
found = 'MariaDB ' + '.'.join(str(v) for v in conn.mysql_version)
|
||||
elif hasattr(conn, 'mysql_version'):
|
||||
if conn.mysql_version >= (5, 7):
|
||||
any_conn_works = True
|
||||
else:
|
||||
found = 'MySQL ' + '.'.join(str(v) for v in conn.mysql_version)
|
||||
|
||||
if conns and not any_conn_works:
|
||||
raise ImproperlyConfigured(
|
||||
'As of pretix 2.2, you need MySQL 5.7+ or MariaDB 10.2.7+ to run pretix. However, we detected a '
|
||||
'database connection to {}'.format(found)
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('pretixbase', '0101_auto_20181025_2255'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
check_mysqlversion, migrations.RunPython.noop
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='cartposition',
|
||||
old_name='attendee_name',
|
||||
new_name='attendee_name_cached',
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='orderposition',
|
||||
old_name='attendee_name',
|
||||
new_name='attendee_name_cached',
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='invoiceaddress',
|
||||
old_name='name',
|
||||
new_name='name_cached',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cartposition',
|
||||
name='attendee_name_parts',
|
||||
field=jsonfallback.fields.FallbackJSONField(null=False, default=dict),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='orderposition',
|
||||
name='attendee_name_parts',
|
||||
field=jsonfallback.fields.FallbackJSONField(null=False, default=dict),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoiceaddress',
|
||||
name='name_parts',
|
||||
field=jsonfallback.fields.FallbackJSONField(default=dict),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.RunPython(set_attendee_name_parts, migrations.RunPython.noop)
|
||||
]
|
||||
@@ -75,7 +75,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
email = models.EmailField(unique=True, db_index=True, null=True, blank=True,
|
||||
verbose_name=_('E-mail'))
|
||||
verbose_name=_('E-mail'), max_length=190)
|
||||
fullname = models.CharField(max_length=255, blank=True, null=True,
|
||||
verbose_name=_('Full name'))
|
||||
is_active = models.BooleanField(default=True,
|
||||
|
||||
@@ -3,6 +3,7 @@ import uuid
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.db.models.constants import LOOKUP_SEP
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
from django.utils.crypto import get_random_string
|
||||
@@ -52,6 +53,7 @@ class LoggingMixin:
|
||||
from .organizer import TeamAPIToken
|
||||
from ..notifications import get_all_notification_types
|
||||
from ..services.notifications import notify
|
||||
from pretix.api.webhooks import get_all_webhook_events, notify_webhooks
|
||||
|
||||
event = None
|
||||
if isinstance(self, Event):
|
||||
@@ -79,8 +81,21 @@ class LoggingMixin:
|
||||
if save:
|
||||
logentry.save()
|
||||
|
||||
if action in get_all_notification_types():
|
||||
no_types = get_all_notification_types()
|
||||
wh_types = get_all_webhook_events()
|
||||
|
||||
no_type = None
|
||||
wh_type = None
|
||||
typepath = logentry.action_type
|
||||
while (not no_type or not wh_types) and '.' in typepath:
|
||||
wh_type = wh_type or wh_types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
|
||||
no_type = no_type or no_types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
|
||||
typepath = typepath.rsplit('.', 1)[0]
|
||||
|
||||
if no_type:
|
||||
notify.apply_async(args=(logentry.pk,))
|
||||
if wh_type:
|
||||
notify_webhooks.apply_async(args=(logentry.pk,))
|
||||
return logentry
|
||||
|
||||
|
||||
@@ -100,3 +115,49 @@ class LoggedModel(models.Model, LoggingMixin):
|
||||
return LogEntry.objects.filter(
|
||||
content_type=ContentType.objects.get_for_model(type(self)), object_id=self.pk
|
||||
).select_related('user', 'event', 'oauth_application', 'api_token', 'device')
|
||||
|
||||
|
||||
class LockModel:
|
||||
def refresh_for_update(self, fields=None, using=None, **kwargs):
|
||||
"""
|
||||
Like refresh_from_db(), but with select_for_update().
|
||||
See also https://code.djangoproject.com/ticket/28344
|
||||
"""
|
||||
if fields is not None:
|
||||
if not fields:
|
||||
return
|
||||
if any(LOOKUP_SEP in f for f in fields):
|
||||
raise ValueError(
|
||||
'Found "%s" in fields argument. Relations and transforms '
|
||||
'are not allowed in fields.' % LOOKUP_SEP)
|
||||
|
||||
hints = {'instance': self}
|
||||
db_instance_qs = self.__class__._base_manager.db_manager(using, hints=hints).filter(pk=self.pk).select_for_update(**kwargs)
|
||||
|
||||
# Use provided fields, if not set then reload all non-deferred fields.
|
||||
deferred_fields = self.get_deferred_fields()
|
||||
if fields is not None:
|
||||
fields = list(fields)
|
||||
db_instance_qs = db_instance_qs.only(*fields)
|
||||
elif deferred_fields:
|
||||
fields = [f.attname for f in self._meta.concrete_fields
|
||||
if f.attname not in deferred_fields]
|
||||
db_instance_qs = db_instance_qs.only(*fields)
|
||||
|
||||
db_instance = db_instance_qs.get()
|
||||
non_loaded_fields = db_instance.get_deferred_fields()
|
||||
for field in self._meta.concrete_fields:
|
||||
if field.attname in non_loaded_fields:
|
||||
# This field wasn't refreshed - skip ahead.
|
||||
continue
|
||||
setattr(self, field.attname, getattr(db_instance, field.attname))
|
||||
# Clear cached foreign keys.
|
||||
if field.is_relation and field.is_cached(self):
|
||||
field.delete_cached_value(self)
|
||||
|
||||
# Clear cached relations.
|
||||
for field in self._meta.related_objects:
|
||||
if field.is_cached(self):
|
||||
field.delete_cached_value(self)
|
||||
|
||||
self._state.db = db_instance._state.db
|
||||
|
||||
@@ -282,10 +282,10 @@ class Event(EventMixin, LoggedModel):
|
||||
if not really:
|
||||
raise TypeError("Pass really=True as a parameter.")
|
||||
|
||||
OrderPosition.objects.all().delete(order__event=self)
|
||||
OrderFee.objects.all().delete(order__event=self)
|
||||
OrderPayment.objects.all().delete(order__event=self)
|
||||
OrderRefund.objects.all().delete(order__event=self)
|
||||
OrderPosition.objects.filter(order__event=self).delete()
|
||||
OrderFee.objects.filter(order__event=self).delete()
|
||||
OrderPayment.objects.filter(order__event=self).delete()
|
||||
OrderRefund.objects.filter(order__event=self).delete()
|
||||
self.orders.all().delete()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@@ -301,7 +301,7 @@ class Event(EventMixin, LoggedModel):
|
||||
return []
|
||||
return self.plugins.split(",")
|
||||
|
||||
def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache":
|
||||
def get_cache(self):
|
||||
"""
|
||||
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
|
||||
Django's built-in cache backends, but puts you into an isolated environment for
|
||||
|
||||
@@ -5,6 +5,8 @@ from django.db import DatabaseError, models, transaction
|
||||
from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import pgettext
|
||||
from django_countries.fields import CountryField
|
||||
|
||||
|
||||
def invoice_filename(instance, filename: str) -> str:
|
||||
@@ -73,11 +75,25 @@ class Invoice(models.Model):
|
||||
is_cancellation = models.BooleanField(default=False)
|
||||
refers = models.ForeignKey('Invoice', related_name='refered', null=True, blank=True, on_delete=models.CASCADE)
|
||||
invoice_from = models.TextField()
|
||||
invoice_from_name = models.CharField(max_length=190, null=True)
|
||||
invoice_from_zipcode = models.CharField(max_length=190, null=True)
|
||||
invoice_from_city = models.CharField(max_length=190, null=True)
|
||||
invoice_from_country = CountryField(null=True)
|
||||
invoice_from_tax_id = models.CharField(max_length=190, null=True)
|
||||
invoice_from_vat_id = models.CharField(max_length=190, null=True)
|
||||
invoice_to = models.TextField()
|
||||
invoice_to_company = models.TextField(null=True)
|
||||
invoice_to_name = models.TextField(null=True)
|
||||
invoice_to_street = models.TextField(null=True)
|
||||
invoice_to_zipcode = models.CharField(max_length=190, null=True)
|
||||
invoice_to_city = models.TextField(null=True)
|
||||
invoice_to_country = CountryField(null=True)
|
||||
invoice_to_vat_id = models.TextField(null=True)
|
||||
date = models.DateField(default=today)
|
||||
locale = models.CharField(max_length=50, default='en')
|
||||
introductory_text = models.TextField(blank=True)
|
||||
additional_text = models.TextField(blank=True)
|
||||
reverse_charge = models.BooleanField(default=False)
|
||||
payment_provider_text = models.TextField(blank=True)
|
||||
footer_text = models.TextField(blank=True)
|
||||
foreign_currency_display = models.CharField(max_length=50, null=True, blank=True)
|
||||
@@ -92,6 +108,18 @@ class Invoice(models.Model):
|
||||
def _to_numeric_invoice_number(number):
|
||||
return '{:05d}'.format(int(number))
|
||||
|
||||
@property
|
||||
def full_invoice_from(self):
|
||||
parts = [
|
||||
self.invoice_from_name,
|
||||
self.invoice_from,
|
||||
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
|
||||
str(self.invoice_from_country),
|
||||
pgettext("invoice", "VAT-ID: %s" % self.invoice_from_vat_id) if self.invoice_from_vat_id else "",
|
||||
pgettext("invoice", "Tax ID: %s" % self.invoice_from_tax_id) if self.invoice_from_tax_id else "",
|
||||
]
|
||||
return '\n'.join([p.strip() for p in parts if p and p.strip()])
|
||||
|
||||
def _get_numeric_invoice_number(self):
|
||||
numeric_invoices = Invoice.objects.filter(
|
||||
event__organizer=self.event.organizer,
|
||||
|
||||
@@ -403,12 +403,9 @@ class Item(LoggedModel):
|
||||
key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize))
|
||||
|
||||
def allow_delete(self):
|
||||
from pretix.base.models.orders import CartPosition, OrderPosition
|
||||
from pretix.base.models.orders import OrderPosition
|
||||
|
||||
return (
|
||||
not OrderPosition.objects.filter(item=self).exists()
|
||||
and not CartPosition.objects.filter(item=self).exists()
|
||||
)
|
||||
return not OrderPosition.objects.filter(item=self).exists()
|
||||
|
||||
@cached_property
|
||||
def has_variations(self):
|
||||
|
||||
@@ -63,6 +63,16 @@ class LogEntry(models.Model):
|
||||
return response
|
||||
return self.action_type
|
||||
|
||||
@cached_property
|
||||
def organizer(self):
|
||||
if self.event:
|
||||
return self.event.organizer
|
||||
elif hasattr(self.content_object, 'event'):
|
||||
return self.content_object.event.organizer
|
||||
elif hasattr(self.content_object, 'organizer'):
|
||||
return self.content_object.organizer
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
def display_object(self):
|
||||
from . import Order, Voucher, Quota, Item, ItemCategory, Question, Event, TaxRule, SubEvent
|
||||
|
||||
@@ -26,12 +26,14 @@ from django.utils.timezone import make_aware, now
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from django_countries.fields import CountryField
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from jsonfallback.fields import FallbackJSONField
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import User
|
||||
from pretix.base.reldate import RelativeDateWrapper
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
|
||||
from .base import LoggedModel
|
||||
from .base import LockModel, LoggedModel
|
||||
from .event import Event, SubEvent
|
||||
from .items import Item, ItemVariation, Question, QuestionOption, Quota
|
||||
|
||||
@@ -47,7 +49,7 @@ def generate_position_secret():
|
||||
return get_random_string(length=settings.ENTROPY['ticket_secret'], allowed_chars='abcdefghjkmnpqrstuvwxyz23456789')
|
||||
|
||||
|
||||
class Order(LoggedModel):
|
||||
class Order(LockModel, LoggedModel):
|
||||
"""
|
||||
An order is created when a user clicks 'buy' on his cart. It holds
|
||||
several OrderPositions and is connected to a user. It has an
|
||||
@@ -456,7 +458,7 @@ class Order(LoggedModel):
|
||||
error_messages = {
|
||||
'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the "
|
||||
"payment settings is over."),
|
||||
'late': _("The payment can not be accepted as it the order is expired and you configured that no late "
|
||||
'late': _("The payment can not be accepted as the order is expired and you configured that no late "
|
||||
"payments should be accepted in the payment settings."),
|
||||
'require_approval': _('This order is not yet approved by the event organizer.')
|
||||
}
|
||||
@@ -699,8 +701,10 @@ class AbstractPosition(models.Model):
|
||||
:type expires: datetime
|
||||
:param price: The price of this item
|
||||
:type price: decimal.Decimal
|
||||
:param attendee_name: The attendee's name, if entered.
|
||||
:type attendee_name: str
|
||||
:param attendee_name_parts: The parts of the attendee's name, if entered.
|
||||
:type attendee_name_parts: str
|
||||
:param attendee_name_cached: The concatenated version of the attendee's name, if entered.
|
||||
:type attendee_name_cached: str
|
||||
:param attendee_email: The attendee's email, if entered.
|
||||
:type attendee_email: str
|
||||
:param voucher: A voucher that has been applied to this sale
|
||||
@@ -729,12 +733,15 @@ class AbstractPosition(models.Model):
|
||||
decimal_places=2, max_digits=10,
|
||||
verbose_name=_("Price")
|
||||
)
|
||||
attendee_name = models.CharField(
|
||||
attendee_name_cached = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("Attendee name"),
|
||||
blank=True, null=True,
|
||||
help_text=_("Empty, if this product is not an admission ticket")
|
||||
)
|
||||
attendee_name_parts = FallbackJSONField(
|
||||
blank=True, default=dict
|
||||
)
|
||||
attendee_email = models.EmailField(
|
||||
verbose_name=_("Attendee email"),
|
||||
blank=True, null=True,
|
||||
@@ -797,6 +804,24 @@ class AbstractPosition(models.Model):
|
||||
if self.variation is None
|
||||
else self.variation.quotas.filter(subevent=self.subevent))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.attendee_name_cached = self.attendee_name
|
||||
if self.attendee_name_parts is None:
|
||||
self.attendee_name_parts = {}
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def attendee_name(self):
|
||||
if not self.attendee_name_parts:
|
||||
return None
|
||||
if '_legacy' in self.attendee_name_parts:
|
||||
return self.attendee_name_parts['_legacy']
|
||||
if '_scheme' in self.attendee_name_parts:
|
||||
scheme = PERSON_NAME_SCHEMES[self.attendee_name_parts['_scheme']]
|
||||
else:
|
||||
scheme = PERSON_NAME_SCHEMES[self.event.settings.name_scheme]
|
||||
return scheme['concatenation'](self.attendee_name_parts).strip()
|
||||
|
||||
|
||||
class OrderPayment(models.Model):
|
||||
"""
|
||||
@@ -901,9 +926,12 @@ class OrderPayment(models.Model):
|
||||
from pretix.base.signals import order_paid
|
||||
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist)
|
||||
if not force and can_be_paid is not True:
|
||||
self.order.log_action('pretix.event.order.quotaexceeded', {
|
||||
'message': can_be_paid
|
||||
}, user=user, auth=auth)
|
||||
raise Quota.QuotaExceededException(can_be_paid)
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.save()
|
||||
self.order.save(update_fields=['status'])
|
||||
|
||||
self.order.log_action('pretix.event.order.paid', {
|
||||
'provider': self.provider,
|
||||
@@ -1479,6 +1507,10 @@ class OrderPosition(AbstractPosition):
|
||||
self.pseudonymization_id = code
|
||||
return
|
||||
|
||||
@property
|
||||
def event(self):
|
||||
return self.order.event
|
||||
|
||||
|
||||
class CartPosition(AbstractPosition):
|
||||
"""
|
||||
@@ -1544,7 +1576,8 @@ class InvoiceAddress(models.Model):
|
||||
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address', on_delete=models.CASCADE)
|
||||
is_business = models.BooleanField(default=False, verbose_name=_('Business customer'))
|
||||
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'))
|
||||
name = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
|
||||
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
|
||||
name_parts = FallbackJSONField(default=dict)
|
||||
street = models.TextField(verbose_name=_('Address'), blank=False)
|
||||
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=False)
|
||||
city = models.CharField(max_length=255, verbose_name=_('City'), blank=False)
|
||||
@@ -1562,8 +1595,25 @@ class InvoiceAddress(models.Model):
|
||||
def save(self, **kwargs):
|
||||
if self.order:
|
||||
self.order.touch()
|
||||
|
||||
if self.name_parts:
|
||||
self.name_cached = self.name
|
||||
else:
|
||||
self.name_cached = ""
|
||||
super().save(**kwargs)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
if not self.name_parts:
|
||||
return ""
|
||||
if '_legacy' in self.name_parts:
|
||||
return self.name_parts['_legacy']
|
||||
if '_scheme' in self.name_parts:
|
||||
scheme = PERSON_NAME_SCHEMES[self.name_parts['_scheme']]
|
||||
else:
|
||||
raise TypeError("Invalid name given.")
|
||||
return scheme['concatenation'](self.name_parts).strip()
|
||||
|
||||
|
||||
def cachedticket_name(instance, filename: str) -> str:
|
||||
secret = get_random_string(length=16, allowed_chars=string.ascii_letters + string.digits)
|
||||
|
||||
@@ -82,6 +82,20 @@ class Organizer(LoggedModel):
|
||||
|
||||
return ObjectRelatedCache(self)
|
||||
|
||||
def allow_delete(self):
|
||||
from . import Order, Invoice
|
||||
return (
|
||||
not Order.objects.filter(event__organizer=self).exists() and
|
||||
not Invoice.objects.filter(event__organizer=self).exists() and
|
||||
not self.devices.exists()
|
||||
)
|
||||
|
||||
def delete_sub_objects(self):
|
||||
for e in self.events.all():
|
||||
e.delete_sub_objects()
|
||||
e.delete()
|
||||
self.teams.all().delete()
|
||||
|
||||
|
||||
def generate_invite_token():
|
||||
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
|
||||
|
||||
@@ -240,6 +240,8 @@ class Voucher(LoggedModel):
|
||||
def clean_quota_needs_checking(data, old_instance, item_changed, creating):
|
||||
# We only need to check for quota on vouchers that are now blocking quota and haven't
|
||||
# before (or have blocked a different quota before)
|
||||
if data.get('allow_ignore_quota', False):
|
||||
return False
|
||||
if data.get('block_quota', False):
|
||||
is_valid = data.get('valid_until') is None or data.get('valid_until') >= now()
|
||||
if not is_valid:
|
||||
|
||||
@@ -225,7 +225,7 @@ def register_default_notification_types(sender, **kwargs):
|
||||
),
|
||||
ParametrizedOrderNotificationType(
|
||||
sender,
|
||||
'pretix.event.order.changed',
|
||||
'pretix.event.order.changed.*',
|
||||
_('Order changed'),
|
||||
_('Order {order.code} has been changed.')
|
||||
),
|
||||
|
||||
@@ -257,16 +257,27 @@ class BasePaymentProvider:
|
||||
label=_('Restrict to countries'),
|
||||
choices=Countries(),
|
||||
help_text=_('Only allow choosing this payment provider for invoice addresses in the selected '
|
||||
'countries. If you don\'t select any country, all countries are allowed.'),
|
||||
'countries. If you don\'t select any country, all countries are allowed. This is only '
|
||||
'enabled if the invoice address is required.'),
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={'class': 'scrolling-multiple-choice'}
|
||||
),
|
||||
required=False
|
||||
required=False,
|
||||
disabled=not self.event.settings.invoice_address_required
|
||||
)),
|
||||
])
|
||||
d['_restricted_countries']._as_type = list
|
||||
return d
|
||||
|
||||
def settings_form_clean(self, cleaned_data):
|
||||
"""
|
||||
Overriding this method allows you to inject custom validation into the settings form.
|
||||
|
||||
:param cleaned_data: Form data as per previous validations.
|
||||
:return: Please return the modified cleaned_data
|
||||
"""
|
||||
return cleaned_data
|
||||
|
||||
def settings_content_render(self, request: HttpRequest) -> str:
|
||||
"""
|
||||
When the event's administrator visits the event configuration
|
||||
@@ -400,11 +411,12 @@ class BasePaymentProvider:
|
||||
request._checkout_flow_invoice_address = InvoiceAddress()
|
||||
return request._checkout_flow_invoice_address
|
||||
|
||||
restricted_countries = self.settings.get('_restricted_countries', as_type=list)
|
||||
if restricted_countries:
|
||||
ia = get_invoice_address()
|
||||
if str(ia.country) not in restricted_countries:
|
||||
return False
|
||||
if self.event.settings.invoice_address_required:
|
||||
restricted_countries = self.settings.get('_restricted_countries', as_type=list)
|
||||
if restricted_countries:
|
||||
ia = get_invoice_address()
|
||||
if str(ia.country) not in restricted_countries:
|
||||
return False
|
||||
|
||||
return timing and pricing
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from reportlab.platypus import Paragraph
|
||||
|
||||
from pretix.base.invoice import ThumbnailingImageReader
|
||||
from pretix.base.models import Order, OrderPosition
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import layout_text_variables
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.presale.style import get_fonts
|
||||
@@ -147,12 +148,12 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"evaluate": lambda op, order, ev: str(ev.location).replace("\n", "<br/>\n")
|
||||
}),
|
||||
("invoice_name", {
|
||||
"label": _("Invoice address: name"),
|
||||
"label": _("Invoice address name"),
|
||||
"editor_sample": _("John Doe"),
|
||||
"evaluate": lambda op, order, ev: order.invoice_address.name if getattr(order, 'invoice_address', None) else ''
|
||||
}),
|
||||
("invoice_company", {
|
||||
"label": _("Invoice address: company"),
|
||||
"label": _("Invoice address company"),
|
||||
"editor_sample": _("Sample company"),
|
||||
"evaluate": lambda op, order, ev: order.invoice_address.company if getattr(order, 'invoice_address', None) else ''
|
||||
}),
|
||||
@@ -182,8 +183,28 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
|
||||
def get_variables(event):
|
||||
v = copy.copy(DEFAULT_VARIABLES)
|
||||
|
||||
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
|
||||
for key, label, weight in scheme['fields']:
|
||||
v['attendee_name_%s' % key] = {
|
||||
'label': _("Attendee name: {part}").format(part=label),
|
||||
'editor_sample': scheme['sample'][key],
|
||||
'evaluate': lambda op, order, ev: op.attendee_name_parts.get(key, '')
|
||||
}
|
||||
|
||||
v['invoice_name']['editor_sample'] = scheme['concatenation'](scheme['sample'])
|
||||
v['attendee_name']['editor_sample'] = scheme['concatenation'](scheme['sample'])
|
||||
|
||||
for key, label, weight in scheme['fields']:
|
||||
v['invoice_name_%s' % key] = {
|
||||
'label': _("Invoice address name: {part}").format(part=label),
|
||||
'editor_sample': scheme['sample'][key],
|
||||
"evaluate": lambda op, order, ev: order.invoice_address.name_parts.get(key, '') if getattr(order, 'invoice_address', None) else ''
|
||||
}
|
||||
|
||||
for recv, res in layout_text_variables.send(sender=event):
|
||||
v.update(res)
|
||||
|
||||
return v
|
||||
|
||||
|
||||
@@ -194,7 +215,7 @@ class Renderer:
|
||||
self.background_file = background_file
|
||||
self.variables = get_variables(event)
|
||||
if self.background_file:
|
||||
self.bg_pdf = PdfFileReader(BytesIO(self.background_file.read()))
|
||||
self.bg_pdf = PdfFileReader(BytesIO(self.background_file.read()), strict=False)
|
||||
else:
|
||||
self.bg_pdf = None
|
||||
|
||||
@@ -216,6 +237,8 @@ class Renderer:
|
||||
|
||||
def _draw_poweredby(self, canvas: Canvas, op: OrderPosition, o: dict):
|
||||
content = o.get('content', 'dark')
|
||||
if content not in ('dark', 'white'):
|
||||
content = 'dark'
|
||||
img = finders.find('pretixpresale/pdf/powered_by_pretix_{}.png'.format(content))
|
||||
|
||||
ir = ThumbnailingImageReader(img)
|
||||
|
||||
@@ -570,6 +570,7 @@ class CartManager:
|
||||
if op.position.expires > self.now_dt:
|
||||
for q in op.position.quotas:
|
||||
quotas_ok[q] += 1
|
||||
op.position.addons.all().delete()
|
||||
op.position.delete()
|
||||
|
||||
elif isinstance(op, self.AddOperation) or isinstance(op, self.ExtendOperation):
|
||||
|
||||
@@ -59,7 +59,8 @@ def _save_answers(op, answers, given_answers):
|
||||
|
||||
@transaction.atomic
|
||||
def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False,
|
||||
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True):
|
||||
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
|
||||
user=None, auth=None):
|
||||
"""
|
||||
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.
|
||||
@@ -133,7 +134,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
'forced': op.order.status != Order.STATUS_PAID,
|
||||
'datetime': dt,
|
||||
'list': clist.pk
|
||||
})
|
||||
}, user=user, auth=auth)
|
||||
else:
|
||||
if not force:
|
||||
raise CheckInError(
|
||||
@@ -147,4 +148,4 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
|
||||
'forced': force,
|
||||
'datetime': dt,
|
||||
'list': clist.pk
|
||||
})
|
||||
}, user=user, auth=auth)
|
||||
|
||||
@@ -14,6 +14,7 @@ from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import pgettext, ugettext as _
|
||||
from django_countries.fields import Country
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.i18n import language
|
||||
@@ -40,6 +41,12 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
|
||||
with language(invoice.locale):
|
||||
invoice.invoice_from = invoice.event.settings.get('invoice_address_from')
|
||||
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
|
||||
invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
|
||||
invoice.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
|
||||
invoice.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
|
||||
invoice.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
|
||||
invoice.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
|
||||
|
||||
introductory = invoice.event.settings.get('invoice_introductory_text', as_type=LazyI18nString)
|
||||
additional = invoice.event.settings.get('invoice_additional_text', as_type=LazyI18nString)
|
||||
@@ -66,8 +73,16 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
country=ia.country.name if ia.country else ia.country_old
|
||||
).strip()
|
||||
invoice.internal_reference = ia.internal_reference
|
||||
invoice.invoice_to_company = ia.company
|
||||
invoice.invoice_to_name = ia.name
|
||||
invoice.invoice_to_street = ia.street
|
||||
invoice.invoice_to_zipcode = ia.zipcode
|
||||
invoice.invoice_to_city = ia.city
|
||||
invoice.invoice_to_country = ia.country
|
||||
|
||||
if ia.vat_id:
|
||||
invoice.invoice_to += "\n" + pgettext("invoice", "VAT-ID: %s") % ia.vat_id
|
||||
invoice.invoice_to_vat_id = ia.vat_id
|
||||
|
||||
cc = str(ia.country)
|
||||
|
||||
@@ -138,6 +153,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
"Reverse Charge: According to Article 194, 196 of Council Directive 2006/112/EEC, VAT liability "
|
||||
"rests with the service recipient."
|
||||
)
|
||||
invoice.reverse_charge = True
|
||||
invoice.save()
|
||||
|
||||
offset = len(positions)
|
||||
@@ -267,6 +283,12 @@ def build_preview_invoice_pdf(event):
|
||||
date=timezone.now().date(), locale=locale, organizer=event.organizer
|
||||
)
|
||||
invoice.invoice_from = event.settings.get('invoice_address_from')
|
||||
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
|
||||
invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
|
||||
invoice.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
|
||||
invoice.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
|
||||
invoice.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
|
||||
invoice.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
|
||||
|
||||
introductory = event.settings.get('invoice_introductory_text', as_type=LazyI18nString)
|
||||
additional = event.settings.get('invoice_additional_text', as_type=LazyI18nString)
|
||||
@@ -277,7 +299,15 @@ def build_preview_invoice_pdf(event):
|
||||
invoice.additional_text = str(additional).replace('\n', '<br />')
|
||||
invoice.footer_text = str(footer)
|
||||
invoice.payment_provider_text = str(payment).replace('\n', '<br />')
|
||||
invoice.invoice_to = _("John Doe\n214th Example Street\n012345 Somecity")
|
||||
invoice.invoice_to_name = _("John Doe")
|
||||
invoice.invoice_to_street = _("214th Example Street")
|
||||
invoice.invoice_to_zipcode = _("012345")
|
||||
invoice.invoice_to_city = _('Sample city')
|
||||
invoice.invoice_to_country = Country('DE')
|
||||
invoice.invoice_to = '{}\n{}\n{} {}'.format(
|
||||
invoice.invoice_to_name, invoice.invoice_to_street,
|
||||
invoice.invoice_to_zipcode, invoice.invoice_to_city
|
||||
)
|
||||
invoice.file = None
|
||||
invoice.save()
|
||||
invoice.lines.all().delete()
|
||||
|
||||
@@ -206,11 +206,14 @@ def mail_send_task(*args, to: List[str], subject: str, body: str, html: str, sen
|
||||
else:
|
||||
if attach_tickets:
|
||||
for name, ct in get_tickets_for_order(order):
|
||||
email.attach(
|
||||
name,
|
||||
ct.file.read(),
|
||||
ct.type
|
||||
)
|
||||
try:
|
||||
email.attach(
|
||||
name,
|
||||
ct.file.read(),
|
||||
ct.type
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
email = email_filter.send_chained(event, 'message', message=email, order=order)
|
||||
|
||||
|
||||
@@ -17,9 +17,15 @@ def notify(logentry_id: int):
|
||||
if not logentry.event:
|
||||
return # Ignore, we only have event-related notifications right now
|
||||
types = get_all_notification_types(logentry.event)
|
||||
notification_type = types.get(logentry.action_type)
|
||||
|
||||
notification_type = None
|
||||
typepath = logentry.action_type
|
||||
while not notification_type and '.' in typepath:
|
||||
notification_type = types.get(typepath + ('.*' if typepath != logentry.action_type else ''))
|
||||
typepath = typepath.rsplit('.', 1)[0]
|
||||
|
||||
if not notification_type:
|
||||
return # Ignore, e.g. plugin not active for this event
|
||||
return # No suitable plugin
|
||||
|
||||
# All users that have the permission to get the notification
|
||||
users = logentry.event.get_users_with_permission(
|
||||
@@ -33,7 +39,7 @@ def notify(logentry_id: int):
|
||||
(ns.user, ns.method): ns.enabled
|
||||
for ns in NotificationSetting.objects.filter(
|
||||
event=logentry.event,
|
||||
action_type=logentry.action_type,
|
||||
action_type=notification_type.action_type,
|
||||
user__pk__in=users.values_list('pk', flat=True)
|
||||
)
|
||||
}
|
||||
@@ -41,7 +47,7 @@ def notify(logentry_id: int):
|
||||
(ns.user, ns.method): ns.enabled
|
||||
for ns in NotificationSetting.objects.filter(
|
||||
event__isnull=True,
|
||||
action_type=logentry.action_type,
|
||||
action_type=notification_type.action_type,
|
||||
user__pk__in=users.values_list('pk', flat=True)
|
||||
)
|
||||
}
|
||||
@@ -49,20 +55,20 @@ def notify(logentry_id: int):
|
||||
for um, enabled in notify_specific.items():
|
||||
user, method = um
|
||||
if enabled:
|
||||
send_notification.apply_async(args=(logentry_id, user.pk, method))
|
||||
send_notification.apply_async(args=(logentry_id, notification_type.action_type, user.pk, method))
|
||||
|
||||
for um, enabled in notify_global.items():
|
||||
user, method = um
|
||||
if enabled and um not in notify_specific:
|
||||
send_notification.apply_async(args=(logentry_id, user.pk, method))
|
||||
send_notification.apply_async(args=(logentry_id, notification_type.action_type, user.pk, method))
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
def send_notification(logentry_id: int, user_id: int, method: str):
|
||||
def send_notification(logentry_id: int, action_type: str, user_id: int, method: str):
|
||||
logentry = LogEntry.all.get(id=logentry_id)
|
||||
user = User.objects.get(id=user_id)
|
||||
types = get_all_notification_types(logentry.event)
|
||||
notification_type = types.get(logentry.action_type)
|
||||
notification_type = types.get(action_type)
|
||||
if not notification_type:
|
||||
return # Ignore, e.g. plugin not active for this event
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
|
||||
raise OrderError(_('The new expiry date needs to be in the future.'))
|
||||
if order.status == Order.STATUS_PENDING:
|
||||
order.expires = new_date
|
||||
order.save()
|
||||
order.save(update_fields=['expires'])
|
||||
order.log_action(
|
||||
'pretix.event.order.expirychanged',
|
||||
user=user,
|
||||
@@ -109,7 +109,7 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
|
||||
if is_available is True or force is True:
|
||||
order.expires = new_date
|
||||
order.status = Order.STATUS_PENDING
|
||||
order.save()
|
||||
order.save(update_fields=['expires', 'status'])
|
||||
order.log_action(
|
||||
'pretix.event.order.expirychanged',
|
||||
user=user,
|
||||
@@ -136,7 +136,7 @@ def mark_order_refunded(order, user=None, auth=None, api_token=None):
|
||||
user = User.objects.get(pk=user)
|
||||
with order.event.lock():
|
||||
order.status = Order.STATUS_REFUNDED
|
||||
order.save()
|
||||
order.save(update_fields=['status'])
|
||||
|
||||
order.log_action('pretix.event.order.refunded', user=user, auth=auth or api_token)
|
||||
i = order.invoices.filter(is_cancellation=False).last()
|
||||
@@ -159,7 +159,7 @@ def mark_order_expired(order, user=None, auth=None):
|
||||
user = User.objects.get(pk=user)
|
||||
with order.event.lock():
|
||||
order.status = Order.STATUS_EXPIRED
|
||||
order.save()
|
||||
order.save(update_fields=['status'])
|
||||
|
||||
order.log_action('pretix.event.order.expired', user=user, auth=auth)
|
||||
i = order.invoices.filter(is_cancellation=False).last()
|
||||
@@ -181,7 +181,7 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None):
|
||||
|
||||
order.require_approval = False
|
||||
order.set_expires(now(), order.event.subevents.filter(id__in=[p.subevent_id for p in order.positions.all()]))
|
||||
order.save()
|
||||
order.save(update_fields=['require_approval', 'expires'])
|
||||
|
||||
order.log_action('pretix.event.order.approved', user=user, auth=auth)
|
||||
if order.total == Decimal('0.00'):
|
||||
@@ -258,7 +258,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
||||
|
||||
with order.event.lock():
|
||||
order.status = Order.STATUS_CANCELED
|
||||
order.save()
|
||||
order.save(update_fields=['status'])
|
||||
|
||||
order.log_action('pretix.event.order.denied', user=user, auth=auth, data={
|
||||
'comment': comment
|
||||
@@ -327,7 +327,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
if not order.cancel_allowed():
|
||||
raise OrderError(_('You cannot cancel this order.'))
|
||||
order.status = Order.STATUS_CANCELED
|
||||
order.save()
|
||||
order.save(update_fields=['status'])
|
||||
|
||||
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device)
|
||||
i = order.invoices.filter(is_cancellation=False).last()
|
||||
@@ -661,47 +661,52 @@ def send_expiry_warnings(sender, **kwargs):
|
||||
eventcache = {}
|
||||
today = now().replace(hour=0, minute=0, second=0)
|
||||
|
||||
for o in Order.objects.filter(expires__gte=today, expiry_reminder_sent=False, status=Order.STATUS_PENDING).select_related('event'):
|
||||
eventsettings = eventcache.get(o.event.pk, None)
|
||||
if eventsettings is None:
|
||||
eventsettings = o.event.settings
|
||||
eventcache[o.event.pk] = eventsettings
|
||||
for o in Order.objects.filter(expires__gte=today, expiry_reminder_sent=False, status=Order.STATUS_PENDING).only('pk'):
|
||||
with transaction.atomic():
|
||||
o = Order.objects.select_related('event').select_for_update().get(pk=o.pk)
|
||||
if o.status != Order.STATUS_PENDING or o.expiry_reminder_sent:
|
||||
# Race condition
|
||||
continue
|
||||
eventsettings = eventcache.get(o.event.pk, None)
|
||||
if eventsettings is None:
|
||||
eventsettings = o.event.settings
|
||||
eventcache[o.event.pk] = eventsettings
|
||||
|
||||
days = eventsettings.get('mail_days_order_expire_warning', as_type=int)
|
||||
tz = pytz.timezone(eventsettings.get('timezone', settings.TIME_ZONE))
|
||||
if days and (o.expires - today).days <= days:
|
||||
with language(o.locale):
|
||||
o.expiry_reminder_sent = True
|
||||
o.save()
|
||||
try:
|
||||
invoice_name = o.invoice_address.name
|
||||
invoice_company = o.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
email_template = eventsettings.mail_text_order_expire_warning
|
||||
email_context = {
|
||||
'event': o.event.name,
|
||||
'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
|
||||
'order': o.code,
|
||||
'secret': o.secret
|
||||
}),
|
||||
'expire_date': date_format(o.expires.astimezone(tz), 'SHORT_DATE_FORMAT'),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
}
|
||||
if eventsettings.payment_term_expire_automatically:
|
||||
email_subject = _('Your order is about to expire: %(code)s') % {'code': o.code}
|
||||
else:
|
||||
email_subject = _('Your order is pending payment: %(code)s') % {'code': o.code}
|
||||
days = eventsettings.get('mail_days_order_expire_warning', as_type=int)
|
||||
tz = pytz.timezone(eventsettings.get('timezone', settings.TIME_ZONE))
|
||||
if days and (o.expires - today).days <= days:
|
||||
with language(o.locale):
|
||||
o.expiry_reminder_sent = True
|
||||
o.save(update_fields=['expiry_reminder_sent'])
|
||||
try:
|
||||
invoice_name = o.invoice_address.name
|
||||
invoice_company = o.invoice_address.company
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
invoice_name = ""
|
||||
invoice_company = ""
|
||||
email_template = eventsettings.mail_text_order_expire_warning
|
||||
email_context = {
|
||||
'event': o.event.name,
|
||||
'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
|
||||
'order': o.code,
|
||||
'secret': o.secret
|
||||
}),
|
||||
'expire_date': date_format(o.expires.astimezone(tz), 'SHORT_DATE_FORMAT'),
|
||||
'invoice_name': invoice_name,
|
||||
'invoice_company': invoice_company,
|
||||
}
|
||||
if eventsettings.payment_term_expire_automatically:
|
||||
email_subject = _('Your order is about to expire: %(code)s') % {'code': o.code}
|
||||
else:
|
||||
email_subject = _('Your order is pending payment: %(code)s') % {'code': o.code}
|
||||
|
||||
try:
|
||||
o.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.expire_warning_sent'
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Reminder email could not be sent')
|
||||
try:
|
||||
o.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.expire_warning_sent'
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Reminder email could not be sent')
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
@@ -709,6 +714,7 @@ def send_download_reminders(sender, **kwargs):
|
||||
today = now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
for e in Event.objects.filter(date_from__gte=today):
|
||||
|
||||
days = e.settings.get('mail_days_download_reminder', as_type=int)
|
||||
if days is None:
|
||||
continue
|
||||
@@ -717,30 +723,35 @@ def send_download_reminders(sender, **kwargs):
|
||||
|
||||
if now() < reminder_date:
|
||||
continue
|
||||
for o in e.orders.filter(status=Order.STATUS_PAID, download_reminder_sent=False):
|
||||
if not all([r for rr, r in allow_ticket_download.send(e, order=o)]):
|
||||
continue
|
||||
for o in e.orders.filter(status=Order.STATUS_PAID, download_reminder_sent=False).only('pk'):
|
||||
with transaction.atomic():
|
||||
o = Order.objects.select_related('event').select_for_update().get(pk=o.pk)
|
||||
if o.download_reminder_sent:
|
||||
# Race condition
|
||||
continue
|
||||
if not all([r for rr, r in allow_ticket_download.send(e, order=o)]):
|
||||
continue
|
||||
|
||||
with language(o.locale):
|
||||
o.download_reminder_sent = True
|
||||
o.save()
|
||||
email_template = e.settings.mail_text_download_reminder
|
||||
email_context = {
|
||||
'event': o.event.name,
|
||||
'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
|
||||
'order': o.code,
|
||||
'secret': o.secret
|
||||
}),
|
||||
}
|
||||
email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code}
|
||||
try:
|
||||
o.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.download_reminder_sent',
|
||||
attach_tickets=True
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Reminder email could not be sent')
|
||||
with language(o.locale):
|
||||
o.download_reminder_sent = True
|
||||
o.save(update_fields=['download_reminder_sent'])
|
||||
email_template = e.settings.mail_text_download_reminder
|
||||
email_context = {
|
||||
'event': o.event.name,
|
||||
'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
|
||||
'order': o.code,
|
||||
'secret': o.secret
|
||||
}),
|
||||
}
|
||||
email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code}
|
||||
try:
|
||||
o.send_mail(
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.download_reminder_sent',
|
||||
attach_tickets=True
|
||||
)
|
||||
except SendMailException:
|
||||
logger.exception('Reminder email could not be sent')
|
||||
|
||||
|
||||
class OrderChangeManager:
|
||||
@@ -911,12 +922,13 @@ class OrderChangeManager:
|
||||
|
||||
def _check_paid_price_change(self):
|
||||
if self.order.status == Order.STATUS_PAID and self._totaldiff > 0:
|
||||
self.order.status = Order.STATUS_PENDING
|
||||
self.order.set_expires(
|
||||
now(),
|
||||
self.order.event.subevents.filter(id__in=self.order.positions.values_list('subevent_id', flat=True))
|
||||
)
|
||||
self.order.save()
|
||||
if self.order.pending_sum > Decimal('0.00'):
|
||||
self.order.status = Order.STATUS_PENDING
|
||||
self.order.set_expires(
|
||||
now(),
|
||||
self.order.event.subevents.filter(id__in=self.order.positions.values_list('subevent_id', flat=True))
|
||||
)
|
||||
self.order.save()
|
||||
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff < 0:
|
||||
if self.order.pending_sum <= Decimal('0.00'):
|
||||
self.order.status = Order.STATUS_PAID
|
||||
@@ -1170,7 +1182,7 @@ class OrderChangeManager:
|
||||
fee.save()
|
||||
if not self.open_payment.fee:
|
||||
self.open_payment.fee = fee
|
||||
self.open_payment.save()
|
||||
self.open_payment.save(update_fields=['fee'])
|
||||
elif fee:
|
||||
fee.delete()
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from pretix.base.models import (
|
||||
OrderPosition,
|
||||
)
|
||||
from pretix.base.services.tasks import ProfiledTask
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import allow_ticket_download, register_ticket_outputs
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.database import rolledback_transaction
|
||||
@@ -87,11 +88,13 @@ def preview(event: int, provider: str):
|
||||
locale=event.settings.locale,
|
||||
expires=now(), code="PREVIEW1234", total=119)
|
||||
|
||||
p = order.positions.create(item=item, attendee_name=_("John Doe"), price=item.default_price)
|
||||
order.positions.create(item=item2, attendee_name=_("John Doe"), price=item.default_price, addon_to=p)
|
||||
order.positions.create(item=item2, attendee_name=_("John Doe"), price=item.default_price, addon_to=p)
|
||||
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
|
||||
sample = {k: str(v) for k, v in scheme['sample'].items()}
|
||||
p = order.positions.create(item=item, attendee_name_parts=sample, price=item.default_price)
|
||||
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=p)
|
||||
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=p)
|
||||
|
||||
InvoiceAddress.objects.create(order=order, name=_("John Doe"), company=_("Sample company"))
|
||||
InvoiceAddress.objects.create(order=order, name_parts=sample, company=_("Sample company"))
|
||||
|
||||
responses = register_ticket_outputs.send(event)
|
||||
for receiver, response in responses:
|
||||
|
||||
@@ -74,5 +74,5 @@ def process_waitinglist(sender, **kwargs):
|
||||
live=True
|
||||
).prefetch_related('_settings_objects', 'organizer___settings_objects').select_related('organizer')
|
||||
for e in qs:
|
||||
if e.settings.waiting_list_enabled and e.settings.waiting_list_auto and e.presale_is_running:
|
||||
if e.settings.waiting_list_auto and e.presale_is_running:
|
||||
assign_automatically.apply_async(args=(e.pk,))
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files import File
|
||||
from django.db.models import Model
|
||||
from django.utils.translation import ugettext_noop
|
||||
from django.utils.translation import (
|
||||
pgettext_lazy, ugettext_lazy as _, ugettext_noop,
|
||||
)
|
||||
from hierarkey.models import GlobalSettingsBase, Hierarkey
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
@@ -556,7 +559,154 @@ Your {event} team"""))
|
||||
'default': 'date_ascending',
|
||||
'type': str
|
||||
},
|
||||
'name_scheme': {
|
||||
'default': 'full',
|
||||
'type': str
|
||||
}
|
||||
}
|
||||
PERSON_NAME_SCHEMES = OrderedDict([
|
||||
('given_family', {
|
||||
'fields': (
|
||||
('given_name', _('Given name'), 1),
|
||||
('family_name', _('Family name'), 1),
|
||||
),
|
||||
'concatenation': lambda d: ' '.join(str(p) for p in [d.get('given_name', ''), d.get('family_name', '')] if p),
|
||||
'sample': {
|
||||
'given_name': pgettext_lazy('person_name_sample', 'John'),
|
||||
'family_name': pgettext_lazy('person_name_sample', 'Doe'),
|
||||
'_scheme': 'given_family',
|
||||
},
|
||||
}),
|
||||
('title_given_family', {
|
||||
'fields': (
|
||||
('title', pgettext_lazy('person_name', 'Title'), 1),
|
||||
('given_name', _('Given name'), 2),
|
||||
('family_name', _('Family name'), 2),
|
||||
),
|
||||
'concatenation': lambda d: ' '.join(
|
||||
str(p) for p in [d.get('title', ''), d.get('given_name', ''), d.get('family_name', '')] if p
|
||||
),
|
||||
'sample': {
|
||||
'title': pgettext_lazy('person_name_sample', 'Dr'),
|
||||
'given_name': pgettext_lazy('person_name_sample', 'John'),
|
||||
'family_name': pgettext_lazy('person_name_sample', 'Doe'),
|
||||
'_scheme': 'title_given_family',
|
||||
},
|
||||
}),
|
||||
('given_middle_family', {
|
||||
'fields': (
|
||||
('given_name', _('First name'), 2),
|
||||
('middle_name', _('Middle name'), 1),
|
||||
('family_name', _('Family name'), 2),
|
||||
),
|
||||
'concatenation': lambda d: ' '.join(
|
||||
str(p) for p in [d.get('given_name', ''), d.get('middle_name', ''), d.get('family_name', '')] if p
|
||||
),
|
||||
'sample': {
|
||||
'given_name': pgettext_lazy('person_name_sample', 'John'),
|
||||
'middle_name': 'M',
|
||||
'family_name': pgettext_lazy('person_name_sample', 'Doe'),
|
||||
'_scheme': 'given_middle_family',
|
||||
},
|
||||
}),
|
||||
('title_given_middle_family', {
|
||||
'fields': (
|
||||
('title', pgettext_lazy('person_name', 'Title'), 1),
|
||||
('given_name', _('First name'), 2),
|
||||
('middle_name', _('Middle name'), 1),
|
||||
('family_name', _('Family name'), 1),
|
||||
),
|
||||
'concatenation': lambda d: ' '.join(
|
||||
str(p) for p in [d.get('title', ''), d.get('given_name'), d.get('middle_name'), d.get('family_name')] if p
|
||||
),
|
||||
'sample': {
|
||||
'title': pgettext_lazy('person_name_sample', 'Dr'),
|
||||
'given_name': pgettext_lazy('person_name_sample', 'John'),
|
||||
'middle_name': 'M',
|
||||
'family_name': pgettext_lazy('person_name_sample', 'Doe'),
|
||||
'_scheme': 'title_given_middle_family',
|
||||
},
|
||||
}),
|
||||
('family_given', {
|
||||
'fields': (
|
||||
('family_name', _('Family name'), 1),
|
||||
('given_name', _('Given name'), 1),
|
||||
),
|
||||
'concatenation': lambda d: ' '.join(
|
||||
str(p) for p in [d.get('family_name', ''), d.get('given_name', '')] if p
|
||||
),
|
||||
'sample': {
|
||||
'given_name': pgettext_lazy('person_name_sample', 'John'),
|
||||
'family_name': pgettext_lazy('person_name_sample', 'Doe'),
|
||||
'_scheme': 'family_given',
|
||||
},
|
||||
}),
|
||||
('family_nospace_given', {
|
||||
'fields': (
|
||||
('given_name', _('Given name'), 1),
|
||||
('family_name', _('Family name'), 1),
|
||||
),
|
||||
'concatenation': lambda d: ''.join(
|
||||
str(p) for p in [d.get('family_name', ''), d.get('given_name', '')] if p
|
||||
),
|
||||
'sample': {
|
||||
'given_name': '泽东',
|
||||
'family_name': '毛',
|
||||
'_scheme': 'family_nospace_given',
|
||||
},
|
||||
}),
|
||||
('family_comma_given', {
|
||||
'fields': (
|
||||
('given_name', _('Given name'), 1),
|
||||
('family_name', _('Family name'), 1),
|
||||
),
|
||||
'concatenation': lambda d: (
|
||||
str(d.get('family_name', '')) +
|
||||
str((', ' if d.get('family_name') and d.get('given_name') else '')) +
|
||||
str(d.get('given_name', ''))
|
||||
),
|
||||
'sample': {
|
||||
'given_name': pgettext_lazy('person_name_sample', 'John'),
|
||||
'family_name': pgettext_lazy('person_name_sample', 'Doe'),
|
||||
'_scheme': 'family_comma_given',
|
||||
},
|
||||
}),
|
||||
('full', {
|
||||
'fields': (
|
||||
('full_name', _('Name'), 1),
|
||||
),
|
||||
'concatenation': lambda d: str(d.get('full_name', '')),
|
||||
'sample': {
|
||||
'full_name': pgettext_lazy('person_name_sample', 'John Doe'),
|
||||
'_scheme': 'full',
|
||||
},
|
||||
}),
|
||||
('calling_full', {
|
||||
'fields': (
|
||||
('calling_name', _('Calling name'), 1),
|
||||
('full_name', _('Full name'), 2),
|
||||
),
|
||||
'concatenation': lambda d: str(d.get('full_name', '')),
|
||||
'sample': {
|
||||
'full_name': pgettext_lazy('person_name_sample', 'John Doe'),
|
||||
'calling_name': pgettext_lazy('person_name_sample', 'John'),
|
||||
'_scheme': 'calling_full',
|
||||
},
|
||||
}),
|
||||
('full_transcription', {
|
||||
'fields': (
|
||||
('full_name', _('Full name'), 1),
|
||||
('latin_transcription', _('Latin transcription'), 2),
|
||||
),
|
||||
'concatenation': lambda d: str(d.get('full_name', '')),
|
||||
'sample': {
|
||||
'full_name': '庄司',
|
||||
'latin_transcription': 'Shōji',
|
||||
'_scheme': 'full_transcription',
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
|
||||
settings_hierarkey = Hierarkey(attribute_name='settings')
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import timedelta
|
||||
from typing import List, Tuple
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Max
|
||||
from django.db.models import Max, Q
|
||||
from django.db.models.functions import Greatest
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
@@ -202,12 +202,20 @@ class AttendeeNameShredder(BaseDataShredder):
|
||||
def generate_files(self) -> List[Tuple[str, str, str]]:
|
||||
yield 'attendee-names.json', 'application/json', json.dumps({
|
||||
'{}-{}'.format(op.order.code, op.positionid): op.attendee_name
|
||||
for op in OrderPosition.objects.filter(order__event=self.event, attendee_name__isnull=False)
|
||||
for op in OrderPosition.objects.filter(
|
||||
order__event=self.event
|
||||
).filter(
|
||||
Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False))
|
||||
)
|
||||
}, indent=4)
|
||||
|
||||
@transaction.atomic
|
||||
def shred_data(self):
|
||||
OrderPosition.objects.filter(order__event=self.event, attendee_name__isnull=False).update(attendee_name=None)
|
||||
OrderPosition.objects.filter(
|
||||
order__event=self.event
|
||||
).filter(
|
||||
Q(Q(attendee_name_cached__isnull=False) | Q(attendee_name_parts__isnull=False))
|
||||
).update(attendee_name_cached=None, attendee_name_parts={'_shredded': True})
|
||||
|
||||
for le in self.event.logentry_set.filter(action_type="pretix.event.order.modified").exclude(data=""):
|
||||
d = le.parsed_data
|
||||
@@ -215,6 +223,10 @@ class AttendeeNameShredder(BaseDataShredder):
|
||||
for i, row in enumerate(d['data']):
|
||||
if 'attendee_name' in row:
|
||||
d['data'][i]['attendee_name'] = '█'
|
||||
if 'attendee_name_parts' in row:
|
||||
d['data'][i]['attendee_name_parts'] = {
|
||||
'_legacy': '█'
|
||||
}
|
||||
le.data = json.dumps(d)
|
||||
le.shredded = True
|
||||
le.save(update_fields=['data', 'shredded'])
|
||||
|
||||
@@ -80,8 +80,8 @@ class BaseQuestionsViewMixin:
|
||||
# This form was correctly filled, so we store the data as
|
||||
# answers to the questions / in the CartPosition object
|
||||
for k, v in form.cleaned_data.items():
|
||||
if k == 'attendee_name':
|
||||
form.pos.attendee_name = v if v != '' else None
|
||||
if k == 'attendee_name_parts':
|
||||
form.pos.attendee_name_parts = v if v else None
|
||||
form.pos.save()
|
||||
elif k == 'attendee_email':
|
||||
form.pos.attendee_email = v if v != '' else None
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.utils.timezone import get_current_timezone_name
|
||||
from django.utils.translation import (
|
||||
pgettext, pgettext_lazy, ugettext_lazy as _,
|
||||
)
|
||||
from django_countries import Countries
|
||||
from django_countries import Countries, countries
|
||||
from django_countries.fields import LazyTypedChoiceField
|
||||
from i18nfield.forms import (
|
||||
I18nForm, I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput,
|
||||
@@ -20,6 +20,7 @@ from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
|
||||
from pretix.base.models import Event, Organizer, TaxRule
|
||||
from pretix.base.models.event import EventMetaValue, SubEvent
|
||||
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.control.forms import (
|
||||
ExtFileField, MultipleLanguagesWidget, SingleLanguageWidget, SlugWidget,
|
||||
SplitDateTimeField, SplitDateTimePickerWidget,
|
||||
@@ -202,17 +203,22 @@ class EventMetaValueForm(forms.ModelForm):
|
||||
|
||||
|
||||
class EventUpdateForm(I18nModelForm):
|
||||
def clean_slug(self):
|
||||
return self.instance.slug
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.change_slug = kwargs.pop('change_slug', False)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['slug'].widget.attrs['readonly'] = 'readonly'
|
||||
if not self.change_slug:
|
||||
self.fields['slug'].widget.attrs['readonly'] = 'readonly'
|
||||
self.fields['location'].widget.attrs['rows'] = '3'
|
||||
self.fields['location'].widget.attrs['placeholder'] = _(
|
||||
'Sample Conference Center\nHeidelberg, Germany'
|
||||
)
|
||||
|
||||
def clean_slug(self):
|
||||
if self.change_slug:
|
||||
return self.cleaned_data['slug']
|
||||
return self.instance.slug
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
localized_fields = '__all__'
|
||||
@@ -317,7 +323,8 @@ class EventSettingsForm(SettingsForm):
|
||||
label=_("Automatic waiting list assignments"),
|
||||
help_text=_("If ticket capacity becomes free, automatically create a voucher and send it to the first person "
|
||||
"on the waiting list for that product. If this is not active, mails will not be send automatically "
|
||||
"but you can send them manually via the control panel."),
|
||||
"but you can send them manually via the control panel. If you disable the waiting list but keep "
|
||||
"this option enabled, tickets will still be sent out."),
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
)
|
||||
@@ -332,6 +339,12 @@ class EventSettingsForm(SettingsForm):
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_names_asked'}),
|
||||
)
|
||||
name_scheme = forms.ChoiceField(
|
||||
label=_("Name format"),
|
||||
help_text=_("This defines how pretix will ask for human names. Changing this after you already received "
|
||||
"orders might lead to unexpected behaviour when sorting or changing names."),
|
||||
required=True,
|
||||
)
|
||||
attendee_emails_asked = forms.BooleanField(
|
||||
label=_("Ask for email addresses per ticket"),
|
||||
help_text=_("Normally, pretix asks for one email address per order and the order confirmation will be sent "
|
||||
@@ -413,6 +426,13 @@ class EventSettingsForm(SettingsForm):
|
||||
'e.g. I hereby confirm that I have read and agree with the event organizer\'s terms of service '
|
||||
'and agree with them.'
|
||||
)
|
||||
self.fields['name_scheme'].choices = (
|
||||
(k, _('Ask for {fields}, display like {example}').format(
|
||||
fields=' + '.join(str(vv[1]) for vv in v['fields']),
|
||||
example=v['concatenation'](v['sample'])
|
||||
))
|
||||
for k, v in PERSON_NAME_SCHEMES.items()
|
||||
)
|
||||
|
||||
|
||||
class PaymentSettingsForm(SettingsForm):
|
||||
@@ -483,6 +503,7 @@ class ProviderForm(SettingsForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.settingspref = kwargs.pop('settingspref')
|
||||
self.provider = kwargs.pop('provider', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def prepare_fields(self):
|
||||
@@ -509,9 +530,15 @@ class ProviderForm(SettingsForm):
|
||||
val = cleaned_data.get(k)
|
||||
if v._required and not val:
|
||||
self.add_error(k, _('This field is required.'))
|
||||
if self.provider:
|
||||
cleaned_data = self.provider.settings_form_clean(cleaned_data)
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class InvoiceSettingsForm(SettingsForm):
|
||||
allcountries = list(countries)
|
||||
allcountries.insert(0, ('', _('Select country')))
|
||||
|
||||
invoice_address_asked = forms.BooleanField(
|
||||
label=_("Ask for invoice address"),
|
||||
required=False
|
||||
@@ -562,9 +589,10 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
invoice_generate = forms.ChoiceField(
|
||||
label=_("Generate invoices"),
|
||||
required=False,
|
||||
widget=forms.RadioSelect,
|
||||
choices=(
|
||||
('False', _('No')),
|
||||
('admin', _('Manually in admin panel')),
|
||||
('False', _('Do not generate invoices')),
|
||||
('admin', _('Only manually in admin panel')),
|
||||
('user', _('Automatically on user request')),
|
||||
('True', _('Automatically for all created orders')),
|
||||
('paid', _('Automatically on payment')),
|
||||
@@ -588,19 +616,46 @@ class InvoiceSettingsForm(SettingsForm):
|
||||
required=True,
|
||||
choices=[]
|
||||
)
|
||||
invoice_address_from_name = forms.CharField(
|
||||
label=_("Company name"),
|
||||
required=False,
|
||||
)
|
||||
invoice_address_from = forms.CharField(
|
||||
label=_("Address line"),
|
||||
widget=forms.Textarea(attrs={
|
||||
'rows': 5,
|
||||
'rows': 2,
|
||||
'placeholder': _(
|
||||
'Sample Event Company\n'
|
||||
'Albert Einstein Road 52\n'
|
||||
'12345 Samplecity'
|
||||
'Albert Einstein Road 52'
|
||||
)
|
||||
}),
|
||||
required=False,
|
||||
label=_("Your address"),
|
||||
help_text=_("Will be printed as the sender on invoices. Be sure to include relevant details required in "
|
||||
"your jurisdiction.")
|
||||
)
|
||||
invoice_address_from_zipcode = forms.CharField(
|
||||
widget=forms.TextInput(attrs={
|
||||
'placeholder': '12345'
|
||||
}),
|
||||
required=False,
|
||||
label=_("ZIP code"),
|
||||
)
|
||||
invoice_address_from_city = forms.CharField(
|
||||
widget=forms.TextInput(attrs={
|
||||
'placeholder': _('Random City')
|
||||
}),
|
||||
required=False,
|
||||
label=_("City"),
|
||||
)
|
||||
invoice_address_from_country = forms.ChoiceField(
|
||||
choices=allcountries,
|
||||
required=False,
|
||||
label=_("Country"),
|
||||
)
|
||||
invoice_address_from_tax_id = forms.CharField(
|
||||
required=False,
|
||||
label=_("Domestic tax ID"),
|
||||
)
|
||||
invoice_address_from_vat_id = forms.CharField(
|
||||
required=False,
|
||||
label=_("EU VAT ID"),
|
||||
)
|
||||
invoice_introductory_text = I18nFormField(
|
||||
widget=I18nTextarea,
|
||||
@@ -1183,7 +1238,13 @@ class QuickSetupForm(I18nForm):
|
||||
"bank statements to process the payments within pretix, or mark them as paid manually."),
|
||||
required=False
|
||||
)
|
||||
payment_banktransfer_bank_details = BankTransfer.form_field(required=False)
|
||||
btf = BankTransfer.form_fields()
|
||||
payment_banktransfer_bank_details_type = btf['bank_details_type']
|
||||
payment_banktransfer_bank_details_sepa_name = btf['bank_details_sepa_name']
|
||||
payment_banktransfer_bank_details_sepa_iban = btf['bank_details_sepa_iban']
|
||||
payment_banktransfer_bank_details_sepa_bic = btf['bank_details_sepa_bic']
|
||||
payment_banktransfer_bank_details_sepa_bank = btf['bank_details_sepa_bank']
|
||||
payment_banktransfer_bank_details = btf['bank_details']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.obj = kwargs.pop('event', None)
|
||||
@@ -1193,6 +1254,16 @@ class QuickSetupForm(I18nForm):
|
||||
if not self.obj.settings.payment_stripe_connect_client_id:
|
||||
del self.fields['payment_stripe__enabled']
|
||||
self.fields['payment_banktransfer_bank_details'].required = False
|
||||
for f in self.fields.values():
|
||||
if 'data-required-if' in f.widget.attrs:
|
||||
del f.widget.attrs['data-required-if']
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if cleaned_data.get('payment_banktransfer__enabled'):
|
||||
provider = BankTransfer(self.obj)
|
||||
cleaned_data = provider.settings_form_clean(cleaned_data)
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class QuickSetupProductForm(I18nForm):
|
||||
|
||||
@@ -129,7 +129,7 @@ class OrderFilterForm(FilterForm):
|
||||
|
||||
matching_positions = OrderPosition.objects.filter(
|
||||
Q(order=OuterRef('pk')) & Q(
|
||||
Q(attendee_name__icontains=u) | Q(attendee_email__icontains=u)
|
||||
Q(attendee_name_cached__icontains=u) | Q(attendee_email__icontains=u)
|
||||
| Q(secret__istartswith=u)
|
||||
)
|
||||
).values('id')
|
||||
@@ -137,7 +137,7 @@ class OrderFilterForm(FilterForm):
|
||||
qs = qs.annotate(has_pos=Exists(matching_positions)).filter(
|
||||
code
|
||||
| Q(email__icontains=u)
|
||||
| Q(invoice_address__name__icontains=u)
|
||||
| Q(invoice_address__name_cached__icontains=u)
|
||||
| Q(invoice_address__company__icontains=u)
|
||||
| Q(pk__in=matching_invoices)
|
||||
| Q(comment__icontains=u)
|
||||
@@ -568,9 +568,9 @@ class CheckInFilterForm(FilterForm):
|
||||
'item': ('item__name', 'variation__value', 'order__code'),
|
||||
'-item': ('-item__name', '-variation__value', '-order__code'),
|
||||
'name': {'_order': F('display_name').asc(nulls_first=True),
|
||||
'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')},
|
||||
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')},
|
||||
'-name': {'_order': F('display_name').desc(nulls_last=True),
|
||||
'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')},
|
||||
'display_name': Coalesce('attendee_name_cached', 'addon_to__attendee_name_cached')},
|
||||
}
|
||||
|
||||
user = forms.CharField(
|
||||
@@ -615,10 +615,10 @@ class CheckInFilterForm(FilterForm):
|
||||
Q(order__code__istartswith=u)
|
||||
| Q(secret__istartswith=u)
|
||||
| Q(order__email__icontains=u)
|
||||
| Q(attendee_name__icontains=u)
|
||||
| Q(attendee_name_cached__icontains=u)
|
||||
| Q(attendee_email__icontains=u)
|
||||
| Q(voucher__code__istartswith=u)
|
||||
| Q(order__invoice_address__name__icontains=u)
|
||||
| Q(order__invoice_address__name_cached__icontains=u)
|
||||
| Q(order__invoice_address__company__icontains=u)
|
||||
)
|
||||
|
||||
@@ -796,7 +796,10 @@ class VoucherFilterForm(FilterForm):
|
||||
|
||||
if fdata.get('tag'):
|
||||
s = fdata.get('tag').strip()
|
||||
qs = qs.filter(tag__icontains=s)
|
||||
if s == '<>':
|
||||
qs = qs.filter(Q(tag__isnull=True) | Q(tag=''))
|
||||
else:
|
||||
qs = qs.filter(tag__icontains=s)
|
||||
|
||||
if fdata.get('qm'):
|
||||
s = fdata.get('qm')
|
||||
|
||||
@@ -2,9 +2,12 @@ from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import RegexValidator
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
|
||||
from i18nfield.forms import I18nFormField, I18nTextarea
|
||||
|
||||
from pretix.api.models import WebHook
|
||||
from pretix.api.webhooks import get_all_webhook_events
|
||||
from pretix.base.forms import I18nModelForm, SettingsForm
|
||||
from pretix.base.models import Device, Organizer, Team
|
||||
from pretix.control.forms import ExtFileField, MultipleLanguagesWidget
|
||||
@@ -31,10 +34,34 @@ class OrganizerForm(I18nModelForm):
|
||||
return slug
|
||||
|
||||
|
||||
class OrganizerDeleteForm(forms.Form):
|
||||
error_messages = {
|
||||
'slug_wrong': _("The slug you entered was not correct."),
|
||||
}
|
||||
slug = forms.CharField(
|
||||
max_length=255,
|
||||
label=_("Event slug"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.organizer = kwargs.pop('organizer')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean_slug(self):
|
||||
slug = self.cleaned_data.get('slug')
|
||||
if slug != self.organizer.slug:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['slug_wrong'],
|
||||
code='slug_wrong',
|
||||
)
|
||||
return slug
|
||||
|
||||
|
||||
class OrganizerUpdateForm(OrganizerForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.domain = kwargs.pop('domain', False)
|
||||
self.change_slug = kwargs.pop('change_slug', False)
|
||||
kwargs.setdefault('initial', {})
|
||||
self.instance = kwargs['instance']
|
||||
if self.domain and self.instance:
|
||||
@@ -43,7 +70,8 @@ class OrganizerUpdateForm(OrganizerForm):
|
||||
kwargs['initial'].setdefault('domain', initial_domain.domainname)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['slug'].widget.attrs['readonly'] = 'readonly'
|
||||
if not self.change_slug:
|
||||
self.fields['slug'].widget.attrs['readonly'] = 'readonly'
|
||||
if self.domain:
|
||||
self.fields['domain'] = forms.CharField(
|
||||
max_length=255,
|
||||
@@ -53,6 +81,8 @@ class OrganizerUpdateForm(OrganizerForm):
|
||||
)
|
||||
|
||||
def clean_slug(self):
|
||||
if self.change_slug:
|
||||
return self.cleaned_data['slug']
|
||||
return self.instance.slug
|
||||
|
||||
def save(self, commit=True):
|
||||
@@ -195,3 +225,32 @@ class OrganizerDisplaySettingsForm(SettingsForm):
|
||||
self.fields['primary_font'].choices += [
|
||||
(a, a) for a in get_fonts()
|
||||
]
|
||||
|
||||
|
||||
class WebHookForm(forms.ModelForm):
|
||||
events = forms.MultipleChoiceField(
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
label=pgettext_lazy('webhooks', 'Event types')
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
organizer = kwargs.pop('organizer')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['limit_events'].queryset = organizer.events.all()
|
||||
self.fields['events'].choices = [
|
||||
(
|
||||
a.action_type,
|
||||
mark_safe('{} – <code>{}</code>'.format(a.verbose_name, a.action_type))
|
||||
) for a in get_all_webhook_events().values()
|
||||
]
|
||||
if self.instance:
|
||||
self.fields['events'].initial = list(self.instance.listeners.values_list('action_type', flat=True))
|
||||
|
||||
class Meta:
|
||||
model = WebHook
|
||||
fields = ['target_url', 'enabled', 'all_events', 'limit_events']
|
||||
widgets = {
|
||||
'limit_events': forms.CheckboxSelectMultiple(attrs={
|
||||
'data-inverse-dependency': '#id_all_events'
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -123,13 +123,13 @@ def _display_checkin(event, logentry):
|
||||
|
||||
if data.get('first'):
|
||||
if show_dt:
|
||||
return _('Position #{posid} has been scanned at {datetime} for list "{list}".').format(
|
||||
return _('Position #{posid} has been checked in at {datetime} for list "{list}".').format(
|
||||
posid=data.get('positionid'),
|
||||
datetime=dt_formatted,
|
||||
list=checkin_list
|
||||
)
|
||||
else:
|
||||
return _('Position #{posid} has been scanned for list "{list}".').format(
|
||||
return _('Position #{posid} has been checked in for list "{list}".').format(
|
||||
posid=data.get('positionid'),
|
||||
list=checkin_list
|
||||
)
|
||||
@@ -199,6 +199,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.order.payment.canceled': _('Payment {local_id} has been canceled.'),
|
||||
'pretix.event.order.payment.started': _('Payment {local_id} has been started.'),
|
||||
'pretix.event.order.payment.failed': _('Payment {local_id} has failed.'),
|
||||
'pretix.event.order.quotaexceeded': _('The order could not be marked as paid: {message}'),
|
||||
'pretix.event.order.refund.created': _('Refund {local_id} has been created.'),
|
||||
'pretix.event.order.refund.created.externally': _('Refund {local_id} has been created by an external entity.'),
|
||||
'pretix.event.order.refund.done': _('Refund {local_id} has been completed.'),
|
||||
@@ -214,10 +215,12 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.user.settings.notifications.enabled': _('Notifications have been enabled.'),
|
||||
'pretix.user.settings.notifications.disabled': _('Notifications have been disabled.'),
|
||||
'pretix.user.settings.notifications.changed': _('Your notification settings have been changed.'),
|
||||
'pretix.user.anonymized': _('This user has been anonymized.'),
|
||||
'pretix.user.oauth.authorized': _('The application "{application_name}" has been authorized to access your '
|
||||
'account.'),
|
||||
'pretix.control.auth.user.forgot_password.mail_sent': _('Password reset mail sent.'),
|
||||
'pretix.control.auth.user.forgot_password.recovered': _('The password has been reset.'),
|
||||
'pretix.organizer.deleted': _('The organizer "{name}" has been deleted.'),
|
||||
'pretix.voucher.added': _('The voucher has been created.'),
|
||||
'pretix.voucher.added.waitinglist': _('The voucher has been created and sent to a person on the waiting list.'),
|
||||
'pretix.voucher.changed': _('The voucher has been changed.'),
|
||||
@@ -318,6 +321,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
return _display_checkin(sender, logentry)
|
||||
|
||||
if logentry.action_type == 'pretix.control.views.checkin':
|
||||
# deprecated
|
||||
dt = dateutil.parser.parse(data.get('datetime'))
|
||||
tz = pytz.timezone(sender.settings.timezone)
|
||||
dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT")
|
||||
@@ -341,7 +345,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
list=checkin_list
|
||||
)
|
||||
|
||||
if logentry.action_type == 'pretix.control.views.checkin.reverted':
|
||||
if logentry.action_type in ('pretix.control.views.checkin.reverted', 'pretix.event.checkin.reverted'):
|
||||
if 'list' in data:
|
||||
try:
|
||||
checkin_list = sender.checkin_lists.get(pk=data.get('list')).name
|
||||
|
||||
@@ -9,6 +9,18 @@
|
||||
<link rel="stylesheet" type="text/x-scss" href="{% static "pretixcontrol/scss/auth.scss" %}"/>
|
||||
{% endcompress %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<link rel="shortcut icon" href="{% static "pretixbase/img/favicon.ico" %}">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{% static "pretixbase/img/icons/apple-touch-icon.png" %}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{% static "pretixbase/img/icons/favicon-32x32.png" %}">
|
||||
<link rel="icon" type="image/png" sizes="194x194" href="{% static "pretixbase/img/icons/favicon-194x194.png" %}">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="{% static "pretixbase/img/icons/android-chrome-192x192.png" %}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{% static "pretixbase/img/icons/favicon-16x16.png" %}">
|
||||
<link rel="manifest" href="{% url "presale:site.webmanifest" %}">
|
||||
<link rel="mask-icon" href="{% static "pretixbase/img/icons/safari-pinned-tab.svg" %}" color="#3b1c4a">
|
||||
<meta name="msapplication-TileColor" content="#3b1c4a">
|
||||
<meta name="msapplication-config" content="{% url "presale:browserconfig.xml" %}">
|
||||
<meta name="theme-color" content="#3b1c4a">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
@@ -52,8 +52,20 @@
|
||||
<script type="text/javascript" src="{% static "fileupload/jquery.fileupload.js" %}"></script>
|
||||
{% endcompress %}
|
||||
{{ html_head|safe }}
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="{% static "pretixbase/img/favicon.ico" %}">
|
||||
<link rel="shortcut icon" href="{% static "pretixbase/img/favicon.ico" %}">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{% static "pretixbase/img/icons/apple-touch-icon.png" %}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{% static "pretixbase/img/icons/favicon-32x32.png" %}">
|
||||
<link rel="icon" type="image/png" sizes="194x194" href="{% static "pretixbase/img/icons/favicon-194x194.png" %}">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="{% static "pretixbase/img/icons/android-chrome-192x192.png" %}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{% static "pretixbase/img/icons/favicon-16x16.png" %}">
|
||||
<link rel="manifest" href="{% url "presale:site.webmanifest" %}">
|
||||
<link rel="mask-icon" href="{% static "pretixbase/img/icons/safari-pinned-tab.svg" %}" color="#3b1c4a">
|
||||
<meta name="msapplication-TileColor" content="#3b1c4a">
|
||||
<meta name="msapplication-config" content="{% url "presale:browserconfig.xml" %}">
|
||||
<meta name="theme-color" content="#3b1c4a">
|
||||
|
||||
{% block custom_header %}{% endblock %}
|
||||
</head>
|
||||
<body data-datetimeformat="{{ js_datetime_format }}" data-timeformat="{{ js_time_format }}"
|
||||
@@ -97,8 +109,11 @@
|
||||
</div>
|
||||
<ul class="nav navbar-nav navbar-top-links navbar-left hidden-xs">
|
||||
<li class="dropdown">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown"><i class="fa fa-calendar"></i>
|
||||
<span class="event-name">{{ request.event }}</span>
|
||||
<a href="#" class="dropdown-toggle event-dropdown-toggle" data-toggle="dropdown"><i class="fa fa-calendar"></i>
|
||||
<div class="event-indicator">
|
||||
<span class="event-name">{{ request.event }}</span>
|
||||
<span class="event-daterange">{{ request.event.get_date_range_display }}</span>
|
||||
</div>
|
||||
<span class="caret"></span></a>
|
||||
<ul class="dropdown-menu event-dropdown" role="menu" data-event-typeahead
|
||||
data-source="{% url "control:events.typeahead" %}">
|
||||
@@ -196,7 +211,7 @@
|
||||
<div class="navbar-default sidebar" role="navigation">
|
||||
<div class="sidebar-nav navbar-events-collapse navbar-collapse hidden-sm hidden-md hidden-lg mobile-event-dropdown">
|
||||
<ul class="nav" data-event-typeahead data-source="{% url "control:events.typeahead" %}">
|
||||
<li>
|
||||
<li class="query-holder">
|
||||
<div class="form-box">
|
||||
<input type="text" class="form-control"
|
||||
placeholder="{% trans "Search for events" %}"
|
||||
|
||||
@@ -6,21 +6,36 @@
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Invoicing" %}</legend>
|
||||
{% bootstrap_field form.invoice_address_asked layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_required layout="control" %}
|
||||
{% bootstrap_field form.invoice_name_required layout="control" %}
|
||||
<legend>{% trans "Invoice settings" %}</legend>
|
||||
{% bootstrap_field form.invoice_generate layout="control" %}
|
||||
{% bootstrap_field form.invoice_email_attachment layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_company_required layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_vatid layout="control" %}
|
||||
{% bootstrap_field form.invoice_numbers_consecutive layout="control" %}
|
||||
{% bootstrap_field form.invoice_numbers_prefix layout="control" %}
|
||||
{% bootstrap_field form.invoice_renderer layout="control" %}
|
||||
{% bootstrap_field form.invoice_numbers_consecutive layout="control" %}
|
||||
{% bootstrap_field form.invoice_language layout="control" %}
|
||||
{% bootstrap_field form.invoice_include_free layout="control" %}
|
||||
{% bootstrap_field form.invoice_attendee_name layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Invoice address form" %}</legend>
|
||||
{% bootstrap_field form.invoice_address_asked layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_required layout="control" %}
|
||||
{% bootstrap_field form.invoice_name_required layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_company_required layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_vatid layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Your invoice details" %}</legend>
|
||||
{% bootstrap_field form.invoice_address_from_name layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_zipcode layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_city layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_country layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_tax_id layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_from_vat_id layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Invoice customization" %}</legend>
|
||||
{% bootstrap_field form.invoice_renderer layout="control" %}
|
||||
{% bootstrap_field form.invoice_introductory_text layout="control" %}
|
||||
{% bootstrap_field form.invoice_additional_text layout="control" %}
|
||||
{% bootstrap_field form.invoice_footer_text layout="control" %}
|
||||
|
||||
@@ -4,25 +4,27 @@
|
||||
{% load formset_tags %}
|
||||
{% block title %}{{ request.event.name }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="quick-setup-step">
|
||||
<div class="quick-icon">
|
||||
<span class="fa fa-fw fa-check-circle text-success"></span>
|
||||
</div>
|
||||
<div class="quick-content">
|
||||
{% if request.method == "GET" %}
|
||||
<div class="quick-setup-step">
|
||||
<div class="quick-icon">
|
||||
<span class="fa fa-fw fa-check-circle text-success"></span>
|
||||
</div>
|
||||
<div class="quick-content">
|
||||
|
||||
<h2>{% trans "Congratulations!" %}</h2>
|
||||
<p>
|
||||
<strong>{% trans "You just created an event!" %}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You can scroll down and create your first ticket products quickly, or you can use the navigation
|
||||
on the left to modify the settings of your event in much more detail.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<div class="clearfix"></div>
|
||||
<h2>{% trans "Congratulations!" %}</h2>
|
||||
<p>
|
||||
<strong>{% trans "You just created an event!" %}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You can scroll down and create your first ticket products quickly, or you can use the navigation
|
||||
on the left to modify the settings of your event in much more detail.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
@@ -157,6 +159,11 @@
|
||||
</p>
|
||||
{% bootstrap_field form.payment_banktransfer__enabled layout="control" label_class="sr-only" field_class="col-md-12" %}
|
||||
<div data-display-dependency="#id_payment_banktransfer__enabled">
|
||||
{% bootstrap_field form.payment_banktransfer_bank_details_type layout="control" %}
|
||||
{% bootstrap_field form.payment_banktransfer_bank_details_sepa_name layout="control" %}
|
||||
{% bootstrap_field form.payment_banktransfer_bank_details_sepa_iban layout="control" %}
|
||||
{% bootstrap_field form.payment_banktransfer_bank_details_sepa_bic layout="control" %}
|
||||
{% bootstrap_field form.payment_banktransfer_bank_details_sepa_bank layout="control" %}
|
||||
{% bootstrap_field form.payment_banktransfer_bank_details layout="control" %}
|
||||
</div>
|
||||
{% if form.payment_stripe__enabled %}
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
{% bootstrap_field sform.max_items_per_order layout="control" %}
|
||||
{% bootstrap_field sform.attendee_names_asked layout="control" %}
|
||||
{% bootstrap_field sform.attendee_names_required layout="control" %}
|
||||
{% bootstrap_field sform.name_scheme layout="control" %}
|
||||
{% bootstrap_field sform.order_email_asked_twice layout="control" %}
|
||||
{% bootstrap_field sform.attendee_emails_asked layout="control" %}
|
||||
{% bootstrap_field sform.attendee_emails_required layout="control" %}
|
||||
|
||||
@@ -157,7 +157,8 @@
|
||||
<form class="form-inline helper-display-inline" method="post"
|
||||
action="{% url "control:event.order.regeninvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code id=i.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default btn-xs">
|
||||
<button class="btn btn-default btn-xs" data-toggle="tooltip"
|
||||
title="{% trans 'Rebuild the invoice with updated data but the same invoice number.' %}">
|
||||
{% trans "Regenerate" %}
|
||||
</button>
|
||||
</form>
|
||||
@@ -165,7 +166,11 @@
|
||||
<form class="form-inline helper-display-inline" method="post"
|
||||
action="{% url "control:event.order.reissueinvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code id=i.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default btn-xs">
|
||||
<button class="btn btn-default btn-xs"
|
||||
{% if order.status != "r" and order.status != "c" %}
|
||||
data-toggle="tooltip"
|
||||
title="{% trans 'Generate a cancellation document for this invoice and create a new invoice with a new invoice number.' %}"
|
||||
{% endif %}>
|
||||
{% if order.status == "r" or order.status == "c" %}
|
||||
{% trans "Generate cancellation" %}
|
||||
{% else %}
|
||||
@@ -565,7 +570,7 @@
|
||||
{{ order.invoice_address.vat_id }}
|
||||
{% if order.invoice_address.vat_id_validated %}
|
||||
<span class="fa fa-check" data-toggle="tooltip" title="{% blocktrans trimmed %}Valid EU VAT ID{% endblocktrans %}"></span>
|
||||
{% else %}
|
||||
{% elif order.invoice_address.vat_id %}
|
||||
<form class="form-inline helper-display-inline" method="post"
|
||||
action="{% url "control:event.order.checkvatid" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
{% csrf_token %}
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
{% trans "Edit" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if request.user.is_staff and staff_session %}
|
||||
<a href="{% url "control:organizer.delete" organizer=organizer.slug %}"
|
||||
class="btn btn-danger hidden-print">
|
||||
<span class="fa fa-trash"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</h1>
|
||||
<ul class="nav nav-pills hidden-print">
|
||||
<li {% if "organizer" == url_name %}class="active"{% endif %}>
|
||||
@@ -40,6 +46,13 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if 'can_change_organizer_settings' in request.orgapermset %}
|
||||
<li {% if "organizer.webhook" in url_name %}class="active"{% endif %}>
|
||||
<a href="{% url "control:organizer.webhooks" organizer=organizer.slug %}">
|
||||
{% trans "Webhooks" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for nav in nav_organizer %}
|
||||
<li {% if nav.active %}class="active"{% endif %}>
|
||||
<a href="{{ nav.url }}">
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Delete organizer" %}</h1>
|
||||
{% if request.organizer.allow_delete %}
|
||||
{% bootstrap_form_errors form layout="inline" %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
This operation will destroy this organizer including all events, configuration, products, quotas,
|
||||
questions, vouchers, lists, etc.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p><strong>
|
||||
{% blocktrans trimmed %}
|
||||
This operation is irreversible and there is no way to bring your data back.
|
||||
{% endblocktrans %}
|
||||
</strong></p>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{% blocktrans trimmed with slug=request.organizer.slug %}
|
||||
To confirm you really want this, please type out the organizer's short name ("{{ slug }}") here:
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% bootstrap_field form.slug layout="inline" %}
|
||||
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>
|
||||
{% trans "This organizer account can not be deleted as it already contains orders, invoices, or devices." %}
|
||||
</p>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
pretix does not allow deleting orders once they have been placed in order to be audit-proof and
|
||||
trustable by financial authorities.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inner %}
|
||||
{% if webhook %}
|
||||
<legend>{% trans "Modify webhook" %}</legend>
|
||||
{% else %}
|
||||
<legend>{% trans "Create a new webhook" %}</legend>
|
||||
{% endif %}
|
||||
<form class="form-horizontal" action="" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_field form.target_url layout="control" %}
|
||||
{% bootstrap_field form.enabled layout="control" %}
|
||||
{% bootstrap_field form.events layout="control" %}
|
||||
{% bootstrap_field form.all_events layout="control" %}
|
||||
{% bootstrap_field form.limit_events layout="control" %}
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,67 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inner %}
|
||||
<legend>{% blocktrans with url=webhook.target_url %}Logs for webhook {{ url }}{% endblocktrans %}</legend>
|
||||
<p>
|
||||
{% trans "This page shows all calls to your webhook in the past 30 days." %}
|
||||
</p>
|
||||
{% for c in calls %}
|
||||
<details class="panel panel-default">
|
||||
<summary class="panel-heading">
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-sm-12 col-xs-12">
|
||||
{% if c.is_retry %}
|
||||
<span class="fa fa-repeat fa-fw" data-toggle="tooltip" title="{% trans "This webhook was retried since it previously failed." %}"></span>
|
||||
{% else %}
|
||||
<span class="fa fa-clock-o fa-fw"></span>
|
||||
{% endif %}
|
||||
{{ c.datetime|date:"SHORT_DATETIME_FORMAT" }}
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12 col-xs-12">
|
||||
<span class="fa fa-tag fa-fw"></span>
|
||||
{{ c.action_type }}
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-2 col-xs-4">
|
||||
<span class="fa fa-hourglass fa-fw"></span>
|
||||
{{ c.execution_time|floatformat:2 }}s
|
||||
</div>
|
||||
<div class="col-md-2 col-xs-8 text-right">
|
||||
{% if c.success %}
|
||||
<span class="label label-success">
|
||||
<span class="fa fa-check-circle fa-fw"></span>
|
||||
{{ c.return_code }}
|
||||
</span>
|
||||
{% else %}
|
||||
{% if c.return_code %}
|
||||
<span class="label label-danger">
|
||||
<span class="fa fa-warning fa-fw"></span>
|
||||
{{ c.return_code }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="label label-danger">
|
||||
<span class="fa fa-warning fa-fw"></span>
|
||||
{% trans "Failed" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
<div id="{{ c.pk }}">
|
||||
|
||||
<div class="panel-body">
|
||||
<strong>{% trans "Request URL" %}</strong>
|
||||
<pre><code>POST {{ c.target_url }}</code></pre>
|
||||
<strong>{% trans "Request POST body" %}</strong>
|
||||
<pre><code>{{ c.payload }}</code></pre>
|
||||
<strong>{% trans "Response body" %}</strong>
|
||||
<pre><code>{{ c.response_body }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% empty %}
|
||||
<div class="alert-info">{% trans "This webhook did not receive any events in the last 30 days." %}</div>
|
||||
{% endfor %}
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends "pretixcontrol/organizers/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block inner %}
|
||||
<legend>
|
||||
{% trans "Webhooks" %}
|
||||
</legend>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
This menu allows you to create webhooks to connect pretix to other online services.
|
||||
{% endblocktrans %}
|
||||
<a href="https://docs.pretix.eu/en/latest/api/webhooks.html" target="_blank">
|
||||
<i class="fa fa-book"></i> {% trans "Read documentation" %}
|
||||
</a>
|
||||
</p>
|
||||
{% if webhooks|length == 0 %}
|
||||
<div class="empty-collection">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You haven't created any webhooks yet.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<a href="{% url "control:organizer.webhook.add" organizer=request.organizer.slug %}"
|
||||
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create webhook" %}</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>
|
||||
<a href="{% url "control:organizer.webhook.add" organizer=request.organizer.slug %}"
|
||||
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create webhook" %}</a>
|
||||
</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Target URL" %}</th>
|
||||
<th>{% trans "Events" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for w in webhooks %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if not w.enabled %}<del>{% endif %}
|
||||
{{ w.target_url }}
|
||||
{% if not w.enabled %}</del>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if w.all_events %}
|
||||
{% trans "All" %}
|
||||
{% else %}
|
||||
<ul>
|
||||
{% for e in w.limit_events.all %}
|
||||
<li>
|
||||
<a href="{% url "control:event.index" organizer=request.organizer.slug event=e.slug %}">
|
||||
{{ e }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<a href="{% url "control:organizer.webhook.edit" organizer=request.organizer.slug webhook=w.id %}"
|
||||
class="btn btn-default btn-sm" data-toggle="tooltip" title="{% trans "Edit" %}">
|
||||
<i class="fa fa-edit"></i>
|
||||
</a>
|
||||
<a href="{% url "control:organizer.webhook.logs" organizer=request.organizer.slug webhook=w.id %}"
|
||||
class="btn btn-default btn-sm" data-toggle="tooltip" title="{% trans "Logs" %}">
|
||||
<i class="fa fa-list"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "pretixcontrol/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Anonymize user" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Anonymize user" %} {{ user.email }}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Disable and anonymize user" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -13,6 +13,7 @@
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default">{% trans "Impersonate user" %}</button>
|
||||
</form>
|
||||
<a href="{% url "control:users.anonymize" id=user.pk %}" class="btn btn-default">{% trans "Anonymize" %}</a>
|
||||
</p>
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-xs-12">
|
||||
|
||||
@@ -27,10 +27,17 @@
|
||||
{% for t in tags %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong><a
|
||||
href="{% url "control:event.vouchers" organizer=request.event.organizer.slug event=request.event.slug %}?tag={{ t.tag|urlencode }}">
|
||||
{{ t.tag }}
|
||||
</a></strong> <small>({{ t.redeemed }} / {{ t.total }})</small>
|
||||
<strong>
|
||||
{% if t.tag %}
|
||||
<a href="{% url "control:event.vouchers" organizer=request.event.organizer.slug event=request.event.slug %}?tag={{ t.tag|urlencode }}">
|
||||
{{ t.tag }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url "control:event.vouchers" organizer=request.event.organizer.slug event=request.event.slug %}?tag={{ '<>'|urlencode }}">
|
||||
{% trans "Empty tag" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</strong> <small>({{ t.redeemed }} / {{ t.total }})</small>
|
||||
</td>
|
||||
<td>
|
||||
<div class="progress">
|
||||
|
||||
@@ -31,6 +31,7 @@ urlpatterns = [
|
||||
url(r'^users/(?P<id>\d+)/$', users.UserEditView.as_view(), name='users.edit'),
|
||||
url(r'^users/(?P<id>\d+)/reset$', users.UserResetView.as_view(), name='users.reset'),
|
||||
url(r'^users/(?P<id>\d+)/impersonate', users.UserImpersonateView.as_view(), name='users.impersonate'),
|
||||
url(r'^users/(?P<id>\d+)/anonymize', users.UserAnonymizeView.as_view(), name='users.anonymize'),
|
||||
url(r'^settings/?$', user.UserSettings.as_view(), name='user.settings'),
|
||||
url(r'^settings/history/$', user.UserHistoryView.as_view(), name='user.settings.history'),
|
||||
url(r'^settings/notifications/$', user.UserNotificationsEditView.as_view(), name='user.settings.notifications'),
|
||||
@@ -67,8 +68,16 @@ urlpatterns = [
|
||||
url(r'^organizers/select2$', typeahead.organizer_select2, name='organizers.select2'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/$', organizer.OrganizerDetail.as_view(), name='organizer'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/edit$', organizer.OrganizerUpdate.as_view(), name='organizer.edit'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/delete$', organizer.OrganizerDelete.as_view(), name='organizer.delete'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/settings/display$', organizer.OrganizerDisplaySettings.as_view(),
|
||||
name='organizer.display'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/webhooks$', organizer.WebHookListView.as_view(), name='organizer.webhooks'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/webhook/add$', organizer.WebHookCreateView.as_view(),
|
||||
name='organizer.webhook.add'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/webhook/(?P<webhook>[^/]+)/edit$', organizer.WebHookUpdateView.as_view(),
|
||||
name='organizer.webhook.edit'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/webhook/(?P<webhook>[^/]+)/logs$', organizer.WebHookLogsView.as_view(),
|
||||
name='organizer.webhook.logs'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/devices$', organizer.DeviceListView.as_view(), name='organizer.devices'),
|
||||
url(r'^organizer/(?P<organizer>[^/]+)/device/add$', organizer.DeviceCreateView.as_view(),
|
||||
name='organizer.device.add'),
|
||||
|
||||
@@ -94,10 +94,11 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, ListView):
|
||||
for op in positions:
|
||||
if op.order.status == Order.STATUS_PAID or (self.list.include_pending and op.order.status == Order.STATUS_PENDING):
|
||||
Checkin.objects.filter(position=op, list=self.list).delete()
|
||||
op.order.log_action('pretix.control.views.checkin.reverted', data={
|
||||
op.order.log_action('pretix.event.checkin.reverted', data={
|
||||
'position': op.id,
|
||||
'positionid': op.positionid,
|
||||
'list': self.list.pk
|
||||
'list': self.list.pk,
|
||||
'web': True
|
||||
}, user=request.user)
|
||||
|
||||
messages.success(request, _('The selected check-ins have been reverted.'))
|
||||
@@ -108,12 +109,14 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, ListView):
|
||||
ci, created = Checkin.objects.get_or_create(position=op, list=self.list, defaults={
|
||||
'datetime': now(),
|
||||
})
|
||||
op.order.log_action('pretix.control.views.checkin', data={
|
||||
op.order.log_action('pretix.event.checkin', data={
|
||||
'position': op.id,
|
||||
'positionid': op.positionid,
|
||||
'first': created,
|
||||
'forced': False,
|
||||
'datetime': now(),
|
||||
'list': self.list.pk
|
||||
'list': self.list.pk,
|
||||
'web': True
|
||||
}, user=request.user)
|
||||
|
||||
messages.success(request, _('The selected tickets have been marked as checked in.'))
|
||||
|
||||
@@ -153,6 +153,12 @@ class EventUpdate(EventSettingsViewMixin, EventPermissionRequiredMixin, MetaData
|
||||
'event': self.object.slug,
|
||||
})
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
if self.request.user.has_active_staff_session(self.request.session.session_key):
|
||||
kwargs['change_slug'] = True
|
||||
return kwargs
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
form = self.get_form()
|
||||
if form.is_valid() and self.sform.is_valid() and all([f.is_valid() for f in self.meta_forms]):
|
||||
@@ -264,7 +270,8 @@ class PaymentProviderSettings(EventSettingsViewMixin, EventPermissionRequiredMix
|
||||
form = ProviderForm(
|
||||
obj=self.request.event,
|
||||
settingspref=self.provider.settings.get_prefix(),
|
||||
data=(self.request.POST if self.request.method == 'POST' else None)
|
||||
data=(self.request.POST if self.request.method == 'POST' else None),
|
||||
provider=self.provider
|
||||
)
|
||||
form.fields = OrderedDict(
|
||||
[
|
||||
@@ -637,7 +644,7 @@ class MailSettingsRendererPreview(MailSettingsPreview):
|
||||
expires=now(), code="PREVIEW", total=119)
|
||||
item = request.event.items.create(name=ugettext("Sample product"), default_price=42.23,
|
||||
description=ugettext("Sample product description"))
|
||||
order.positions.create(item=item, attendee_name=ugettext("John Doe"), price=item.default_price)
|
||||
order.positions.create(item=item, attendee_name_parts={'full_name': ugettext("John Doe")}, price=item.default_price)
|
||||
v = renderers[request.GET.get('renderer')].render(
|
||||
v,
|
||||
str(request.event.settings.mail_text_signature),
|
||||
@@ -1187,6 +1194,7 @@ class QuickSetupView(FormView):
|
||||
if form.is_valid() and self.formset.is_valid():
|
||||
return self.form_valid(form)
|
||||
else:
|
||||
messages.error(self.request, _('We could not save your changes. See below for details.'))
|
||||
return self.form_invalid(form)
|
||||
|
||||
@transaction.atomic
|
||||
@@ -1218,8 +1226,12 @@ class QuickSetupView(FormView):
|
||||
data={'plugin': 'pretix.plugins.banktransfer'})
|
||||
plugins_active.append('pretix.plugins.banktransfer')
|
||||
self.request.event.settings.payment_banktransfer__enabled = True
|
||||
self.request.event.settings.payment_banktransfer_bank_details = form.cleaned_data[
|
||||
'payment_banktransfer_bank_details']
|
||||
for f in ('bank_details', 'bank_details_type', 'bank_details_sepa_name', 'bank_details_sepa_iban',
|
||||
'bank_details_sepa_bic', 'bank_details_sepa_bank'):
|
||||
self.request.event.settings.set(
|
||||
'payment_banktransfer_%s' % f,
|
||||
form.cleaned_data['payment_banktransfer_%s' % f]
|
||||
)
|
||||
|
||||
if form.cleaned_data.get('payment_stripe__enabled', None):
|
||||
if 'pretix.plugins.stripe' not in plugins_active:
|
||||
|
||||
@@ -173,11 +173,12 @@ class OrderDetail(OrderView):
|
||||
p.additional_fields = []
|
||||
data = p.meta_info_data
|
||||
for r, response in sorted(responses, key=lambda r: str(r[0])):
|
||||
for key, value in response.items():
|
||||
p.additional_fields.append({
|
||||
'answer': data.get('question_form_data', {}).get(key),
|
||||
'question': value.label
|
||||
})
|
||||
if response:
|
||||
for key, value in response.items():
|
||||
p.additional_fields.append({
|
||||
'answer': data.get('question_form_data', {}).get(key),
|
||||
'question': value.label
|
||||
})
|
||||
|
||||
p.has_questions = (
|
||||
p.additional_fields or
|
||||
@@ -218,7 +219,7 @@ class OrderComment(OrderView):
|
||||
self.order.log_action('pretix.event.order.checkin_attention', user=self.request.user, data={
|
||||
'new_value': form.cleaned_data.get('checkin_attention')
|
||||
})
|
||||
self.order.save()
|
||||
self.order.save(update_fields=['checkin_attention', 'comment'])
|
||||
messages.success(self.request, _('The comment has been updated.'))
|
||||
else:
|
||||
messages.error(self.request, _('Could not update the comment.'))
|
||||
@@ -345,7 +346,7 @@ class OrderRefundProcess(OrderView):
|
||||
self.order.event.subevents.filter(
|
||||
id__in=self.order.positions.values_list('subevent_id', flat=True))
|
||||
)
|
||||
self.order.save()
|
||||
self.order.save(update_fields=['status', 'expires'])
|
||||
|
||||
messages.success(self.request, _('The refund has been processed.'))
|
||||
else:
|
||||
@@ -507,7 +508,7 @@ class OrderRefundView(OrderView):
|
||||
manual_value = formats.sanitize_separators(manual_value)
|
||||
try:
|
||||
manual_value = Decimal(manual_value)
|
||||
except (DecimalException, TypeError) as e:
|
||||
except (DecimalException, TypeError):
|
||||
messages.error(self.request, _('You entered an invalid number.'))
|
||||
is_valid = False
|
||||
else:
|
||||
@@ -530,7 +531,7 @@ class OrderRefundView(OrderView):
|
||||
offsetting_value = formats.sanitize_separators(offsetting_value)
|
||||
try:
|
||||
offsetting_value = Decimal(offsetting_value)
|
||||
except (DecimalException, TypeError) as e:
|
||||
except (DecimalException, TypeError):
|
||||
messages.error(self.request, _('You entered an invalid number.'))
|
||||
is_valid = False
|
||||
else:
|
||||
@@ -561,7 +562,7 @@ class OrderRefundView(OrderView):
|
||||
value = formats.sanitize_separators(value)
|
||||
try:
|
||||
value = Decimal(value)
|
||||
except (DecimalException, TypeError) as e:
|
||||
except (DecimalException, TypeError):
|
||||
messages.error(self.request, _('You entered an invalid number.'))
|
||||
is_valid = False
|
||||
else:
|
||||
@@ -630,7 +631,7 @@ class OrderRefundView(OrderView):
|
||||
self.order.event.subevents.filter(
|
||||
id__in=self.order.positions.values_list('subevent_id', flat=True))
|
||||
)
|
||||
self.order.save()
|
||||
self.order.save(update_fields=['status', 'expires'])
|
||||
|
||||
return redirect(self.get_order_url())
|
||||
else:
|
||||
@@ -688,8 +689,22 @@ class OrderTransition(OrderView):
|
||||
p.confirm(user=self.request.user, count_waitinglist=False,
|
||||
force=self.mark_paid_form.cleaned_data.get('force', False))
|
||||
except Quota.QuotaExceededException as e:
|
||||
p.state = OrderPayment.PAYMENT_STATE_FAILED
|
||||
p.save()
|
||||
self.order.log_action('pretix.event.order.payment.failed', {
|
||||
'local_id': p.local_id,
|
||||
'provider': p.provider,
|
||||
'message': str(e)
|
||||
})
|
||||
messages.error(self.request, str(e))
|
||||
except PaymentException as e:
|
||||
p.state = OrderPayment.PAYMENT_STATE_FAILED
|
||||
p.save()
|
||||
self.order.log_action('pretix.event.order.payment.failed', {
|
||||
'local_id': p.local_id,
|
||||
'provider': p.provider,
|
||||
'message': str(e)
|
||||
})
|
||||
messages.error(self.request, str(e))
|
||||
except SendMailException:
|
||||
messages.warning(self.request, _('The order has been marked as paid, but we were unable to send a '
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.contrib import messages
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.files import File
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.db.models import Count, ProtectedError
|
||||
from django.forms import inlineformset_factory
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
@@ -17,18 +17,23 @@ from django.views.generic import (
|
||||
CreateView, DeleteView, DetailView, FormView, ListView, UpdateView,
|
||||
)
|
||||
|
||||
from pretix.api.models import WebHook
|
||||
from pretix.base.models import Device, Organizer, Team, TeamInvite, User
|
||||
from pretix.base.models.event import EventMetaProperty
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
from pretix.control.forms.filter import OrganizerFilterForm
|
||||
from pretix.control.forms.organizer import (
|
||||
DeviceForm, EventMetaPropertyForm, OrganizerDisplaySettingsForm,
|
||||
OrganizerForm, OrganizerSettingsForm, OrganizerUpdateForm, TeamForm,
|
||||
DeviceForm, EventMetaPropertyForm, OrganizerDeleteForm,
|
||||
OrganizerDisplaySettingsForm, OrganizerForm, OrganizerSettingsForm,
|
||||
OrganizerUpdateForm, TeamForm, WebHookForm,
|
||||
)
|
||||
from pretix.control.permissions import (
|
||||
AdministratorPermissionRequiredMixin, OrganizerPermissionRequiredMixin,
|
||||
)
|
||||
from pretix.control.permissions import OrganizerPermissionRequiredMixin
|
||||
from pretix.control.signals import nav_organizer
|
||||
from pretix.control.views import PaginationMixin
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
from pretix.presale.style import regenerate_organizer_css
|
||||
|
||||
@@ -168,6 +173,47 @@ class OrganizerDisplaySettings(OrganizerSettingsFormView):
|
||||
return self.get(request)
|
||||
|
||||
|
||||
class OrganizerDelete(AdministratorPermissionRequiredMixin, FormView):
|
||||
model = Organizer
|
||||
template_name = 'pretixcontrol/organizers/delete.html'
|
||||
context_object_name = 'organizer'
|
||||
form_class = OrganizerDeleteForm
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if not self.request.organizer.allow_delete():
|
||||
messages.error(self.request, _('This organizer can not be deleted.'))
|
||||
return self.get(self.request, *self.args, **self.kwargs)
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs['organizer'] = self.request.organizer
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
self.request.user.log_action(
|
||||
'pretix.organizer.deleted', user=self.request.user,
|
||||
data={
|
||||
'organizer_id': self.request.organizer.pk,
|
||||
'name': str(self.request.organizer.name),
|
||||
'logentries': list(self.request.organizer.all_logentries().values_list('pk', flat=True))
|
||||
}
|
||||
)
|
||||
self.request.organizer.delete_sub_objects()
|
||||
self.request.organizer.delete()
|
||||
messages.success(self.request, _('The organizer has been deleted.'))
|
||||
return redirect(self.get_success_url())
|
||||
except ProtectedError:
|
||||
messages.error(self.request, _('The organizer could not be deleted as some constraints (e.g. data created by '
|
||||
'plug-ins) do not allow it.'))
|
||||
return self.get(self.request, *self.args, **self.kwargs)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('control:index')
|
||||
|
||||
|
||||
class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
|
||||
model = Organizer
|
||||
form_class = OrganizerUpdateForm
|
||||
@@ -225,6 +271,7 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
|
||||
kwargs = super().get_form_kwargs()
|
||||
if self.request.user.has_active_staff_session(self.request.session.session_key):
|
||||
kwargs['domain'] = True
|
||||
kwargs['change_slug'] = True
|
||||
return kwargs
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
@@ -716,3 +763,110 @@ class DeviceRevokeView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
|
||||
return redirect(reverse('control:organizer.devices', kwargs={
|
||||
'organizer': self.request.organizer.slug,
|
||||
}))
|
||||
|
||||
|
||||
class WebHookListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
|
||||
model = WebHook
|
||||
template_name = 'pretixcontrol/organizers/webhooks.html'
|
||||
permission = 'can_change_organizer_settings'
|
||||
context_object_name = 'webhooks'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.organizer.webhooks.prefetch_related('limit_events')
|
||||
|
||||
|
||||
class WebHookCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView):
|
||||
model = WebHook
|
||||
template_name = 'pretixcontrol/organizers/webhook_edit.html'
|
||||
permission = 'can_change_organizer_settings'
|
||||
form_class = WebHookForm
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs['organizer'] = self.request.organizer
|
||||
return kwargs
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('control:organizer.webhooks', kwargs={
|
||||
'organizer': self.request.organizer.slug,
|
||||
})
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.organizer = self.request.organizer
|
||||
ret = super().form_valid(form)
|
||||
self.request.organizer.log_action('pretix.webhook.created', user=self.request.user, data=merge_dicts({
|
||||
k: form.cleaned_data[k] if k != 'limit_events' else [e.id for e in getattr(self.object, k).all()]
|
||||
for k in form.changed_data
|
||||
}, {'id': form.instance.pk}))
|
||||
new_listeners = set(form.cleaned_data['events'])
|
||||
for l in new_listeners:
|
||||
self.object.listeners.create(action_type=l)
|
||||
return ret
|
||||
|
||||
def form_invalid(self, form):
|
||||
messages.error(self.request, _('Your changes could not be saved.'))
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class WebHookUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView):
|
||||
model = WebHook
|
||||
template_name = 'pretixcontrol/organizers/webhook_edit.html'
|
||||
permission = 'can_change_organizer_settings'
|
||||
context_object_name = 'webhook'
|
||||
form_class = WebHookForm
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs['organizer'] = self.request.organizer
|
||||
return kwargs
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
return get_object_or_404(WebHook, organizer=self.request.organizer, pk=self.kwargs.get('webhook'))
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('control:organizer.webhooks', kwargs={
|
||||
'organizer': self.request.organizer.slug,
|
||||
})
|
||||
|
||||
def form_valid(self, form):
|
||||
if form.has_changed():
|
||||
self.request.organizer.log_action('pretix.webhook.changed', user=self.request.user, data=merge_dicts({
|
||||
k: form.cleaned_data[k] if k != 'limit_events' else [e.id for e in getattr(self.object, k).all()]
|
||||
for k in form.changed_data
|
||||
}, {'id': form.instance.pk}))
|
||||
|
||||
current_listeners = set(self.object.listeners.values_list('action_type', flat=True))
|
||||
new_listeners = set(form.cleaned_data['events'])
|
||||
for l in current_listeners - new_listeners:
|
||||
self.object.listeners.filter(action_type=l).delete()
|
||||
for l in new_listeners - current_listeners:
|
||||
self.object.listeners.create(action_type=l)
|
||||
|
||||
messages.success(self.request, _('Your changes have been saved.'))
|
||||
return super().form_valid(form)
|
||||
|
||||
def form_invalid(self, form):
|
||||
messages.error(self.request, _('Your changes could not be saved.'))
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class WebHookLogsView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
|
||||
model = WebHook
|
||||
template_name = 'pretixcontrol/organizers/webhook_logs.html'
|
||||
permission = 'can_change_organizer_settings'
|
||||
context_object_name = 'calls'
|
||||
paginate_by = 50
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['webhook'] = self.webhook
|
||||
return ctx
|
||||
|
||||
@cached_property
|
||||
def webhook(self):
|
||||
return get_object_or_404(
|
||||
WebHook, organizer=self.request.organizer, pk=self.kwargs.get('webhook')
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return self.webhook.calls.order_by('-datetime')
|
||||
|
||||
@@ -18,6 +18,7 @@ from django.views.generic import TemplateView
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import CachedFile, InvoiceAddress, OrderPosition
|
||||
from pretix.base.pdf import get_variables
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
from pretix.helpers.database import rolledback_transaction
|
||||
from pretix.presale.style import get_fonts
|
||||
@@ -65,24 +66,26 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
|
||||
locale=self.request.event.settings.locale,
|
||||
expires=now(), code="PREVIEW1234", total=119)
|
||||
|
||||
p = order.positions.create(item=item, attendee_name=_("John Doe"), price=item.default_price)
|
||||
order.positions.create(item=item2, attendee_name=_("John Doe"), price=item.default_price, addon_to=p)
|
||||
order.positions.create(item=item2, attendee_name=_("John Doe"), price=item.default_price, addon_to=p)
|
||||
scheme = PERSON_NAME_SCHEMES[self.request.event.settings.name_scheme]
|
||||
sample = {k: str(v) for k, v in scheme['sample'].items()}
|
||||
p = order.positions.create(item=item, attendee_name_parts=sample, price=item.default_price)
|
||||
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=p)
|
||||
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=p)
|
||||
|
||||
InvoiceAddress.objects.create(order=order, name=_("John Doe"), company=_("Sample company"))
|
||||
InvoiceAddress.objects.create(order=order, name_parts=sample, company=_("Sample company"))
|
||||
return p
|
||||
|
||||
def generate(self, p: OrderPosition, override_layout=None, override_background=None):
|
||||
raise NotImplemented
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_layout_settings_key(self):
|
||||
raise NotImplemented
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_background_settings_key(self):
|
||||
raise NotImplemented
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_default_background(self):
|
||||
raise NotImplemented
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_current_background(self):
|
||||
return (
|
||||
|
||||
@@ -36,7 +36,8 @@ class OrderSearch(PaginationMixin, ListView):
|
||||
qs = self.filter_form.filter_qs(qs)
|
||||
|
||||
return qs.only(
|
||||
'id', 'invoice_address__name', 'code', 'event', 'email', 'datetime', 'total', 'status'
|
||||
'id', 'invoice_address__name_cached', 'invoice_address__name_parts', 'code', 'event', 'email',
|
||||
'datetime', 'total', 'status'
|
||||
).prefetch_related(
|
||||
'event', 'event__organizer'
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
@@ -6,7 +8,7 @@ from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.generic import ListView
|
||||
from django.views.generic import ListView, TemplateView
|
||||
from hijack.helpers import login_user, release_hijack
|
||||
|
||||
from pretix.base.models import User
|
||||
@@ -99,6 +101,36 @@ class UserResetView(AdministratorPermissionRequiredMixin, RecentAuthenticationRe
|
||||
return reverse('control:users.edit', kwargs=self.kwargs)
|
||||
|
||||
|
||||
class UserAnonymizeView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, TemplateView):
|
||||
template_name = "pretixcontrol/users/anonymize.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['user'] = get_object_or_404(User, pk=self.kwargs.get("id"))
|
||||
return ctx
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = get_object_or_404(User, pk=self.kwargs.get("id"))
|
||||
self.object.log_action('pretix.user.anonymized',
|
||||
user=request.user)
|
||||
self.object.email = "{}@disabled.pretix.eu".format(self.object.pk)
|
||||
self.object.fullname = ""
|
||||
self.object.is_active = False
|
||||
self.object.notifications_send = False
|
||||
self.object.save()
|
||||
for le in self.object.all_logentries.filter(action_type="pretix.user.settings.changed"):
|
||||
d = le.parsed_data
|
||||
if 'email' in d:
|
||||
d['email'] = '█'
|
||||
if 'fullname' in d:
|
||||
d['fullname'] = '█'
|
||||
le.data = json.dumps(d)
|
||||
le.shredded = True
|
||||
le.save(update_fields=['data', 'shredded'])
|
||||
|
||||
return redirect(reverse('control:users.edit', kwargs=self.kwargs))
|
||||
|
||||
|
||||
class UserImpersonateView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, View):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-10-03 10:25+0000\n"
|
||||
"POT-Creation-Date: 2018-11-08 15:40+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: Automatically generated\n"
|
||||
"Language-Team: none\n"
|
||||
@@ -51,8 +51,6 @@ msgid "Total"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:28
|
||||
#: pretix/static/pretixbase/js/asynctask.js:42
|
||||
#: pretix/static/pretixbase/js/asynctask.js:94
|
||||
msgid ""
|
||||
"Your request has been queued on the server and will now be processed. If "
|
||||
"this takes longer than two minutes, please contact us or go back in your "
|
||||
@@ -60,50 +58,58 @@ msgid ""
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:41
|
||||
#: pretix/static/pretixbase/js/asynctask.js:62
|
||||
#: pretix/static/pretixbase/js/asynctask.js:116
|
||||
#: pretix/static/pretixbase/js/asynctask.js:66
|
||||
#: pretix/static/pretixbase/js/asynctask.js:124
|
||||
#: pretix/static/pretixcontrol/js/ui/mail.js:23
|
||||
msgid "An error of type {code} occurred."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:54
|
||||
#: pretix/static/pretixbase/js/asynctask.js:137
|
||||
#: pretix/static/pretixbase/js/asynctask.js:148
|
||||
msgid "We are processing your request …"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixbase/js/asyncdownload.js:55
|
||||
#: pretix/static/pretixbase/js/asynctask.js:138
|
||||
#: pretix/static/pretixbase/js/asynctask.js:156
|
||||
msgid ""
|
||||
"We are currently sending your request to the server. If this takes longer "
|
||||
"than one minute, please check your internet connection and then reload this "
|
||||
"page and try again."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:38
|
||||
#: pretix/static/pretixbase/js/asynctask.js:90
|
||||
#: pretix/static/pretixbase/js/asynctask.js:39
|
||||
#: pretix/static/pretixbase/js/asynctask.js:95
|
||||
msgid ""
|
||||
"Your request has been queued on the server and will now be processed. "
|
||||
"Depending on the size of your event, this might take up to a few minutes."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:65
|
||||
#: pretix/static/pretixbase/js/asynctask.js:45
|
||||
#: pretix/static/pretixbase/js/asynctask.js:101
|
||||
msgid ""
|
||||
"Your request arrived on the server but we still wait for it to be processed. "
|
||||
"If this takes longer than two minutes, please contact us or go back in your "
|
||||
"browser and try again."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:69
|
||||
msgid ""
|
||||
"We currently cannot reach the server, but we keep trying. Last error code: "
|
||||
"{code}"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:107
|
||||
#: pretix/static/pretixbase/js/asynctask.js:115
|
||||
#: pretix/static/pretixcontrol/js/ui/mail.js:20
|
||||
msgid "The request took to long. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:119
|
||||
#: pretix/static/pretixbase/js/asynctask.js:127
|
||||
#: pretix/static/pretixcontrol/js/ui/mail.js:25
|
||||
msgid ""
|
||||
"We currently cannot reach the server. Please try again. Error code: {code}"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:174
|
||||
#: pretix/static/pretixbase/js/asynctask.js:193
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:28
|
||||
msgid "Close message"
|
||||
msgstr ""
|
||||
@@ -176,15 +182,15 @@ msgstr ""
|
||||
msgid "Unknown error."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:250
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:252
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:251
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:253
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:541
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:546
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user