Compare commits

..

6 Commits

Author SHA1 Message Date
Raphael Michel
8c0214f334 Fix sqlparse version 2019-04-07 14:09:21 +02:00
Raphael Michel
3b4261884e Deal with deprecation warnings 2019-04-07 14:09:21 +02:00
Raphael Michel
68033e5d7d Resolve naive datetime warnings in test suite 2019-04-07 14:09:21 +02:00
Raphael Michel
f14c12546d Provide explicit orderings to all models used in paginated queries 2019-04-07 14:09:21 +02:00
Raphael Michel
289878689b Update to Django 2.2 and recent versions of similar packages 2019-04-07 14:09:21 +02:00
Raphael Michel
80a2e80b1c Upgrade django and stuff 2019-04-07 14:09:21 +02:00
634 changed files with 87552 additions and 248229 deletions

View File

@@ -14,18 +14,18 @@ if [ "$PRETIX_CONFIG_FILE" == "tests/travis_postgres.cfg" ]; then
fi fi
if [ "$1" == "style" ]; then if [ "$1" == "style" ]; then
XDG_CACHE_HOME=/cache pip3 install --no-use-pep517 -Ur src/requirements.txt -r src/requirements/dev.txt XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt
cd src cd src
flake8 . flake8 .
isort -c -rc -df . isort -c -rc -df .
fi fi
if [ "$1" == "doctests" ]; then if [ "$1" == "doctests" ]; then
XDG_CACHE_HOME=/cache pip3 install --no-use-pep517 -Ur doc/requirements.txt XDG_CACHE_HOME=/cache pip3 install -Ur doc/requirements.txt
cd doc cd doc
make doctest make doctest
fi fi
if [ "$1" == "doc-spelling" ]; then if [ "$1" == "doc-spelling" ]; then
XDG_CACHE_HOME=/cache pip3 install --no-use-pep517 -Ur doc/requirements.txt XDG_CACHE_HOME=/cache pip3 install -Ur doc/requirements.txt
cd doc cd doc
make spelling make spelling
if [ -s _build/spelling/output.txt ]; then if [ -s _build/spelling/output.txt ]; then
@@ -33,26 +33,26 @@ if [ "$1" == "doc-spelling" ]; then
fi fi
fi fi
if [ "$1" == "translation-spelling" ]; then if [ "$1" == "translation-spelling" ]; then
XDG_CACHE_HOME=/cache pip3 install --no-use-pep517 -Ur src/requirements/dev.txt XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements/dev.txt
cd src cd src
potypo potypo
fi fi
if [ "$1" == "tests" ]; then if [ "$1" == "tests" ]; then
pip3 install -r src/requirements.txt --no-use-pep517 -Ur src/requirements/dev.txt pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
cd src cd src
python manage.py check python manage.py check
make all compress make all compress
py.test --reruns 5 -n 3 tests py.test --reruns 5 -n 3 tests
fi fi
if [ "$1" == "tests-cov" ]; then if [ "$1" == "tests-cov" ]; then
pip3 install -r src/requirements.txt --no-use-pep517 -Ur src/requirements/dev.txt pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
cd src cd src
python manage.py check python manage.py check
make all compress make all compress
coverage run -m py.test --reruns 5 tests && codecov coverage run -m py.test --reruns 5 tests && codecov
fi fi
if [ "$1" == "plugins" ]; then if [ "$1" == "plugins" ]; then
pip3 install -r src/requirements.txt --no-use-pep517 -Ur src/requirements/dev.txt pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
cd src cd src
python setup.py develop python setup.py develop
make all compress make all compress

View File

@@ -25,6 +25,8 @@ matrix:
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.5 - python: 3.5
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.7
env: JOB=plugins
- python: 3.7 - python: 3.7
env: JOB=doc-spelling env: JOB=doc-spelling
- python: 3.7 - python: 3.7

View File

@@ -3,7 +3,7 @@ Contributing to pretix
Hey there and welcome to pretix! Hey there and welcome to pretix!
We've got a contributors guide in [our documentation](https://docs.pretix.eu/en/latest/development/contribution/) We've got an contributors guide in [our documentation](https://docs.pretix.eu/en/latest/development/contribution/)
together with notes on the [development setup](https://docs.pretix.eu/en/latest/development/setup.html). together with notes on the [development setup](https://docs.pretix.eu/en/latest/development/setup.html).
Please note that we have a [Code of Conduct](https://docs.pretix.eu/en/latest/development/contribution/codeofconduct.html) Please note that we have a [Code of Conduct](https://docs.pretix.eu/en/latest/development/contribution/codeofconduct.html)

View File

@@ -57,8 +57,6 @@ COPY deployment/docker/nginx.conf /etc/nginx/nginx.conf
COPY deployment/docker/production_settings.py /pretix/src/production_settings.py COPY deployment/docker/production_settings.py /pretix/src/production_settings.py
COPY src /pretix/src COPY src /pretix/src
RUN cd /pretix/src && pip3 install .
RUN chmod +x /usr/local/bin/pretix && \ RUN chmod +x /usr/local/bin/pretix && \
rm /etc/nginx/sites-enabled/default && \ rm /etc/nginx/sites-enabled/default && \
cd /pretix/src && \ cd /pretix/src && \

View File

@@ -57,9 +57,6 @@ Example::
A comma-separated list of plugins that are not available even though they are installed. A comma-separated list of plugins that are not available even though they are installed.
Defaults to an empty string. Defaults to an empty string.
``auth_backends``
A comma-separated list of available auth backends. Defaults to ``pretix.base.auth.NativeAuthBackend``.
``cookie_domain`` ``cookie_domain``
The cookie domain to be set. Defaults to ``None``. The cookie domain to be set. Defaults to ``None``.
@@ -81,15 +78,6 @@ Example::
Enables or disables nagging staff users for leaving comments on their sessions for auditability. Enables or disables nagging staff users for leaving comments on their sessions for auditability.
Defaults to ``off``. Defaults to ``off``.
``obligatory_2fa``
Enables or disables obligatory usage of Two-Factor Authentication for users of the pretix backend.
Defaults to ``False``
``trust_x_forwarded_for``
Specifies whether the ``X-Forwarded-For`` header can be trusted. Only set to ``on`` if you have a reverse
proxy that actively removes and re-adds the header to make sure the correct client IP is the first value.
Defaults to ``off``.
Locale settings Locale settings
--------------- ---------------
@@ -137,8 +125,6 @@ Example::
Indicates if the database backend is a MySQL/MariaDB Galera cluster and Indicates if the database backend is a MySQL/MariaDB Galera cluster and
turns on some optimizations/special case handlers. Default: ``False`` turns on some optimizations/special case handlers. Default: ``False``
.. _`config-replica`:
Database replica settings Database replica settings
------------------------- -------------------------
@@ -156,8 +142,6 @@ Example::
[replica] [replica]
host=192.168.0.2 host=192.168.0.2
.. _`config-urls`:
URLs URLs
---- ----
@@ -285,24 +269,6 @@ to speed up various operations::
If redis is not configured, pretix will store sessions and locks in the database. If memcached If redis is not configured, pretix will store sessions and locks in the database. If memcached
is configured, memcached will be used for caching instead of redis. is configured, memcached will be used for caching instead of redis.
Translations
------------
pretix comes with a number of translations. Some of them are marked as "incubating", which means
they can usually only be selected in development mode. If you want to use them nevertheless, you
can activate them like this::
[languages]
allow_incubating=pt-br,da
You can also tell pretix about additional paths where it will search for translations::
[languages]
path=/path/to/my/translations
For a given language (e.g. ``pt-br``), pretix will then look in the
specific sub-folder, e.g. ``/path/to/my/translations/pt_BR/LC_MESSAGES/django.po``.
Celery task queue Celery task queue
----------------- -----------------

View File

@@ -11,4 +11,3 @@ This documentation is for everyone who wants to install pretix on a server.
installation/index installation/index
config config
maintainance maintainance
scaling

View File

@@ -276,7 +276,7 @@ choice)::
Then, go to that directory and build the image:: Then, go to that directory and build the image::
$ docker build . -t mypretix $ docker build -t mypretix
You can now use that image ``mypretix`` instead of ``pretix/standalone`` in your service file (see above). Be sure You can now use that image ``mypretix`` instead of ``pretix/standalone`` in your service file (see above). Be sure
to re-build your custom image after you pulled ``pretix/standalone`` if you want to perform an update. to re-build your custom image after you pulled ``pretix/standalone`` if you want to perform an update.

View File

@@ -68,7 +68,7 @@ generated key and installs the plugin from the URL we told you::
mkdir -p /etc/ssh && \ mkdir -p /etc/ssh && \
ssh-keyscan -t rsa -p 10022 code.rami.io >> /root/.ssh/known_hosts && \ ssh-keyscan -t rsa -p 10022 code.rami.io >> /root/.ssh/known_hosts && \
echo StrictHostKeyChecking=no >> /root/.ssh/config && \ echo StrictHostKeyChecking=no >> /root/.ssh/config && \
DJANGO_SETTINGS_MODULE=pretix.settings pip3 install -U "git+ssh://git@code.rami.io:10022/pretix/pretix-slack.git@stable#egg=pretix-slack" && \ pip3 install -Ue "git+ssh://git@code.rami.io:10022/pretix/pretix-slack.git@stable#egg=pretix-slack" && \
cd /pretix/src && \ cd /pretix/src && \
sudo -u pretixuser make production sudo -u pretixuser make production
USER pretixuser USER pretixuser

View File

@@ -1,5 +1,3 @@
.. _`installation`:
Installation guide Installation guide
================== ==================

View File

@@ -1,236 +0,0 @@
.. _`scaling`:
Scaling guide
=============
Our :ref:`installation guide <installation>` only covers "small-scale" setups, by which we mostly mean
setups that run on a **single (virtual) machine** and do not encounter large traffic peaks.
We do not offer an installation guide for larger-scale setups of pretix, mostly because we believe that
there is no one-size-fits-all solution for this and the desired setup highly depends on your use case,
the platform you run pretix on, and your technical capabilities. We do not recommend trying set up pretix
in a multi-server environment if you do not already have experience with managing server clusters.
This document is intended to give you a general idea on what issues you will encounter when you scale up
and what you should think of.
.. tip::
If you require more help on this, we're happy to help. Our pretix Enterprise support team has built
and helped building, scaling and load-testing pretix installations at any scale and we're looking
forward to work with you on fine-tuning your system. If you intend to sell **more than a thousand
tickets in a very short amount of time**, we highly recommend reaching out and at least talking this
through. Just get in touch at sales@pretix.eu!
Scaling reasons
---------------
There's mainly two reasons to scale up a pretix installation beyond a single server:
* **Availability:** Distributing pretix over multiple servers can allow you to survive failure of one or more single machines, leading to a higher uptime and reliability of your system.
* **Traffic and throughput:** Distributing pretix over multiple servers can allow you to process more web requests and ticket sales at the same time.
You are very unlikely to require scaling for other reasons, such as having too much data in your database.
Components
----------
A pretix installation usually consists of the following components which run performance-relevant processes:
* ``pretix-web`` is the Django-based web application that serves all user interaction.
* ``pretix-worker`` is a Celery-based application that processes tasks that should be run asynchronously outside of the web application process.
* A **SQL database** keeps all the important data and processes the actual transactions. We recommend using PostgreSQL, but MySQL/MariaDB works as well.
* A **web server** that terminates TLS and HTTP connections and forwards them to ``pretix-web``. In some cases, e.g. when serving static files, the web servers might return a response directly. We recommend using ``nginx``.
* A **redis** server responsible for the communication between ``pretix-web`` and ``pretix-worker``, as well as for caching.
* A directory of **media files** such as user-uploaded files or generated files (tickets, invoices, …) that are created and used by ``pretix-web``, ``pretix-worker`` and the web server.
In the following, we will discuss the scaling behavior of every component individually. In general, you can run all of the components
on the same server, but you can just as well distribute every component to its own server, or even use multiple servers for some single
components.
.. warning::
When setting up your system, don't forget about security. In a multi-server environment,
you need to take special care to ensure that no unauthorized access to your database
is possible through the network and that it's not easy to wiretap your connections. We
recommend a rigorous use of firewalls and encryption on all communications. You can
ensure this either on an application level (such as using the TLS support in your
database) or on a network level with a VPN solution.
Web server
""""""""""
Your web server is at the very front of your installation. It will need to absorb all of the traffic, and it should be able to
at least show a decent error message, even when everything else fails. Luckily, web servers are really fast these days, so this
can be achieved without too much work.
We recommend reading up on tuning your web server for high concurrency. For nginx, this means thinking about the number of worker
processes and the number of connections each worker process accepts. Double-check that TLS session caching works, because TLS
handshakes can get really expensive.
During a traffic peak, your web server will be able to make us of more CPU resources, while memory usage will stay comparatively low,
so if you invest in more hardware here, invest in more and faster CPU cores.
Make sure that pretix' static files (such as CSS and JavaScript assets) as well as user-uploaded media files (event logos, etc)
are served directly by your web server and your web server caches them in-memory (nginx does it by default) and sets useful
headers for client-side caching. As an additional performance improvement, you can turn of access logging for these types of files.
If you want, you can even farm out serving static files to a different web server entirely and :ref:`configure pretix to reference
them from a different URL <config-urls>`.
.. tip::
If you expect *really high traffic* for your very popular event, you might want to do some rate limiting on this layer, or,
if you want to ensure a fair and robust first-come-first-served experience and prefer letting users wait over showing them
errors, consider a queuing solution. We're happy to provide you with such systems, just get in touch at sales@pretix.eu.
pretix-web
""""""""""
The ``pretix-web`` process does not carry any internal state can be easily started on as many machines as you like, and you can
use the load balancing features of your frontend web server to redirect to all of them.
You can adjust the number of processes in the ``gunicorn`` command line, and we recommend choosing roughly two times the number
of CPU cores available. Under load, the memory consumption of ``pretix-web`` will stay comparatively constant, while the CPU usage
will increase a lot. Therefore, if you can add more or faster CPU cores, you will be able to serve more users.
pretix-worker
"""""""""""""
The ``pretix-worker`` process performs all operations that are not directly executed in the request-response-cycle of ``pretix-web``.
Just like ``pretix-web`` you can easily start up as many instances as you want on different machines to share the work. As long as they
all talk to the same redis server, they will all receive tasks from ``pretix-web``, work on them and post their result back.
You can configure the number of threads that run tasks in parallel through the ``--concurrency`` command line option of ``celery``.
Just like ``pretix-web``, this process is mostly heavy on CPU, disk IO and network IO, although memory peaks can occur e.g. during the
generation of large PDF files, so we recommend having some reserves here.
``pretix-worker`` performs a variety of tasks which are of different importance.
Some of them are mission-critical and need to be run quickly even during high load (such as
creating a cart or an order), others are irrelevant and can easily run later (such as
distributing tickets on the waiting list). You can fine-tune the capacity you assign to each
of these tasks by running ``pretix-worker`` processes that only work on a specific **queue**.
For example, you could have three servers dedicated only to process order creations and one
server dedicated only to sending emails. This allows you to set priorities and also protects
you from e.g. a slow email server lowering your ticket throughput.
You can do so by specifying one or more queues on the ``celery`` command line of this process, such as ``celery -A pretix.celery_app worker -Q notifications,mail``. Currently,
the following queues exist:
* ``checkout`` -- This queue handles everything related to carts and orders and thereby everything required to process a sale. This includes adding and deleting items from carts as well as creating and canceling orders.
* ``mail`` -- This queue handles sending of outgoing emails.
* ``notifications`` -- This queue handles the processing of any outgoing notifications, such as email notifications to admin users (except for the actual sending) or API notifications to registered webhooks.
* ``background`` -- This queue handles tasks that are expected to take long or have no human waiting for their result immediately, such as refreshing caches, re-generating CSS files, assigning tickets on the waiting list or parsing bank data files.
* ``default`` -- This queue handles everything else with "medium" or unassigned priority, most prominently the generation of files for tickets, invoices, badges, admin exports, etc.
Media files
"""""""""""
Both ``pretix-web``, ``pretix-worker`` and in some cases your webserver need to work with
media files. Media files are all files generated *at runtime* by the software. This can
include files uploaded by the event organizers, such as the event logo, files uploaded by
ticket buyers (if you use such features) or files generated by the software, such as
ticket files, invoice PDFs, data exports or customized CSS files.
Those files are by default stored to the ``media/`` sub-folder of the data directory given
in the ``pretix.cfg`` configuration file. Inside that ``media/`` folder, you will find a
``pub/`` folder containing the subset of files that should be publicly accessible through
the web server. Everything else only needs to be accessible by ``pretix-web`` and
``pretix-worker`` themselves.
If you distribute ``pretix-web`` or ``pretix-worker`` across more than one machine, you
**must** make sure that they all have access to a shared storage to read and write these
files, otherwise you **will** run into errors with the user interface.
The easiest solution for this is probably to store them on a NFS server that you mount
on each of the other servers.
Since we use Django's file storage mechanism internally, you can in theory also use a object-storage solution like Amazon S3, Ceph, or Minio to store these files, although we currently do not expose this through pretix' configuration file and this would require you to ship your own variant of ``pretix/settings.py`` and reference it through the ``DJANGO_SETTINGS_MODULE`` environment variable.
At pretix.eu, we use a custom-built `object storage cluster`_.
SQL database
""""""""""""
One of the most critical parts of the whole setup is the SQL database -- and certainly the
hardest to scale. Tuning relational databases is an art form, and while there's lots of
material on it on the internet, there's not a single recipe that you can apply to every case.
As a general rule of thumb, the more resources you can give your databases, the better.
Most databases will happily use all CPU cores available, but only use memory up to an amount
you configure, so make sure to set this memory usage as high as you can afford. Having more
memory available allows your database to make more use of caching, which is usually good.
Scaling your database to multiple machines needs to be treated with great caution. It's a
good to have a replica of your database for availability reasons. In case your primary
database server fails, you can easily switch over to the replica and continue working.
However, using database replicas for performance gains is much more complicated. When using
replicated database systems, you are always trading in consistency or availability to get
additional performance and the consequences of this can be subtle and it is important
that you have a deep understanding of the semantics of your replication mechanism.
.. warning::
Using an off-the-shelf database proxy solution that redirects read queries to your
replicas and write queries to your primary database **will lead to very nasty bugs.**
As an example, if you buy a ticket, pretix first needs to calculate how many tickets
are left to sell. If this calculation is done on a database replica that lags behind
even for fractions of a second, the decision to allow selling the ticket will be made
on out-of-data data and you can end up with more tickets sold than configured. Similarly,
you could imagine situations leading to double payments etc.
If you do have a replica, you *can* tell pretix about it :ref:`in your configuration <config-replica>`.
This way, pretix can offload complex read-only queries to the replica when it is safe to do so.
As of pretix 2.7, this is mainly used for search queries in the backend and for rendering the
product list and event lists in the frontend, but we plan on expanding this in the future.
Therefore, for now our clear recommendation is: Try to scale your database vertically and put
it on the most powerful machine you have available.
redis
"""""
While redis is a very important part that glues together some of the components, it isn't used
heavily and can usually handle a fairly large pretix installation easily on a single modern
CPU core.
Having some memory available is good in case of e.g. lots of tasks queuing up during a traffic peak, but we wouldn't expect ever needing more than a gigabyte of it.
Feel free to set up a redis cluster for availability but you won't need it for performance in a long time.
The limitations
---------------
Up to a certain point, pretix scales really well. However, there are a few things that we consider
even more important than scalability, and those are correctness and reliability. We want you to be
able to trust that pretix will not sell more tickets than you intended or run into similar error
cases.
Combined with pretix' flexibility and complexity, especially around vouchers and quotas, this creates
some hard issues. In many cases, we need to fall back to event-global locking for some actions which
are likely to run with high concurrency and cause harm.
For every event, only one of these locking actions can be run at the same time. Examples for this are
adding products limited by a quota to a cart, adding items to a cart using a voucher or placing an order
consisting of cart positions that don't have a valid reservation for much longer. In these cases, it is
currently not realistically possible to exceed selling **approx. 500 orders per minute per event**, even
if you add more hardware.
If you have an unlimited number of tickets, we can apply fewer locking and we've reached **approx.
1500 orders per minute per event** in benchmarks, although even more should be possible.
We're working to reduce the number of cases in which this is relevant and thereby improve the possible
throughput. If you want to use pretix for an event with 10,000+ tickets that are likely to be sold out
within minutes, please get in touch to discuss possible solutions. We'll work something out for you!
.. _object storage cluster: https://behind.pretix.eu/2018/03/20/high-available-cdn/

View File

@@ -1,132 +0,0 @@
Creating an external checkout process
=====================================
Occasionally, we get asked whether it is possible to just use pretix' powerful backend as a ticketing engine but use
a fully-customized checkout process that only communicates via the API. This is possible, but with a few limitations.
If you go down this route, you will miss out on many of pretix features and safeguards, as well as the added flexibility
by most of pretix' plugins. We strongly recommend to talk this through with us before you decide this is the way to go.
However, this is really useful if you need to tightly integrate pretix into existing web applications that e.g. control
the pricing of your products in a way that cannot be mapped to pretix' product structures.
Creating orders
---------------
After letting your user select the products to buy in your application, you should create a new order object inside
pretix. Below, you can see an example of such an order, but most fields are optional and there are some more features
supported. Read :ref:`rest-orders-create` to learn more about this endpoint.
Please note that this endpoint assumes trustworthy input for the most part. By default, the endpoint checks that
you do not exceed any quotas, do not sell any seats twice, or do not use any redeemed vouchers. However, it will not
complain about violation of any other availability constraints, such as violation of time frames or minimum/maximum
amounts of either your product or event. Bundled products will not be added in automatically and fees will not be
calculated automatically.
.. sourcecode:: http
POST /api/v1/organizers/democon/events/3vjrh/orders/ HTTP/1.1
Host: test.pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Authorization: …
{
"email": "dummy@example.org",
"locale": "en",
"sales_channel": "web",
"payment_provider": "banktransfer",
"invoice_address": {
"is_business": false,
"company": "Sample company",
"name_parts": {"full_name": "John Doe"},
"street": "Sesam Street 12",
"zipcode": "12345",
"city": "Sample City",
"country": "US",
"state": "NY",
"internal_reference": "",
"vat_id": ""
},
"positions": [
{
"item": 21,
"variation": null,
"attendee_name_parts": {
"full_name": "Peter"
},
"answers": [
{
"question": 1,
"answer": "23",
"options": []
}
],
"subevent": null
}
],
"fees": []
}
You will be returned a full order object that you can inspect, store, or use to build emails or confirmation pages for
the user. If you don't want to do that yourself, it will also contain the URL to our confirmation page in the ``url``
attribute. If you pass the ``"send_mail": true`` option, pretix will also send order confirmations for you.
Handling payments yourself
--------------------------
If you want to handle payments in your application, you can either just create the orders with status "paid" or you can
create them in "pending" state (the default) and later confirm the payment. We strongly advise to use the payment
provider ``"manual"`` in this case to avoid interference with payment code with pretix.
However, it is often unfeasible to implement the payment process yourself, and it also requires you to give up a
lot of pretix functionality, such as automatic refunds. Therefore, it is also possible to utilize pretix' native
payment process even in this case:
Using pretix payment providers
------------------------------
If you passed a ``payment_provider`` during order creation above, pretix will have created a payment object with state
``created`` that you can see in the returned order object. This payment object will have an attribute ``payment_url``
that you can use to let the user pay. For example, you could link or redirect to this page.
If you want the user to return to your application after the payment is complete, you can pass a query parameter
``return_url``. To prepare your event for this, open your event in the pretix backend and go to "Settings", then
"Plugins". Enable the plugin "Redirection from order page". Then, go to the new page "Settings", then "Redirection".
Enter the base URL of your web application. This will allow you to redirect to pages under this base URL later on.
For example, if you want users to be redirected to ``https://example.org/order/return?tx_id=1234``, you could now
either enter ``https://example.org`` or ``https://example.org/order/``.
The user will be redirected back to your page instead of pretix' order confirmation page after the payment,
**regardless of whether it was successful or not**. Make sure you use our API to check if the payment actually
worked! Your final URL could look like this::
https://test.pretix.eu/democon/3vjrh/order/NSLEZ/ujbrnsjzbq4dzhck/pay/123/?return_url=https%3A%2F%2Fexample.org%2Forder%2Freturn%3Ftx_id%3D1234
You can also embed this page in an ``<iframe>`` instead. Note, however, that this causes problems with some payment
methods such as PayPal which do not allow being opened in an iframe. pretix can partly work around these issues by
opening a new window, but will only to so if you also append an ``iframe=1`` parameter to the URL::
https://test.pretix.eu/democon/3vjrh/order/NSLEZ/ujbrnsjzbq4dzhck/pay/123/?return_url=https%3A%2F%2Fexample.org%2Forder%2Freturn%3Ftx_id%3D1234&iframe=1
If you did **not** pass a payment method since you want us to ask the user which payment method they want to use, you
need to construct the URL from the ``url`` attribute of the order and the sub-path ``pay/change```. For example, you
would end up with the following URL::
https://test.pretix.eu/democon/3vjrh/order/NSLEZ/ujbrnsjzbq4dzhck/pay/change
Of course, you can also use the ``iframe`` and ``return_url`` parameters here.
Optional: Cart reservations
---------------------------
Creating orders is an atomic operation: The order is either created as a whole or not at all. However, pretix'
built-in checkout automatically reserves tickets in a user's cart for a configurable amount of time to ensure users
will actually get their tickets once they started entering all their details. If you want a similar behavior in your
application, you need to create :ref:`rest-carts` through the API.
When creating your order, you can pass a ``consume_carts`` parameter with the cart ID(s) of your user. This way, the
quota reserved by the cart will be credited towards the order and the carts will be destroyed if (and only if) the
order creation succeeds.
Cart creation is currently even more limited than the order creation endpoints, as cart creation currently does not
support vouchers or automatic price calculation. If you require these features, please get in touch with us.

View File

@@ -1,11 +0,0 @@
.. _`rest-api-guides`:
API Usage Guides
================
This part of the documentation contains how-to guides on some special use cases of our API.
.. toctree::
:maxdepth: 2
custom_checkout

View File

@@ -18,4 +18,3 @@ in functionality over time.
resources/index resources/index
ratelimit ratelimit
webhooks webhooks
guides/index

View File

@@ -1,131 +0,0 @@
pretix Hosted billing invoices
==============================
This endpoint allows you to access invoices you received for pretix Hosted. It only contains invoices created starting
November 2017.
.. note:: Only available on pretix Hosted, not on self-hosted pretix instances.
Resource description
--------------------
The resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
invoice_number string Invoice number
date_issued date Invoice date
===================================== ========================== =======================================================
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/billing_invoices/
Returns a list of all invoices to a given organizer.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/billing_invoices/ 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": [
{
"invoice_number": "R2019002",
"date_issued": "2019-06-03"
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``date_issued`` and
its reverse, ``-date_issued``. Default: ``date_issued``.
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/billing_invoices/(invoice_number)/
Returns information on one invoice, identified by its invoice number.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/billing_invoices/R2019002/ 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
{
"invoice_number": "R2019002",
"date_issued": "2019-06-03"
}
:param organizer: The ``slug`` field of the organizer to fetch
:param invoice_number: The ``invoice_number`` field of the invoice to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/billing_invoices/(invoice_number)/download/
Download an invoice in PDF format.
.. warning:: After we created the invoices, they are placed in review with our accounting department. You will
already see them in the API at this point, but you are not able to download them until they completed
review and are sent to you via email. This usually takes a few hours. If you try to download them
in this time frame, you will receive a status code :http:statuscode:`423`.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/billing_invoices/R2019002/download/ 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/pdf
...
:param organizer: The ``slug`` field of the organizer to fetch
:param invoice_number: The ``invoice_number`` field of the invoice to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 423: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
seconds.

View File

@@ -36,20 +36,12 @@ answers list of objects Answers to user
├ question_identifier string The question's ``identifier`` field ├ question_identifier string The question's ``identifier`` field
├ options list of integers Internal IDs of selected option(s)s (only for choice types) ├ options list of integers Internal IDs of selected option(s)s (only for choice types)
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s └ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
seat objects The assigned seat. Can be ``null``.
├ id integer Internal ID of the seat instance
├ name string Human-readable seat name
└ seat_guid string Identifier of the seat within the seating plan
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 1.17 .. versionchanged:: 1.17
This resource has been added. This resource has been added.
.. versionchanged:: 3.0
This ``seat`` attribute has been added.
Cart position endpoints Cart position endpoints
----------------------- -----------------------
@@ -95,7 +87,6 @@ Cart position endpoints
"datetime": "2018-06-11T10:00:00Z", "datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z", "expires": "2018-06-11T10:00:00Z",
"includes_tax": true, "includes_tax": true,
"seat": null,
"answers": [] "answers": []
} }
] ]
@@ -141,7 +132,6 @@ Cart position endpoints
"datetime": "2018-06-11T10:00:00Z", "datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z", "expires": "2018-06-11T10:00:00Z",
"includes_tax": true, "includes_tax": true,
"seat": null,
"answers": [] "answers": []
} }
@@ -188,7 +178,6 @@ Cart position endpoints
* ``item`` * ``item``
* ``variation`` (optional) * ``variation`` (optional)
* ``price`` * ``price``
* ``seat`` (The ``seat_guid`` attribute of a seat. Required when the specified ``item`` requires a seat, otherwise must be ``null``.)
* ``attendee_name`` **or** ``attendee_name_parts`` (optional) * ``attendee_name`` **or** ``attendee_name_parts`` (optional)
* ``attendee_email`` (optional) * ``attendee_email`` (optional)
* ``subevent`` (optional) * ``subevent`` (optional)
@@ -207,7 +196,7 @@ Cart position endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/cartpositions/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/cartpositions/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"item": 1, "item": 1,

View File

@@ -131,7 +131,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/categories/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/categories/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"name": {"en": "Tickets"}, "name": {"en": "Tickets"},

View File

@@ -1,5 +1,3 @@
.. spelling:: checkin
Check-in lists Check-in lists
============== ==============
@@ -29,7 +27,6 @@ subevent integer ID of the date
position_count integer Number of tickets that match this list (read-only). position_count integer Number of tickets that match this list (read-only).
checkin_count integer Number of check-ins performed on this list (read-only). checkin_count integer Number of check-ins performed on this list (read-only).
include_pending boolean If ``true``, the check-in list also contains tickets from orders in pending state. include_pending boolean If ``true``, the check-in list also contains tickets from orders in pending state.
auto_checkin_sales_channels list of strings All items on the check-in list will be automatically marked as checked-in when purchased through any of the listed sales channels.
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 1.10 .. versionchanged:: 1.10
@@ -44,10 +41,6 @@ auto_checkin_sales_channels list of strings All items on th
The ``include_pending`` field has been added. The ``include_pending`` field has been added.
.. versionchanged:: 3.2
The ``auto_checkin_sales_channels`` field has been added.
Endpoints Endpoints
--------- ---------
@@ -88,10 +81,7 @@ Endpoints
"all_products": true, "all_products": true,
"limit_products": [], "limit_products": [],
"include_pending": false, "include_pending": false,
"subevent": null, "subevent": null
"auto_checkin_sales_channels": [
"pretixpos"
]
} }
] ]
} }
@@ -132,10 +122,7 @@ Endpoints
"all_products": true, "all_products": true,
"limit_products": [], "limit_products": [],
"include_pending": false, "include_pending": false,
"subevent": null, "subevent": null
"auto_checkin_sales_channels": [
"pretixpos"
]
} }
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
@@ -222,16 +209,13 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/checkinlists/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/checkinlists/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"name": "VIP entry", "name": "VIP entry",
"all_products": false, "all_products": false,
"limit_products": [1, 2], "limit_products": [1, 2],
"subevent": null, "subevent": null
"auto_checkin_sales_channels": [
"pretixpos"
]
} }
**Example response**: **Example response**:
@@ -250,10 +234,7 @@ Endpoints
"all_products": false, "all_products": false,
"limit_products": [1, 2], "limit_products": [1, 2],
"include_pending": false, "include_pending": false,
"subevent": null, "subevent": null
"auto_checkin_sales_channels": [
"pretixpos"
]
} }
:param organizer: The ``slug`` field of the organizer of the event/item to create a list for :param organizer: The ``slug`` field of the organizer of the event/item to create a list for
@@ -302,10 +283,7 @@ Endpoints
"all_products": false, "all_products": false,
"limit_products": [1, 2], "limit_products": [1, 2],
"include_pending": false, "include_pending": false,
"subevent": null, "subevent": null
"auto_checkin_sales_channels": [
"pretixpos"
]
} }
:param organizer: The ``slug`` field of the organizer to modify :param organizer: The ``slug`` field of the organizer to modify
@@ -358,29 +336,11 @@ Order position endpoints
The order positions endpoint has been extended by the filter queries ``voucher`` and ``voucher__code``. The order positions endpoint has been extended by the filter queries ``voucher`` and ``voucher__code``.
.. versionchanged:: 2.7
The resource now contains the new attributes ``require_attention`` and ``order__status`` and accepts the new
``ignore_status`` filter. The ``attendee_name`` field is now "smart" (see below) and the redemption endpoint
returns ``400`` instead of ``404`` on tickets which are known but not paid.
.. versionchanged:: 3.2
The ``checkins`` dict now also contains a ``auto_checked_in`` value to indicate if the check-in has been performed
automatically by the system.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/ .. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/
Returns a list of all order positions within a given event. The result is the same as Returns a list of all order positions within a given event. The result is the same as
the :ref:`order-position-resource`, with the following differences: the :ref:`order-position-resource`, with one important difference: the ``checkins`` value will only include
check-ins for the selected list.
* The ``checkins`` value will only include check-ins for the selected list.
* An additional boolean property ``require_attention`` will inform you whether either the order or the item
have the ``checkin_attention`` flag set.
* If ``attendee_name`` is empty, it will automatically fall back to values from a parent product or from invoice
addresses.
**Example request**: **Example request**:
@@ -423,12 +383,10 @@ Order position endpoints
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [ "checkins": [
{ {
"list": 1, "list": 1,
"datetime": "2017-12-25T12:45:23Z", "datetime": "2017-12-25T12:45:23Z"
"auto_checked_in": true
} }
], ],
"answers": [ "answers": [
@@ -449,8 +407,6 @@ Order position endpoints
} }
:query integer page: The page number in case of a multi-page result set, default is 1 :query integer page: The page number in case of a multi-page result set, default is 1
:query string ignore_status: If set to ``true``, results will be returned regardless of the state of
the order they belong to and you will need to do your own filtering by order status.
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``order__code``, :query string ordering: Manually set the ordering of results. Valid fields to be used are ``order__code``,
``order__datetime``, ``positionid``, ``attendee_name``, ``last_checked_in`` and ``order__email``. Default: ``order__datetime``, ``positionid``, ``attendee_name``, ``last_checked_in`` and ``order__email``. Default:
``attendee_name,positionid`` ``attendee_name,positionid``
@@ -486,15 +442,8 @@ Order position endpoints
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/(id)/ .. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/(id)/
Returns information on one order position, identified by its internal ID. Returns information on one order position, identified by its internal ID.
The result is the same as the :ref:`order-position-resource`, with the following differences: The result format is the same as the :ref:`order-position-resource`, with one important difference: the
``checkins`` value will only include check-ins for the selected list.
* The ``checkins`` value will only include check-ins for the selected list.
* An additional boolean property ``require_attention`` will inform you whether either the order or the item
have the ``checkin_attention`` flag set.
* If ``attendee_name`` is empty, it will automatically fall back to values from a parent product or from invoice
addresses.
**Instead of an ID, you can also use the ``secret`` field as the lookup parameter.** **Instead of an ID, you can also use the ``secret`` field as the lookup parameter.**
@@ -534,12 +483,10 @@ Order position endpoints
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [ "checkins": [
{ {
"list": 1, "list": 1,
"datetime": "2017-12-25T12:45:23Z", "datetime": "2017-12-25T12:45:23Z"
"auto_checked_in": true
} }
], ],
"answers": [ "answers": [
@@ -577,14 +524,11 @@ Order position endpoints
you do not implement question handling in your user interface, you **must** you do not implement question handling in your user interface, you **must**
set this to ``false``. In that case, questions will just be ignored. Defaults set this to ``false``. In that case, questions will just be ignored. Defaults
to ``true``. to ``true``.
:<json boolean canceled_supported: When this parameter is set to ``true``, the response code ``canceled`` may be
returned. Otherwise, canceled orders will return ``unpaid``.
:<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used. :<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used.
:<json boolean force: Specifies that the check-in should succeed regardless of previous check-ins or required :<json boolean force: Specifies that the check-in should succeed regardless of previous check-ins or required
questions that have not been filled. Defaults to ``false``. questions that have not been filled. Defaults to ``false``.
:<json boolean ignore_unpaid: Specifies that the check-in should succeed even if the order is in pending state. :<json boolean ignore_unpaid: Specifies that the check-in should succeed even if the order is in pending state.
Defaults to ``false`` and only works when ``include_pending`` is set on the check-in Defaults to ``false``.
list.
:<json string nonce: You can set this parameter to a unique random value to identify this check-in. If you're sending :<json string nonce: You can set this parameter to a unique random value to identify this check-in. If you're sending
this request twice with the same nonce, the second request will also succeed but will always this request twice with the same nonce, the second request will also succeed but will always
create only one check-in object even when the previous request was successful as well. This create only one check-in object even when the previous request was successful as well. This
@@ -607,7 +551,6 @@ Order position endpoints
"nonce": "Pvrk50vUzQd0DhdpNRL4I4OcXsvg70uA", "nonce": "Pvrk50vUzQd0DhdpNRL4I4OcXsvg70uA",
"datetime": null, "datetime": null,
"questions_supported": true, "questions_supported": true,
"canceled_supported": true,
"answers": { "answers": {
"4": "XS" "4": "XS"
} }
@@ -691,9 +634,7 @@ Order position endpoints
Possible error reasons: Possible error reasons:
* ``unpaid`` - Ticket is not paid for * ``unpaid`` - Ticket is not paid for or has been refunded
* ``canceled`` Ticket is canceled or expired. This reason is only sent when your request sets
``canceled_supported`` to ``true``, otherwise these orders return ``unpaid``.
* ``already_redeemed`` - Ticket already has been redeemed * ``already_redeemed`` - Ticket already has been redeemed
* ``product`` - Tickets with this product may not be scanned at this device * ``product`` - Tickets with this product may not be scanned at this device

View File

@@ -1,9 +1,3 @@
.. spelling::
geo
lat
lon
Events Events
====== ======
@@ -31,17 +25,11 @@ is_public boolean If ``true``, th
presale_start datetime The date at which the ticket shop opens (or ``null``) presale_start datetime The date at which the ticket shop opens (or ``null``)
presale_end datetime The date at which the ticket shop closes (or ``null``) presale_end datetime The date at which the ticket shop closes (or ``null``)
location multi-lingual string The event location (or ``null``) location multi-lingual string The event location (or ``null``)
geo_lat float Latitude of the location (or ``null``)
geo_lon float Longitude of the location (or ``null``)
has_subevents boolean ``true`` if the event series feature is active for this has_subevents boolean ``true`` if the event series feature is active for this
event. Cannot change after event is created. event. Cannot change after event is created.
meta_data object Values set for organizer-specific meta data parameters. meta_data dict Values set for organizer-specific meta data parameters.
plugins list A list of package names of the enabled plugins for this plugins list A list of package names of the enabled plugins for this
event. event.
seating_plan integer If reserved seating is in use, the ID of a seating
plan. Otherwise ``null``.
seat_category_mapping object An object mapping categories of the seating plan
(strings) to items in the event (integers or ``null``).
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
@@ -62,25 +50,9 @@ seat_category_mapping object An object mappi
The ``testmode`` attribute has been added. The ``testmode`` attribute has been added.
.. versionchanged:: 2.8
When cloning events, the ``testmode`` attribute will now be cloned, too.
.. versionchanged:: 3.0
The attributes ``seating_plan`` and ``seat_category_mapping`` have been added.
.. versionchanged:: 3.3
The attributes ``geo_lat`` and ``geo_lon`` have been added.
Endpoints Endpoints
--------- ---------
.. versionchanged:: 3.3
The events resource can now be filtered by meta data attributes.
.. http:get:: /api/v1/organizers/(organizer)/events/ .. http:get:: /api/v1/organizers/(organizer)/events/
Returns a list of all events within a given organizer the authenticated user/token has access to. Returns a list of all events within a given organizer the authenticated user/token has access to.
@@ -121,12 +93,8 @@ Endpoints
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"geo_lat": null,
"geo_lon": null,
"has_subevents": false, "has_subevents": false,
"meta_data": {}, "meta_data": {},
"seating_plan": null,
"seat_category_mapping": {},
"plugins": [ "plugins": [
"pretix.plugins.banktransfer" "pretix.plugins.banktransfer"
"pretix.plugins.stripe" "pretix.plugins.stripe"
@@ -144,13 +112,6 @@ Endpoints
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. Event series are never (always) returned. :query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. Event series are never (always) returned.
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned. Event series are never (always) returned. :query is_past: If set to ``true`` (``false``), only events that are over are (not) returned. Event series are never (always) returned.
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned. Event series are never returned. :query ends_after: If set to a date and time, only events that happen during of after the given time are returned. Event series are never returned.
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``date_from`` and
``slug``. Keep in mind that ``date_from`` of event series does not really tell you anything.
Default: ``slug``.
:query array attr[meta_data_key]: By providing the key and value of a meta data attribute, the list of events will
only contain the events matching the set criteria. Providing ``?attr[Format]=Seminar`` would return only those
events having set their ``Format`` meta data to ``Seminar``, ``?attr[Format]=`` only those, that have no value
set. Please note that this filter will respect default values set on organizer level.
:param organizer: The ``slug`` field of a valid organizer :param organizer: The ``slug`` field of a valid organizer
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
@@ -191,11 +152,7 @@ Endpoints
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"geo_lat": null,
"geo_lon": null,
"has_subevents": false, "has_subevents": false,
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {}, "meta_data": {},
"plugins": [ "plugins": [
"pretix.plugins.banktransfer" "pretix.plugins.banktransfer"
@@ -227,7 +184,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"name": {"en": "Sample Conference"}, "name": {"en": "Sample Conference"},
@@ -241,11 +198,7 @@ Endpoints
"is_public": false, "is_public": false,
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"seating_plan": null,
"seat_category_mapping": {},
"location": null, "location": null,
"geo_lat": null,
"geo_lon": null,
"has_subevents": false, "has_subevents": false,
"meta_data": {}, "meta_data": {},
"plugins": [ "plugins": [
@@ -275,10 +228,6 @@ Endpoints
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"geo_lat": null,
"geo_lon": null,
"seating_plan": null,
"seat_category_mapping": {},
"has_subevents": false, "has_subevents": false,
"meta_data": {}, "meta_data": {},
"plugins": [ "plugins": [
@@ -296,11 +245,11 @@ Endpoints
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/clone/ .. http:post:: /api/v1/organizers/(organizer)/events/(event)/clone/
Creates a new event with properties as set in the request body. The properties that are copied are: ``is_public``, Creates a new event with properties as set in the request body. The properties that are copied are: 'is_public',
``testmode``, ``has_subevents``, settings, plugin settings, items, variations, add-ons, quotas, categories, tax rules, questions. settings, plugin settings, items, variations, add-ons, quotas, categories, tax rules, questions.
If the ``plugins``, ``has_subevents`` and/or ``is_public`` fields are present in the post body this will determine their If the 'plugins' and/or 'is_public' fields are present in the post body this will determine their value. Otherwise
value. Otherwise their value will be copied from the existing event. their value will be copied from the existing event.
Please note that you can only copy from events under the same organizer. Please note that you can only copy from events under the same organizer.
@@ -313,7 +262,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/clone/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/clone/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"name": {"en": "Sample Conference"}, "name": {"en": "Sample Conference"},
@@ -328,10 +277,6 @@ Endpoints
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"geo_lat": null,
"geo_lon": null,
"seating_plan": null,
"seat_category_mapping": {},
"has_subevents": false, "has_subevents": false,
"meta_data": {}, "meta_data": {},
"plugins": [ "plugins": [
@@ -361,11 +306,7 @@ Endpoints
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"geo_lat": null,
"geo_lon": null,
"has_subevents": false, "has_subevents": false,
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {}, "meta_data": {},
"plugins": [ "plugins": [
"pretix.plugins.stripe", "pretix.plugins.stripe",
@@ -394,7 +335,7 @@ Endpoints
PATCH /api/v1/organizers/bigevents/events/sampleconf/ HTTP/1.1 PATCH /api/v1/organizers/bigevents/events/sampleconf/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"plugins": [ "plugins": [
@@ -426,11 +367,7 @@ Endpoints
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"geo_lat": null,
"geo_lon": null,
"has_subevents": false, "has_subevents": false,
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {}, "meta_data": {},
"plugins": [ "plugins": [
"pretix.plugins.banktransfer", "pretix.plugins.banktransfer",

View File

@@ -1,229 +0,0 @@
.. _`rest-giftcards`:
Gift cards
==========
Resource description
--------------------
The gift card resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the gift card
secret string Gift card code (can not be modified later)
value money (string) Current gift card value
currency string Currency of the value (can not be modified later)
testmode boolean Whether this is a test gift card
===================================== ========================== =======================================================
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/giftcards/
Returns a list of all gift cards issued by a given organizer.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/giftcards/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"secret": "HLBYVELFRC77NCQY",
"currency": "EUR",
"testmode": false,
"value": "13.37"
}
]
}
: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)/giftcards/(id)/
Returns information on one gift card, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/giftcards/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"secret": "HLBYVELFRC77NCQY",
"currency": "EUR",
"testmode": false,
"value": "13.37"
}
:param organizer: The ``slug`` field of the organizer to fetch
:param id: The ``id`` field of the gift card 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)/giftcards/
Creates a new gift card
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/giftcards/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"secret": "HLBYVELFRC77NCQY",
"currency": "EUR",
"value": "13.37"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 1,
"secret": "HLBYVELFRC77NCQY",
"testmode": false,
"currency": "EUR",
"value": "13.37"
}
:param organizer: The ``slug`` field of the organizer to create a gift card for
:statuscode 201: no error
:statuscode 400: The gift card 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)/giftcards/(id)/
Update a gift card. 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``, ``secret``, ``testmode``, and ``currency`` fields. Be
careful when modifying the ``value`` field to avoid race conditions. We recommend to use the ``transact`` method
described below.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/giftcards/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"value": "14.00"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"secret": "HLBYVELFRC77NCQY",
"testmode": false,
"currency": "EUR",
"value": "14.00"
}
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the gift card to modify
:statuscode 200: no error
:statuscode 400: The gift card 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:post:: /api/v1/organizers/(organizer)/giftcards/(id)/transact/
Atomically change the value of a gift card. A positive amount will increase the value of the gift card,
a negative amount will decrease it.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/giftcards/1/transact/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"value": "2.00"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"secret": "HLBYVELFRC77NCQY",
"currency": "EUR",
"testmode": false,
"value": "15.37"
}
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the gift card to modify
:statuscode 200: no error
:statuscode 400: The gift card 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.

View File

@@ -21,8 +21,5 @@ Resources and endpoints
vouchers vouchers
checkinlists checkinlists
waitinglist waitinglist
giftcards
carts carts
webhooks webhooks
seatingplans
billing_invoices

View File

@@ -28,7 +28,6 @@ payment_provider_text string Text to be prin
payment information payment information
footer_text string Text to be printed in the page footer area footer_text string Text to be printed in the page footer area
lines list of objects The actual invoice contents lines list of objects The actual invoice contents
├ position integer Number of the line within an invoice.
├ description string Text representing the invoice line (e.g. product name) ├ description string Text representing the invoice line (e.g. product name)
├ gross_value money (string) Price including taxes ├ gross_value money (string) Price including taxes
├ tax_value money (string) Tax amount included ├ tax_value money (string) Tax amount included
@@ -64,11 +63,6 @@ internal_reference string Customer's refe
The attribute ``internal_reference`` has been added. The attribute ``internal_reference`` has been added.
.. versionchanged:: 3.4
The attribute ``lines.number`` has been added.
Endpoints Endpoints
--------- ---------
@@ -113,7 +107,6 @@ Endpoints
"footer_text": "Big Events LLC - Registration No. 123456 - VAT ID: EU0987654321", "footer_text": "Big Events LLC - Registration No. 123456 - VAT ID: EU0987654321",
"lines": [ "lines": [
{ {
"position": 1,
"description": "Budget Ticket", "description": "Budget Ticket",
"gross_value": "23.00", "gross_value": "23.00",
"tax_value": "0.00", "tax_value": "0.00",
@@ -178,7 +171,6 @@ Endpoints
"footer_text": "Big Events LLC - Registration No. 123456 - VAT ID: EU0987654321", "footer_text": "Big Events LLC - Registration No. 123456 - VAT ID: EU0987654321",
"lines": [ "lines": [
{ {
"position": 1,
"description": "Budget Ticket", "description": "Budget Ticket",
"gross_value": "23.00", "gross_value": "23.00",
"tax_value": "0.00", "tax_value": "0.00",

View File

@@ -134,7 +134,7 @@ Endpoints
POST /api/v1/organizers/(organizer)/events/(event)/items/(item)/addons/ HTTP/1.1 POST /api/v1/organizers/(organizer)/events/(event)/items/(item)/addons/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"addon_category": 1, "addon_category": 1,

View File

@@ -134,7 +134,7 @@ Endpoints
POST /api/v1/organizers/(organizer)/events/(event)/items/(item)/bundles/ HTTP/1.1 POST /api/v1/organizers/(organizer)/events/(event)/items/(item)/bundles/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"bundled_item": 3, "bundled_item": 3,

View File

@@ -18,18 +18,12 @@ default_price money (string) The price set d
price money (string) The price used for this variation. This is either the price money (string) The price used for this variation. This is either the
same as ``default_price`` if that value is set or equal same as ``default_price`` if that value is set or equal
to the item's ``default_price`` (read-only). to the item's ``default_price`` (read-only).
original_price money (string) An original price, shown for comparison, not used
for price calculations (or ``null``).
active boolean If ``false``, this variation will not be sold or shown. active boolean If ``false``, this variation will not be sold or shown.
description multi-lingual string A public description of the variation. May contain description multi-lingual string A public description of the variation. May contain
Markdown syntax or can be ``null``. Markdown syntax or can be ``null``.
position integer An integer, used for sorting position integer An integer, used for sorting
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 2.7
The attribute ``original_price`` has been added.
.. versionchanged:: 1.12 .. versionchanged:: 1.12
This resource has been added. This resource has been added.
@@ -73,8 +67,7 @@ Endpoints
}, },
"position": 0, "position": 0,
"default_price": "223.00", "default_price": "223.00",
"price": 223.0, "price": 223.0
"original_price": null,
}, },
{ {
"id": 3, "id": 3,
@@ -127,7 +120,6 @@ Endpoints
}, },
"default_price": "10.00", "default_price": "10.00",
"price": "10.00", "price": "10.00",
"original_price": null,
"active": true, "active": true,
"description": null, "description": null,
"position": 0 "position": 0
@@ -152,7 +144,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/items/1/variations/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/items/1/variations/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"value": {"en": "Student"}, "value": {"en": "Student"},
@@ -175,7 +167,6 @@ Endpoints
"value": {"en": "Student"}, "value": {"en": "Student"},
"default_price": "10.00", "default_price": "10.00",
"price": "10.00", "price": "10.00",
"original_price": null,
"active": true, "active": true,
"description": null, "description": null,
"position": 0 "position": 0
@@ -225,7 +216,6 @@ Endpoints
"value": {"en": "Student"}, "value": {"en": "Student"},
"default_price": "10.00", "default_price": "10.00",
"price": "10.00", "price": "10.00",
"original_price": null,
"active": false, "active": false,
"description": null, "description": null,
"position": 1 "position": 1

View File

@@ -29,8 +29,7 @@ free_price boolean If ``true``, cu
they buy the product (however, the price can't be set they buy the product (however, the price can't be set
lower than the price defined by ``default_price`` or lower than the price defined by ``default_price`` or
otherwise). otherwise).
tax_rate decimal (string) The VAT rate to be applied for this item (read-only, tax_rate decimal (string) The VAT rate to be applied for this item.
set through ``tax_rule``).
tax_rule integer The internal ID of the applied tax rule (or ``null``). tax_rule integer The internal ID of the applied tax rule (or ``null``).
admission boolean ``true`` for items that grant admission to the event admission boolean ``true`` for items that grant admission to the event
(such as primary tickets) and ``false`` for others (such as primary tickets) and ``false`` for others
@@ -44,9 +43,6 @@ available_from datetime The first date
(or ``null``). (or ``null``).
available_until datetime The last date time at which this item can be bought available_until datetime The last date time at which this item can be bought
(or ``null``). (or ``null``).
hidden_if_available integer The internal ID of a quota object, or ``null``. If
set, this item won't be shown publicly as long as this
quota is available.
require_voucher boolean If ``true``, this item can only be bought using a require_voucher boolean If ``true``, this item can only be bought using a
voucher that is specifically assigned to this item. voucher that is specifically assigned to this item.
hide_without_voucher boolean If ``true``, this item is only shown during the voucher hide_without_voucher boolean If ``true``, this item is only shown during the voucher
@@ -75,11 +71,6 @@ generate_tickets boolean If ``false``, t
non-admission or add-on product, regardless of event non-admission or add-on product, regardless of event
settings. If this is ``null``, regular ticketing settings. If this is ``null``, regular ticketing
rules apply. rules apply.
allow_waitinglist boolean If ``false``, no waiting list will be shown for this
product when it is sold out.
issue_giftcard boolean If ``true``, buying this product will yield a gift card.
show_quota_left boolean Publicly show how many tickets are still available.
If this is ``null``, the event default is used.
has_variations boolean Shows whether or not this item has variations. has_variations boolean Shows whether or not this item has variations.
variations list of objects A list with one object for each variation of this item. variations list of objects A list with one object for each variation of this item.
Can be empty. Only writable during creation, Can be empty. Only writable during creation,
@@ -90,8 +81,6 @@ variations list of objects A list with one
├ price money (string) The price used for this variation. This is either the ├ price money (string) The price used for this variation. This is either the
same as ``default_price`` if that value is set or equal same as ``default_price`` if that value is set or equal
to the item's ``default_price``. to the item's ``default_price``.
├ original_price money (string) An original price, shown for comparison, not used
for price calculations (or ``null``).
├ active boolean If ``false``, this variation will not be sold or shown. ├ active boolean If ``false``, this variation will not be sold or shown.
├ description multi-lingual string A public description of the variation. May contain ├ description multi-lingual string A public description of the variation. May contain
Markdown syntax or can be ``null``. Markdown syntax or can be ``null``.
@@ -116,10 +105,6 @@ bundles list of objects Definition of b
taxation. This is not added to the price. taxation. This is not added to the price.
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 2.7
The attribute ``original_price`` has been added for ``variations``.
.. versionchanged:: 1.7 .. versionchanged:: 1.7
The attribute ``tax_rule`` has been added. ``tax_rate`` is kept for compatibility. The attribute The attribute ``tax_rule`` has been added. ``tax_rate`` is kept for compatibility. The attribute
@@ -150,10 +135,6 @@ bundles list of objects Definition of b
The ``bundles`` and ``require_bundling`` attributes have been added. The ``bundles`` and ``require_bundling`` attributes have been added.
.. versionchanged:: 3.0
The ``show_quota_left``, ``allow_waitinglist``, and ``hidden_if_available`` attributes have been added.
Notes Notes
----- -----
@@ -207,12 +188,10 @@ Endpoints
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": 1, "tax_rule": 1,
"admission": false, "admission": false,
"issue_giftcard": false,
"position": 0, "position": 0,
"picture": null, "picture": null,
"available_from": null, "available_from": null,
"available_until": null, "available_until": null,
"hidden_if_available": null,
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"allow_cancel": true, "allow_cancel": true,
@@ -221,8 +200,6 @@ Endpoints
"checkin_attention": false, "checkin_attention": false,
"has_variations": false, "has_variations": false,
"generate_tickets": null, "generate_tickets": null,
"allow_waitinglist": true,
"show_quota_left": null,
"require_approval": false, "require_approval": false,
"require_bundling": false, "require_bundling": false,
"variations": [ "variations": [
@@ -230,7 +207,6 @@ Endpoints
"value": {"en": "Student"}, "value": {"en": "Student"},
"default_price": "10.00", "default_price": "10.00",
"price": "10.00", "price": "10.00",
"original_price": null,
"active": true, "active": true,
"description": null, "description": null,
"position": 0 "position": 0
@@ -239,7 +215,6 @@ Endpoints
"value": {"en": "Regular"}, "value": {"en": "Regular"},
"default_price": null, "default_price": null,
"price": "23.00", "price": "23.00",
"original_price": null,
"active": true, "active": true,
"description": null, "description": null,
"position": 1 "position": 1
@@ -302,18 +277,14 @@ Endpoints
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": 1, "tax_rule": 1,
"admission": false, "admission": false,
"issue_giftcard": false,
"position": 0, "position": 0,
"picture": null, "picture": null,
"available_from": null, "available_from": null,
"available_until": null, "available_until": null,
"hidden_if_available": null,
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"allow_cancel": true, "allow_cancel": true,
"generate_tickets": null, "generate_tickets": null,
"allow_waitinglist": true,
"show_quota_left": null,
"min_per_order": null, "min_per_order": null,
"max_per_order": null, "max_per_order": null,
"checkin_attention": false, "checkin_attention": false,
@@ -325,7 +296,6 @@ Endpoints
"value": {"en": "Student"}, "value": {"en": "Student"},
"default_price": "10.00", "default_price": "10.00",
"price": "10.00", "price": "10.00",
"original_price": null,
"active": true, "active": true,
"description": null, "description": null,
"position": 0 "position": 0
@@ -334,7 +304,6 @@ Endpoints
"value": {"en": "Regular"}, "value": {"en": "Regular"},
"default_price": null, "default_price": null,
"price": "23.00", "price": "23.00",
"original_price": null,
"active": true, "active": true,
"description": null, "description": null,
"position": 1 "position": 1
@@ -362,7 +331,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/items/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/items/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"id": 1, "id": 1,
@@ -378,18 +347,14 @@ Endpoints
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": 1, "tax_rule": 1,
"admission": false, "admission": false,
"issue_giftcard": false,
"position": 0, "position": 0,
"picture": null, "picture": null,
"available_from": null, "available_from": null,
"available_until": null, "available_until": null,
"hidden_if_available": null,
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"allow_cancel": true, "allow_cancel": true,
"generate_tickets": null, "generate_tickets": null,
"allow_waitinglist": true,
"show_quota_left": null,
"min_per_order": null, "min_per_order": null,
"max_per_order": null, "max_per_order": null,
"checkin_attention": false, "checkin_attention": false,
@@ -400,7 +365,6 @@ Endpoints
"value": {"en": "Student"}, "value": {"en": "Student"},
"default_price": "10.00", "default_price": "10.00",
"price": "10.00", "price": "10.00",
"original_price": null,
"active": true, "active": true,
"description": null, "description": null,
"position": 0 "position": 0
@@ -409,7 +373,6 @@ Endpoints
"value": {"en": "Regular"}, "value": {"en": "Regular"},
"default_price": null, "default_price": null,
"price": "23.00", "price": "23.00",
"original_price": null,
"active": true, "active": true,
"description": null, "description": null,
"position": 1 "position": 1
@@ -441,20 +404,16 @@ Endpoints
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": 1, "tax_rule": 1,
"admission": false, "admission": false,
"issue_giftcard": false,
"position": 0, "position": 0,
"picture": null, "picture": null,
"available_from": null, "available_from": null,
"available_until": null, "available_until": null,
"hidden_if_available": null,
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"allow_cancel": true, "allow_cancel": true,
"min_per_order": null, "min_per_order": null,
"max_per_order": null, "max_per_order": null,
"generate_tickets": null, "generate_tickets": null,
"allow_waitinglist": true,
"show_quota_left": null,
"checkin_attention": false, "checkin_attention": false,
"has_variations": true, "has_variations": true,
"require_approval": false, "require_approval": false,
@@ -464,7 +423,6 @@ Endpoints
"value": {"en": "Student"}, "value": {"en": "Student"},
"default_price": "10.00", "default_price": "10.00",
"price": "10.00", "price": "10.00",
"original_price": null,
"active": true, "active": true,
"description": null, "description": null,
"position": 0 "position": 0
@@ -473,7 +431,6 @@ Endpoints
"value": {"en": "Regular"}, "value": {"en": "Regular"},
"default_price": null, "default_price": null,
"price": "23.00", "price": "23.00",
"original_price": null,
"active": true, "active": true,
"description": null, "description": null,
"position": 1 "position": 1
@@ -536,17 +493,13 @@ Endpoints
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": 1, "tax_rule": 1,
"admission": false, "admission": false,
"issue_giftcard": false,
"position": 0, "position": 0,
"picture": null, "picture": null,
"available_from": null, "available_from": null,
"available_until": null, "available_until": null,
"hidden_if_available": null,
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"generate_tickets": null, "generate_tickets": null,
"allow_waitinglist": true,
"show_quota_left": null,
"allow_cancel": true, "allow_cancel": true,
"min_per_order": null, "min_per_order": null,
"max_per_order": null, "max_per_order": null,
@@ -559,7 +512,6 @@ Endpoints
"value": {"en": "Student"}, "value": {"en": "Student"},
"default_price": "10.00", "default_price": "10.00",
"price": "10.00", "price": "10.00",
"original_price": null,
"active": true, "active": true,
"description": null, "description": null,
"position": 0 "position": 0
@@ -568,7 +520,6 @@ Endpoints
"value": {"en": "Regular"}, "value": {"en": "Regular"},
"default_price": null, "default_price": null,
"price": "23.00", "price": "23.00",
"original_price": null,
"active": true, "active": true,
"description": null, "description": null,
"position": 1 "position": 1

View File

@@ -53,9 +53,7 @@ invoice_address object Invoice address
├ street string Customer street ├ street string Customer street
├ zipcode string Customer ZIP code ├ zipcode string Customer ZIP code
├ city string Customer city ├ city string Customer city
├ country string Customer country code ├ country string Customer country
├ state string Customer state (ISO 3166-2 code). Only supported in
AU, BR, CA, CN, MY, MX, and US.
├ internal_reference string Customer's internal reference to be printed on the invoice ├ internal_reference string Customer's internal reference to be printed on the invoice
├ vat_id string Customer VAT ID ├ vat_id string Customer VAT ID
└ vat_id_validated string ``true``, if the VAT ID has been validated against the └ vat_id_validated string ``true``, if the VAT ID has been validated against the
@@ -84,7 +82,6 @@ require_approval boolean If ``true`` and
needs approval by an organizer before it can needs approval by an organizer before it can
continue. If ``true`` and the order is canceled, continue. If ``true`` and the order is canceled,
this order has been denied by the event organizer. this order has been denied by the event organizer.
url string The full URL to the order confirmation page
payments list of objects List of payment processes (see below) payments list of objects List of payment processes (see below)
refunds list of objects List of refund processes (see below) refunds list of objects List of refund processes (see below)
last_modified datetime Last modification of this object last_modified datetime Last modification of this object
@@ -131,21 +128,15 @@ last_modified datetime Last modificati
The ``sales_channel`` attribute has been added. The ``sales_channel`` attribute has been added.
.. versionchanged:: 2.4 .. versionchanged:: 2.4:
``order.status`` can no longer be ``r``, ``…/mark_canceled/`` now accepts a ``cancellation_fee`` parameter and ``order.status`` can no longer be ``r``, ``…/mark_canceled/`` now accepts a ``cancellation_fee`` parameter and
``…/mark_refunded/`` has been deprecated. ``…/mark_refunded/`` has been deprecated.
.. versionchanged:: 2.5 .. versionchanged:: 2.5:
The ``testmode`` attribute has been added and ``DELETE`` has been implemented for orders. The ``testmode`` attribute has been added and ``DELETE`` has been implemented for orders.
.. versionchanged:: 3.1
The ``invoice_address.state`` and ``url`` attributes have been added. When creating orders through the API,
vouchers are now supported and many fields are now optional.
.. _order-position-resource: .. _order-position-resource:
Order position resource Order position resource
@@ -175,8 +166,7 @@ subevent integer ID of the date
pseudonymization_id string A random ID, e.g. for use in lead scanning apps pseudonymization_id string A random ID, e.g. for use in lead scanning apps
checkins list of objects List of check-ins with this ticket checkins list of objects List of check-ins with this ticket
├ list integer Internal ID of the check-in list ├ list integer Internal ID of the check-in list
datetime datetime Time of check-in datetime datetime Time of check-in
└ auto_checked_in boolean Indicates if this check-in been performed automatically by the system
downloads list of objects List of ticket download options downloads list of objects List of ticket download options
├ output string Ticket output provider (e.g. ``pdf``, ``passbook``) ├ output string Ticket output provider (e.g. ``pdf``, ``passbook``)
└ url string Download URL └ url string Download URL
@@ -186,10 +176,6 @@ answers list of objects Answers to user
├ question_identifier string The question's ``identifier`` field ├ question_identifier string The question's ``identifier`` field
├ options list of integers Internal IDs of selected option(s)s (only for choice types) ├ options list of integers Internal IDs of selected option(s)s (only for choice types)
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s └ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
seat objects The assigned seat. Can be ``null``.
├ id integer Internal ID of the seat instance
├ name string Human-readable seat name
└ seat_guid string Identifier of the seat within the seating plan
pdf_data object Data object required for ticket PDF generation. By default, pdf_data object Data object required for ticket PDF generation. By default,
this field is missing. It will be added only if you add the this field is missing. It will be added only if you add the
``pdf_data=true`` query parameter to your request. ``pdf_data=true`` query parameter to your request.
@@ -211,19 +197,6 @@ pdf_data object Data object req
The attributes ``pseudonymization_id`` and ``pdf_data`` have been added. The attributes ``pseudonymization_id`` and ``pdf_data`` have been added.
.. versionchanged:: 3.0
The attribute ``seat`` has been added.
.. versionchanged:: 3.2
The value ``auto_checked_in`` has been added to the ``checkins``-attribute.
.. versionchanged:: 3.3
The ``url`` of a ticket ``download`` can now also return a ``text/uri-list`` instead of a file. See
:ref:`order-position-ticket-download` for details.
.. _order-payment-resource: .. _order-payment-resource:
Order payment resource Order payment resource
@@ -240,27 +213,13 @@ amount money (string) Payment amount
created datetime Date and time of creation of this payment created datetime Date and time of creation of this payment
payment_date datetime Date and time of completion of this payment (or ``null``) payment_date datetime Date and time of completion of this payment (or ``null``)
provider string Identification string of the payment provider provider string Identification string of the payment provider
payment_url string The URL where an user can continue with the payment (or ``null``)
details object Payment-specific information. This is a dictionary
with various fields that can be different between
payment providers, versions, payment states, etc. If
you read this field, you always need to be able to
deal with situations where values that you expect are
missing. Mostly, the field contains various IDs that
can be used for matching with other systems. If a
payment provider does not implement this feature,
the object is empty.
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 2.0 .. versionchanged:: 2.0
This resource has been added. This resource has been added.
.. versionchanged:: 3.1 .. _order-payment-resource:
The attributes ``payment_url`` and ``details`` have been added.
.. _order-refund-resource:
Order refund resource Order refund resource
--------------------- ---------------------
@@ -321,7 +280,6 @@ List of all orders
"status": "p", "status": "p",
"testmode": false, "testmode": false,
"secret": "k24fiuwvu8kxz3y1", "secret": "k24fiuwvu8kxz3y1",
"url": "https://test.pretix.eu/dummy/dummy/order/ABC12/k24fiuwvu8kxz3y1/",
"email": "tester@example.org", "email": "tester@example.org",
"locale": "en", "locale": "en",
"sales_channel": "web", "sales_channel": "web",
@@ -344,8 +302,7 @@ List of all orders
"street": "Test street 12", "street": "Test street 12",
"zipcode": "12345", "zipcode": "12345",
"city": "Testington", "city": "Testington",
"country": "DE", "country": "Testikistan",
"state": "",
"internal_reference": "", "internal_reference": "",
"vat_id": "EU123456789", "vat_id": "EU123456789",
"vat_id_validated": false "vat_id_validated": false
@@ -371,12 +328,10 @@ List of all orders
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [ "checkins": [
{ {
"list": 44, "list": 44,
"datetime": "2017-12-25T12:45:23Z", "datetime": "2017-12-25T12:45:23Z"
"auto_checked_in": false
} }
], ],
"answers": [ "answers": [
@@ -409,8 +364,6 @@ List of all orders
"amount": "23.00", "amount": "23.00",
"created": "2017-12-01T10:00:00Z", "created": "2017-12-01T10:00:00Z",
"payment_date": "2017-12-04T12:13:12Z", "payment_date": "2017-12-04T12:13:12Z",
"payment_url": null,
"details": {},
"provider": "banktransfer" "provider": "banktransfer"
} }
], ],
@@ -469,7 +422,6 @@ Fetching individual orders
"status": "p", "status": "p",
"testmode": false, "testmode": false,
"secret": "k24fiuwvu8kxz3y1", "secret": "k24fiuwvu8kxz3y1",
"url": "https://test.pretix.eu/dummy/dummy/order/ABC12/k24fiuwvu8kxz3y1/",
"email": "tester@example.org", "email": "tester@example.org",
"locale": "en", "locale": "en",
"sales_channel": "web", "sales_channel": "web",
@@ -492,8 +444,7 @@ Fetching individual orders
"street": "Test street 12", "street": "Test street 12",
"zipcode": "12345", "zipcode": "12345",
"city": "Testington", "city": "Testington",
"country": "DE", "country": "Testikistan",
"state": "",
"internal_reference": "", "internal_reference": "",
"vat_id": "EU123456789", "vat_id": "EU123456789",
"vat_id_validated": false "vat_id_validated": false
@@ -519,12 +470,10 @@ Fetching individual orders
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [ "checkins": [
{ {
"list": 44, "list": 44,
"datetime": "2017-12-25T12:45:23Z", "datetime": "2017-12-25T12:45:23Z"
"auto_checked_in": false
} }
], ],
"answers": [ "answers": [
@@ -557,8 +506,6 @@ Fetching individual orders
"amount": "23.00", "amount": "23.00",
"created": "2017-12-01T10:00:00Z", "created": "2017-12-01T10:00:00Z",
"payment_date": "2017-12-04T12:13:12Z", "payment_date": "2017-12-04T12:13:12Z",
"payment_url": null,
"details": {},
"provider": "banktransfer" "provider": "banktransfer"
} }
], ],
@@ -734,8 +681,6 @@ Deleting orders
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource **or** the order may not be deleted. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource **or** the order may not be deleted.
:statuscode 404: The requested order does not exist. :statuscode 404: The requested order does not exist.
.. _rest-orders-create:
Creating orders Creating orders
--------------- ---------------
@@ -743,6 +688,8 @@ Creating orders
Creates a new order. Creates a new order.
.. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice.
.. warning:: .. warning::
This endpoint is intended for advanced users. It is not designed to be used to build your own shop frontend, This endpoint is intended for advanced users. It is not designed to be used to build your own shop frontend,
@@ -761,20 +708,24 @@ Creating orders
* does not validate the number of items per order or the number of times an item can be included in an order * does not validate the number of items per order or the number of times an item can be included in an order
* does not validate any requirements related to add-on products and does not add bundled products automatically * does not validate any requirements related to add-on products
* does not check prices but believes any prices you send * does not check or calculate prices but believes any prices you send
* does not support the redemption of vouchers
* does not prevent you from buying items that can only be bought with a voucher * does not prevent you from buying items that can only be bought with a voucher
* does not calculate fees automatically * does not calculate fees
* does not allow to pass data to plugins and will therefore cause issues with some plugins like the shipping * does not allow to pass data to plugins and will therefore cause issues with some plugins like the shipping
module module
* does not support file upload questions * does not send order confirmations via email
* does not support redeeming gift cards * does not support reverse charge taxation
* does not support file upload questions
You can supply the following fields of the resource: You can supply the following fields of the resource:
@@ -786,15 +737,14 @@ Creating orders
then call the ``mark_paid`` API method. then call the ``mark_paid`` API method.
* ``testmode`` (optional) Defaults to ``false`` * ``testmode`` (optional) Defaults to ``false``
* ``consume_carts`` (optional) A list of cart IDs. All cart positions with these IDs will be deleted if the * ``consume_carts`` (optional) A list of cart IDs. All cart positions with these IDs will be deleted if the
order creation is successful. Any quotas or seats that become free by this operation will be credited to your order order creation is successful. Any quotas that become free by this operation will be credited to your order
creation. creation.
* ``email`` * ``email``
* ``locale`` * ``locale``
* ``sales_channel`` * ``sales_channel``
* ``payment_provider`` (optional) The identifier of the payment provider set for this order. This needs to be an * ``payment_provider`` The identifier of the payment provider set for this order. This needs to be an existing
existing payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"`` payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"`` for all
for all orders you create as paid. This field is optional when the order status is ``"n"`` or the order total is orders you create as paid.
zero, otherwise it is required.
* ``payment_info`` (optional) You can pass a nested JSON object that will be set as the internal ``info`` * ``payment_info`` (optional) You can pass a nested JSON object that will be set as the internal ``info``
value of the payment object that will be created. How this value is handled is up to the payment provider and you value of the payment object that will be created. How this value is handled is up to the payment provider and you
should only use this if you know the specific payment provider in detail. Please keep in mind that the payment should only use this if you know the specific payment provider in detail. Please keep in mind that the payment
@@ -812,22 +762,16 @@ Creating orders
* ``zipcode`` * ``zipcode``
* ``city`` * ``city``
* ``country`` * ``country``
* ``state``
* ``internal_reference`` * ``internal_reference``
* ``vat_id`` * ``vat_id``
* ``vat_id_validated`` (optional) If you need support for reverse charge (rarely the case), you need to check
yourself if the passed VAT ID is a valid EU VAT ID. In that case, set this to ``true``. Only valid VAT IDs will
trigger reverse charge taxation. Don't forget to set ``is_business`` as well!
* ``positions`` * ``positions``
* ``positionid`` (optional, see below) * ``positionid`` (optional, see below)
* ``item`` * ``item``
* ``variation`` * ``variation``
* ``price`` (optional, if set to ``null`` or missing the price will be computed from the given product) * ``price``
* ``seat`` (The ``seat_guid`` attribute of a seat. Required when the specified ``item`` requires a seat, otherwise must be ``null``.)
* ``attendee_name`` **or** ``attendee_name_parts`` * ``attendee_name`` **or** ``attendee_name_parts``
* ``voucher`` (optional, the ``code`` attribute of a valid voucher)
* ``attendee_email`` * ``attendee_email``
* ``secret`` (optional) * ``secret`` (optional)
* ``addon_to`` (optional, see below) * ``addon_to`` (optional, see below)
@@ -845,18 +789,8 @@ Creating orders
* ``description`` * ``description``
* ``internal_type`` * ``internal_type``
* ``tax_rule`` * ``tax_rule``
* ``_treat_value_as_percentage`` (Optional convenience flag. If set to ``true``, your ``value`` parameter will
be treated as a percentage and the fee will be calculated using that percentage and the sum of all product
prices. Note that this will not include other fees and is calculated once during order generation and will not
be respected automatically when the order changes later.)
* ``_split_taxes_like_products`` (Optional convenience flag. If set to ``true``, your ``tax_rule`` will be ignored
and the fee will be taxed like the products in the order. If the products have multiple tax rates, multiple fees
will be generated with weights adjusted to the net price of the products. Note that this will be calculated once
during order generation and is not respected automatically when the order changes later.)
* ``force`` (optional). If set to ``true``, quotas will be ignored. * ``force`` (optional). If set to ``true``, quotas will be ignored.
* ``send_mail`` (optional). If set to ``true``, the same emails will be sent as for a regular order. Defaults to
``false``.
If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually
to incrementing integers starting with ``1``. Then, you can reference one of these to incrementing integers starting with ``1``. Then, you can reference one of these
@@ -894,7 +828,6 @@ Creating orders
"zipcode": "12345", "zipcode": "12345",
"city": "Sample City", "city": "Sample City",
"country": "UK", "country": "UK",
"state": "",
"internal_reference": "", "internal_reference": "",
"vat_id": "" "vat_id": ""
}, },
@@ -918,7 +851,7 @@ Creating orders
], ],
"subevent": null "subevent": null
} }
] ],
} }
**Example response**: **Example response**:
@@ -1309,11 +1242,6 @@ List of all order positions
The order positions endpoint has been extended by the filter queries ``voucher``, ``voucher__code`` and The order positions endpoint has been extended by the filter queries ``voucher``, ``voucher__code`` and
``pseudonymization_id``. ``pseudonymization_id``.
.. versionchanged:: 3.2
The value ``auto_checked_in`` has been added to the ``checkins``-attribute.
.. note:: Individually canceled order positions are currently not visible via the API at all. .. note:: Individually canceled order positions are currently not visible via the API at all.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/ .. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
@@ -1359,14 +1287,12 @@ List of all order positions
"tax_value": "0.00", "tax_value": "0.00",
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"checkins": [ "checkins": [
{ {
"list": 44, "list": 44,
"datetime": "2017-12-25T12:45:23Z", "datetime": "2017-12-25T12:45:23Z"
"auto_checked_in": false
} }
], ],
"answers": [ "answers": [
@@ -1463,12 +1389,10 @@ Fetching individual positions
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [ "checkins": [
{ {
"list": 44, "list": 44,
"datetime": "2017-12-25T12:45:23Z", "datetime": "2017-12-25T12:45:23Z"
"auto_checked_in": false
} }
], ],
"answers": [ "answers": [
@@ -1496,8 +1420,6 @@ Fetching individual positions
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order position does not exist. :statuscode 404: The requested order position does not exist.
.. _`order-position-ticket-download`:
Order position ticket download Order position ticket download
------------------------------ ------------------------------
@@ -1507,11 +1429,6 @@ Order position ticket download
Depending on the chosen output, the response might be a ZIP file, PDF file or something else. The order details Depending on the chosen output, the response might be a ZIP file, PDF file or something else. The order details
response contains a list of output options for this particular order position. response contains a list of output options for this particular order position.
Be aware that the output does not have to be a file, but can also be a regular HTTP response with a ``Content-Type``
set to ``text/uri-list``. In this case, the user is expected to navigate to that URL in order to access their ticket.
The referenced URL can provide a download or a regular, human-viewable website - so it is advised to open this URL
in a webbrowser and leave it up to the user to handle the result.
Tickets can be only downloaded if the order is paid and if ticket downloads are active. Also, depending on event Tickets can be only downloaded if the order is paid and if ticket downloads are active. Also, depending on event
configuration downloads might be only unavailable for add-on products or non-admission products. configuration downloads might be only unavailable for add-on products or non-admission products.
Note that in some cases the ticket file might not yet have been created. In that case, you will receive a status Note that in some cases the ticket file might not yet have been created. In that case, you will receive a status
@@ -1618,8 +1535,6 @@ Order payment endpoints
"amount": "23.00", "amount": "23.00",
"created": "2017-12-01T10:00:00Z", "created": "2017-12-01T10:00:00Z",
"payment_date": "2017-12-04T12:13:12Z", "payment_date": "2017-12-04T12:13:12Z",
"payment_url": null,
"details": {},
"provider": "banktransfer" "provider": "banktransfer"
} }
] ]
@@ -1660,8 +1575,6 @@ Order payment endpoints
"amount": "23.00", "amount": "23.00",
"created": "2017-12-01T10:00:00Z", "created": "2017-12-01T10:00:00Z",
"payment_date": "2017-12-04T12:13:12Z", "payment_date": "2017-12-04T12:13:12Z",
"payment_url": null,
"details": {},
"provider": "banktransfer" "provider": "banktransfer"
} }

View File

@@ -56,8 +56,6 @@ Endpoints
} }
:query page: The page number in case of a multi-page result set, default is 1 :query page: The page number in case of a multi-page result set, default is 1
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``slug`` and
``name``. Default: ``slug``.
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure

View File

@@ -30,8 +30,6 @@ type string The expected ty
* ``D`` date * ``D`` date
* ``H`` time * ``H`` time
* ``W`` date and time * ``W`` date and time
* ``CC`` country code (ISO 3666-1 alpha-2)
* ``TEL`` telephone number
required boolean If ``true``, the question needs to be filled out. required boolean If ``true``, the question needs to be filled out.
position integer An integer, used for sorting position integer An integer, used for sorting
items list of integers List of item IDs this question is assigned to. items list of integers List of item IDs this question is assigned to.
@@ -40,10 +38,6 @@ identifier string An arbitrary st
ask_during_checkin boolean If ``true``, this question will not be asked while ask_during_checkin boolean If ``true``, this question will not be asked while
buying the ticket, but will show up when redeeming buying the ticket, but will show up when redeeming
the ticket instead. the ticket instead.
hidden boolean If ``true``, the question will only be shown in the
backend.
print_on_invoice boolean If ``true``, the question will only be shown on
invoices.
options list of objects In case of question type ``C`` or ``M``, this lists the options list of objects In case of question type ``C`` or ``M``, this lists the
available objects. Only writable during creation, available objects. Only writable during creation,
use separate endpoint to modify this later. use separate endpoint to modify this later.
@@ -57,12 +51,11 @@ dependency_question integer Internal ID of
this attribute is set to the value given in this attribute is set to the value given in
``dependency_value``. This cannot be combined with ``dependency_value``. This cannot be combined with
``ask_during_checkin``. ``ask_during_checkin``.
dependency_values list of strings If ``dependency_question`` is set to a boolean dependency_value string The value ``dependency_question`` needs to be set to.
question, this should be ``["True"]`` or ``["False"]``. If ``dependency_question`` is set to a boolean
Otherwise, it should be a list of ``identifier`` values question, this should be ``"true"`` or ``"false"``.
of question options. Otherwise, it should be the ``identifier`` of a
dependency_value string An old version of ``dependency_values`` that only allows question option.
for one value. **Deprecated.**
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 1.12 .. versionchanged:: 1.12
@@ -75,18 +68,6 @@ dependency_value string An old version
Write methods have been added. The attribute ``identifier`` has been added to both the resource itself and the Write methods have been added. The attribute ``identifier`` has been added to both the resource itself and the
options resource. The ``position`` attribute has been added to the options resource. options resource. The ``position`` attribute has been added to the options resource.
.. versionchanged:: 2.7
The attribute ``hidden`` and the question type ``CC`` have been added.
.. versionchanged:: 3.0
The attribute ``dependency_values`` has been added.
.. versionchanged:: 3.1
The attribute ``print_on_invoice`` has been added.
Endpoints Endpoints
--------- ---------
@@ -129,11 +110,8 @@ Endpoints
"position": 1, "position": 1,
"identifier": "WY3TP9SL", "identifier": "WY3TP9SL",
"ask_during_checkin": false, "ask_during_checkin": false,
"hidden": false,
"print_on_invoice": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
"dependency_values": [],
"options": [ "options": [
{ {
"id": 1, "id": 1,
@@ -199,11 +177,8 @@ Endpoints
"position": 1, "position": 1,
"identifier": "WY3TP9SL", "identifier": "WY3TP9SL",
"ask_during_checkin": false, "ask_during_checkin": false,
"hidden": false,
"print_on_invoice": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
"dependency_values": [],
"options": [ "options": [
{ {
"id": 1, "id": 1,
@@ -244,7 +219,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/questions/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/questions/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"question": {"en": "T-Shirt size"}, "question": {"en": "T-Shirt size"},
@@ -253,10 +228,8 @@ Endpoints
"items": [1, 2], "items": [1, 2],
"position": 1, "position": 1,
"ask_during_checkin": false, "ask_during_checkin": false,
"hidden": false,
"print_on_invoice": false,
"dependency_question": null, "dependency_question": null,
"dependency_values": [], "dependency_value": null,
"options": [ "options": [
{ {
"answer": {"en": "S"} "answer": {"en": "S"}
@@ -288,11 +261,8 @@ Endpoints
"position": 1, "position": 1,
"identifier": "WY3TP9SL", "identifier": "WY3TP9SL",
"ask_during_checkin": false, "ask_during_checkin": false,
"hidden": false,
"print_on_invoice": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
"dependency_values": [],
"options": [ "options": [
{ {
"id": 1, "id": 1,
@@ -362,11 +332,8 @@ Endpoints
"position": 2, "position": 2,
"identifier": "WY3TP9SL", "identifier": "WY3TP9SL",
"ask_during_checkin": false, "ask_during_checkin": false,
"hidden": false,
"print_on_invoice": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
"dependency_values": [],
"options": [ "options": [
{ {
"id": 1, "id": 1,

View File

@@ -20,22 +20,12 @@ size integer The size of the
items list of integers List of item IDs this quota acts on. items list of integers List of item IDs this quota acts on.
variations list of integers List of item variation IDs this quota acts on. variations list of integers List of item variation IDs this quota acts on.
subevent integer ID of the date inside an event series this quota belongs to (or ``null``). subevent integer ID of the date inside an event series this quota belongs to (or ``null``).
close_when_sold_out boolean If ``true``, the quota will "close" as soon as it is
sold out once. Even if tickets become available again,
they will not be sold unless the quota is set to open
again.
closed boolean Whether the quota is currently closed (see above
field).
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 1.10 .. versionchanged:: 1.10
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added. The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
.. versionchanged:: 3.0
The attributes ``close_when_sold_out`` and ``closed`` have been added.
Endpoints Endpoints
--------- ---------
@@ -71,9 +61,7 @@ Endpoints
"size": 200, "size": 200,
"items": [1, 2], "items": [1, 2],
"variations": [1, 4, 5, 7], "variations": [1, 4, 5, 7],
"subevent": null, "subevent": null
"close_when_sold_out": false,
"closed": false
} }
] ]
} }
@@ -114,9 +102,7 @@ Endpoints
"size": 200, "size": 200,
"items": [1, 2], "items": [1, 2],
"variations": [1, 4, 5, 7], "variations": [1, 4, 5, 7],
"subevent": null, "subevent": null
"close_when_sold_out": false,
"closed": false
} }
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
@@ -137,16 +123,14 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/quotas/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/quotas/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"name": "Ticket Quota", "name": "Ticket Quota",
"size": 200, "size": 200,
"items": [1, 2], "items": [1, 2],
"variations": [1, 4, 5, 7], "variations": [1, 4, 5, 7],
"subevent": null, "subevent": null
"close_when_sold_out": false,
"closed": false
} }
**Example response**: **Example response**:
@@ -163,9 +147,7 @@ Endpoints
"size": 200, "size": 200,
"items": [1, 2], "items": [1, 2],
"variations": [1, 4, 5, 7], "variations": [1, 4, 5, 7],
"subevent": null, "subevent": null
"close_when_sold_out": false,
"closed": false
} }
:param organizer: The ``slug`` field of the organizer of the event/item to create a quota for :param organizer: The ``slug`` field of the organizer of the event/item to create a quota for
@@ -218,9 +200,7 @@ Endpoints
1, 1,
2 2
], ],
"subevent": null, "subevent": null
"close_when_sold_out": false,
"closed": false
} }
:param organizer: The ``slug`` field of the organizer to modify :param organizer: The ``slug`` field of the organizer to modify

View File

@@ -1,209 +0,0 @@
.. _`rest-seatingplans`:
Seating plans
=============
Resource description
--------------------
The seating plan resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the plan
name string Human-readable name of the plan
layout object JSON representation of the seating plan. These
representations follow a JSON schema that currently
still evolves. The version in use can be found `here`_.
===================================== ========================== =======================================================
.. versionchanged:: 3.0
This endpoint has been added.
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/seatingplans/
Returns a list of all seating plans within a given organizer.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/seatingplans/ 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,
"name": "Main plan",
"layout": { … }
}
]
}
: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)/seatingplans/(id)/
Returns information on one plan, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/seatingplans/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,
"name": "Main plan",
"layout": { … }
}
:param organizer: The ``slug`` field of the organizer to fetch
:param id: The ``id`` field of the seating plan 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)/seatingplans/
Creates a new seating plan
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/seatingplans/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"name": "Main plan",
"layout": { … }
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 3,
"name": "Main plan",
"layout": { … }
}
:param organizer: The ``slug`` field of the organizer to create a seating plan for
:statuscode 201: no error
:statuscode 400: The seating plan 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)/seatingplans/(id)/
Update a plan. 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. **You can not change a plan while it is in use for
any events.**
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/seatingplans/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"name": "Old plan"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": "Old plan",
"layout": { … }
}
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the plan to modify
:statuscode 200: no error
:statuscode 400: The plan 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 **or** the plan is currently in use.
.. http:delete:: /api/v1/organizers/(organizer)/seatingplans/(id)/
Delete a plan. You can not delete plans which are currently in use by any events.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/seatingplans/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 plan 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 **or** the plan is currently in use.
.. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/seating/seating-plan.schema.json

View File

@@ -1,9 +1,3 @@
.. spelling::
geo
lat
lon
.. _rest-subevents: .. _rest-subevents:
Event series dates / Sub-events Event series dates / Sub-events
@@ -26,16 +20,12 @@ name multi-lingual string The sub-event's
event string The slug of the parent event event string The slug of the parent event
active boolean If ``true``, the sub-event ticket shop is publicly active boolean If ``true``, the sub-event ticket shop is publicly
available. available.
is_public boolean If ``true``, the sub-event ticket shop is publicly
shown in lists.
date_from datetime The sub-event's start date date_from datetime The sub-event's start date
date_to datetime The sub-event's end date (or ``null``) date_to datetime The sub-event's end date (or ``null``)
date_admission datetime The sub-event's admission date (or ``null``) date_admission datetime The sub-event's admission date (or ``null``)
presale_start datetime The sub-date at which the ticket shop opens (or ``null``) presale_start datetime The sub-date at which the ticket shop opens (or ``null``)
presale_end datetime The sub-date at which the ticket shop closes (or ``null``) presale_end datetime The sub-date at which the ticket shop closes (or ``null``)
location multi-lingual string The sub-event location (or ``null``) location multi-lingual string The sub-event location (or ``null``)
geo_lat float Latitude of the location (or ``null``)
geo_lon float Longitude of the location (or ``null``)
item_price_overrides list of objects List of items for which this sub-event overrides the item_price_overrides list of objects List of items for which this sub-event overrides the
default price default price
├ item integer The internal item ID ├ item integer The internal item ID
@@ -44,11 +34,7 @@ variation_price_overrides list of objects List of variati
the default price the default price
├ variation integer The internal variation ID ├ variation integer The internal variation ID
└ price money (string) The price or ``null`` for the default price └ price money (string) The price or ``null`` for the default price
meta_data object Values set for organizer-specific meta data parameters. meta_data dict Values set for organizer-specific meta data parameters.
seating_plan integer If reserved seating is in use, the ID of a seating
plan. Otherwise ``null``.
seat_category_mapping object An object mapping categories of the seating plan
(strings) to items in the event (integers or ``null``).
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 1.7 .. versionchanged:: 1.7
@@ -62,25 +48,9 @@ seat_category_mapping object An object mappi
.. versionchanged:: 2.6 .. versionchanged:: 2.6
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added. The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
.. versionchanged:: 2.7
The attribute ``is_public`` has been added.
.. versionchanged:: 3.0
The attributes ``seating_plan`` and ``seat_category_mapping`` have been added.
.. versionchanged:: 3.3
The attributes ``geo_lat`` and ``geo_lon`` have been added.
Endpoints Endpoints
--------- ---------
.. versionchanged:: 3.3
The sub-events resource can now be filtered by meta data attributes.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/ .. http:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/
Returns a list of all sub-events of an event. Returns a list of all sub-events of an event.
@@ -111,17 +81,12 @@ Endpoints
"name": {"en": "First Sample Conference"}, "name": {"en": "First Sample Conference"},
"event": "sampleconf", "event": "sampleconf",
"active": false, "active": false,
"is_public": true,
"date_from": "2017-12-27T10:00:00Z", "date_from": "2017-12-27T10:00:00Z",
"date_to": null, "date_to": null,
"date_admission": null, "date_admission": null,
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"seating_plan": null,
"seat_category_mapping": {},
"location": null, "location": null,
"geo_lat": null,
"geo_lon": null,
"item_price_overrides": [ "item_price_overrides": [
{ {
"item": 2, "item": 2,
@@ -141,11 +106,6 @@ Endpoints
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned. :query ends_after: If set to a date and time, only events that happen during of after the given time are returned.
:param organizer: The ``slug`` field of a valid organizer :param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the main event :param event: The ``slug`` field of the main event
:query array attr[meta_data_key]: By providing the key and value of a meta data attribute, the list of sub-events
will only contain the sub-events matching the set criteria. Providing ``?attr[Format]=Seminar`` would return
only those sub-events having set their ``Format`` meta data to ``Seminar``, ``?attr[Format]=`` only those, that
have no value set. Please note that this filter will respect default values set on
organizer or event level.
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it. :statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
@@ -163,22 +123,17 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/subevents/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/subevents/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"name": {"en": "First Sample Conference"}, "name": {"en": "First Sample Conference"},
"active": false, "active": false,
"is_public": true,
"date_from": "2017-12-27T10:00:00Z", "date_from": "2017-12-27T10:00:00Z",
"date_to": null, "date_to": null,
"date_admission": null, "date_admission": null,
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"geo_lat": null,
"geo_lon": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [ "item_price_overrides": [
{ {
"item": 2, "item": 2,
@@ -202,17 +157,12 @@ Endpoints
"id": 1, "id": 1,
"name": {"en": "First Sample Conference"}, "name": {"en": "First Sample Conference"},
"active": false, "active": false,
"is_public": true,
"date_from": "2017-12-27T10:00:00Z", "date_from": "2017-12-27T10:00:00Z",
"date_to": null, "date_to": null,
"date_admission": null, "date_admission": null,
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"geo_lat": null,
"geo_lon": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [ "item_price_overrides": [
{ {
"item": 2, "item": 2,
@@ -257,17 +207,12 @@ Endpoints
"name": {"en": "First Sample Conference"}, "name": {"en": "First Sample Conference"},
"event": "sampleconf", "event": "sampleconf",
"active": false, "active": false,
"is_public": true,
"date_from": "2017-12-27T10:00:00Z", "date_from": "2017-12-27T10:00:00Z",
"date_to": null, "date_to": null,
"date_admission": null, "date_admission": null,
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"geo_lat": null,
"geo_lon": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [ "item_price_overrides": [
{ {
"item": 2, "item": 2,
@@ -300,7 +245,7 @@ Endpoints
PATCH /api/v1/organizers/bigevents/events/sampleconf/subevents/1/ HTTP/1.1 PATCH /api/v1/organizers/bigevents/events/sampleconf/subevents/1/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"name": {"en": "New Subevent Name"}, "name": {"en": "New Subevent Name"},
@@ -325,17 +270,12 @@ Endpoints
"name": {"en": "New Subevent Name"}, "name": {"en": "New Subevent Name"},
"event": "sampleconf", "event": "sampleconf",
"active": false, "active": false,
"is_public": true,
"date_from": "2017-12-27T10:00:00Z", "date_from": "2017-12-27T10:00:00Z",
"date_to": null, "date_to": null,
"date_admission": null, "date_admission": null,
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"geo_lat": null,
"geo_lon": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [ "item_price_overrides": [
{ {
"item": 2, "item": 2,
@@ -413,17 +353,12 @@ Endpoints
"name": {"en": "First Sample Conference"}, "name": {"en": "First Sample Conference"},
"event": "sampleconf", "event": "sampleconf",
"active": false, "active": false,
"is_public": true,
"date_from": "2017-12-27T10:00:00Z", "date_from": "2017-12-27T10:00:00Z",
"date_to": null, "date_to": null,
"date_admission": null, "date_admission": null,
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"geo_lat": null,
"geo_lon": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [ "item_price_overrides": [
{ {
"item": 2, "item": 2,

View File

@@ -38,11 +38,9 @@ quota integer An ID of a quot
attached either to a specific product or to all attached either to a specific product or to all
products within one quota or it can be available products within one quota or it can be available
for all items without restriction. for all items without restriction.
seat string ``seat_guid`` attribute of a specific seat (or ``null``)
tag string A string that is used for grouping vouchers tag string A string that is used for grouping vouchers
comment string An internal comment on the voucher comment string An internal comment on the voucher
subevent integer ID of the date inside an event series this voucher belongs to (or ``null``). subevent integer ID of the date inside an event series this voucher belongs to (or ``null``).
show_hidden_items boolean Only if set to ``true``, this voucher allows to buy products with the property ``hide_without_voucher``. Defaults to ``true``.
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
@@ -50,14 +48,6 @@ show_hidden_items boolean Only if set to
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added. The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
.. versionchanged:: 3.0
The attribute ``show_hidden_items`` has been added.
.. versionchanged:: 3.4
The attribute ``seat`` has been added.
Endpoints Endpoints
--------- ---------
@@ -101,8 +91,7 @@ Endpoints
"quota": null, "quota": null,
"tag": "testvoucher", "tag": "testvoucher",
"comment": "", "comment": "",
"seat": null, "subevent": null
"subevent": null,
} }
] ]
} }
@@ -168,7 +157,6 @@ Endpoints
"quota": null, "quota": null,
"tag": "testvoucher", "tag": "testvoucher",
"comment": "", "comment": "",
"seat": null,
"subevent": null "subevent": null
} }
@@ -232,7 +220,6 @@ Endpoints
"quota": null, "quota": null,
"tag": "testvoucher", "tag": "testvoucher",
"comment": "", "comment": "",
"seat": null,
"subevent": null "subevent": null
} }
@@ -360,7 +347,6 @@ Endpoints
"quota": null, "quota": null,
"tag": "testvoucher", "tag": "testvoucher",
"comment": "", "comment": "",
"seat": null,
"subevent": null "subevent": null
} }

View File

@@ -137,7 +137,7 @@ Endpoints
POST /api/v1/organizers/bigevents/webhooks/ HTTP/1.1 POST /api/v1/organizers/bigevents/webhooks/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content-Type: application/json Content: application/json
{ {
"enabled": true, "enabled": true,

View File

@@ -66,7 +66,7 @@ source_suffix = '.rst'
#source_encoding = 'utf-8-sig' #source_encoding = 'utf-8-sig'
# The master toctree document. # The master toctree document.
master_doc = 'index' master_doc = 'contents'
# General information about the project. # General information about the project.
project = 'pretix' project = 'pretix'
@@ -234,7 +234,7 @@ latex_elements = {
# (source start file, target name, title, # (source start file, target name, title,
# author, documentclass [howto, manual, or own class]). # author, documentclass [howto, manual, or own class]).
latex_documents = [ latex_documents = [
('index', 'pretix.tex', 'pretix Documentation', ('contents', 'pretix.tex', 'pretix Documentation',
'Raphael Michel', 'manual'), 'Raphael Michel', 'manual'),
] ]

View File

@@ -1,70 +0,0 @@
.. highlight:: python
:linenothreshold: 5
Pluggable authentication backends
=================================
Plugins can supply additional authentication backends. This is mainly useful in self-hosted installations
and allows you to use company-wide login mechanisms such as LDAP or OAuth for accessing pretix' backend.
Every authentication backend contains an implementation of the interface defined in ``pretix.base.auth.BaseAuthBackend``
(see below). Note that pretix authentication backends work differently than plain Django authentication backends.
Basically, three pre-defined flows are supported:
* Authentication mechanisms that rely on a **set of input parameters**, e.g. a username and a password. These can be
implemented by supplying the ``login_form_fields`` property and a ``form_authenticate`` method.
* Authentication mechanisms that rely on **external sessions**, e.g. a cookie or a proxy HTTP header. These can be
implemented by supplying a ``request_authenticate`` method.
* Authentication mechanisms that rely on **redirection**, e.g. to an OAuth provider. These can be implemented by
supplying a ``authentication_url`` method and implementing a custom return view.
Authentication backends are *not* collected through a signal. Instead, they must explicitly be set through the
``auth_backends`` directive in the ``pretix.cfg`` :ref:`configuration file <config>`.
In each of these methods (``form_authenticate``, ``request_authenticate`` or your custom view) you are supposed to
either get an existing :py:class:`pretix.base.models.User` object from the database or create a new one. There are a
few rules you need to follow:
* You **MUST** only return users with the ``auth_backend`` attribute set to the ``identifier`` value of your backend.
* You **MUST** create new users with the ``auth_backend`` attribute set to the ``identifier`` value of your backend.
* Every user object **MUST** have an email address. Email addresses are globally unique. If the email address is
already registered to a user who signs in through a different backend, you **SHOULD** refuse the login.
The backend interface
---------------------
.. class:: pretix.base.auth.BaseAuthBackend
The central object of each backend is the subclass of ``BaseAuthBackend``.
.. autoattribute:: identifier
This is an abstract attribute, you **must** override this!
.. autoattribute:: verbose_name
This is an abstract attribute, you **must** override this!
.. autoattribute:: login_form_fields
.. autoattribute:: visible
.. automethod:: form_authenticate
.. automethod:: request_authenticate
.. automethod:: authentication_url
Logging users in
----------------
If you return a user from ``form_authenticate`` or ``request_authenticate``, the system will handle everything else
for you correctly. However, if you use a redirection method and build a custom view to verify the login, we strongly
recommend that you use the following utility method to correctly set session values and enforce two-factor
authentication (if activated):
.. autofunction:: pretix.control.views.auth.process_login

View File

@@ -101,12 +101,9 @@ The template is passed the following context variables:
The ``Event`` object The ``Event`` object
``signature`` (optional, only if configured) ``signature`` (optional, only if configured)
The signature with event organizer contact details as markdown (render with ``{{ signature|safe }}``) The body as markdown (render with ``{{ signature|safe }}``)
``order`` (optional, only if applicable) ``order`` (optional, only if applicable)
The ``Order`` object The ``Order`` object
``position`` (optional, only if applicable)
The ``OrderPosition`` object
.. _inlinestyler: https://pypi.org/project/inlinestyler/ .. _inlinestyler: https://pypi.org/project/inlinestyler/

View File

@@ -12,7 +12,7 @@ Core
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, :members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types,
item_copy_data, register_sales_channels, register_global_settings, quota_availability, global_email_filter item_copy_data, register_sales_channels
Order events Order events
"""""""""""" """"""""""""
@@ -20,13 +20,13 @@ Order events
There are multiple signals that will be sent out in the ordering cycle: There are multiple signals that will be sent out in the ordering cycle:
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:members: validate_cart, validate_cart_addons, validate_order, order_fee_calculation, order_paid, order_placed, order_canceled, order_expired, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download, order_split, order_gracefully_delete, invoice_line_text :members: validate_cart, order_fee_calculation, order_paid, order_placed, order_canceled, order_expired, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download
Frontend Frontend
-------- --------
.. automodule:: pretix.presale.signals .. automodule:: pretix.presale.signals
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info, item_description :members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble
.. automodule:: pretix.presale.signals .. automodule:: pretix.presale.signals
@@ -49,11 +49,11 @@ Backend
.. automodule:: pretix.control.signals .. automodule:: pretix.control.signals
:members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings, :members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings,
order_info, event_settings_widget, oauth_application_registered, order_position_buttons, subevent_forms, order_info, event_settings_widget, oauth_application_registered, order_position_buttons
item_formsets, order_search_filter_q
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:members: logentry_display, logentry_object_link, requiredaction_display, timeline_events :members: logentry_display, logentry_object_link, requiredaction_display
Vouchers Vouchers
"""""""" """"""""
@@ -78,6 +78,3 @@ Ticket designs
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:members: layout_text_variables :members: layout_text_variables
.. automodule:: pretix.plugins.ticketoutputpdf.signals
:members: override_layout

View File

@@ -12,10 +12,8 @@ Contents:
payment payment
payment_2.0 payment_2.0
email email
placeholder
invoice invoice
shredder shredder
customview customview
auth
general general
quality quality

View File

@@ -62,8 +62,6 @@ The provider class
.. autoattribute:: is_enabled .. autoattribute:: is_enabled
.. autoattribute:: priority
.. autoattribute:: settings_form_fields .. autoattribute:: settings_form_fields
.. automethod:: settings_form_clean .. automethod:: settings_form_clean
@@ -110,14 +108,8 @@ The provider class
.. automethod:: execute_refund .. automethod:: execute_refund
.. automethod:: refund_control_render
.. automethod:: api_payment_details
.. automethod:: shred_payment_info .. automethod:: shred_payment_info
.. automethod:: cancel_payment
.. autoattribute:: is_implicit .. autoattribute:: is_implicit
.. autoattribute:: is_meta .. autoattribute:: is_meta

View File

@@ -1,79 +0,0 @@
.. highlight:: python
:linenothreshold: 5
Writing an e-mail placeholder plugin
====================================
An email placeholder is a dynamic value that pretix users can use in their email templates.
Please read :ref:`Creating a plugin <pluginsetup>` first, if you haven't already.
Placeholder registration
------------------------
The placeholder API does not make a lot of usage from signals, however, it
does use a signal to get a list of all available email placeholders. Your plugin
should listen for this signal and return an instance of a subclass of ``pretix.base.email.BaseMailTextPlaceholder``::
from django.dispatch import receiver
from pretix.base.signals import register_mail_placeholders
@receiver(register_mail_placeholders, dispatch_uid="placeholder_custom")
def register_mail_renderers(sender, **kwargs):
from .email import MyPlaceholderClass
return MyPlaceholder()
Context mechanism
-----------------
Emails are sent in different "contexts" within pretix. For example, many emails are sent in the
the context of an order, but some are not, such as the notification of a waiting list voucher.
Not all placeholders make sense in every email, and placeholders usually depend some parameters
themselves, such as the ``Order`` object. Therefore, placeholders are expected to explicitly declare
what values they depend on and they will only be available in an email if all those dependencies are
met. Currently, placeholders can depend on the following context parameters:
* ``event``
* ``order``
* ``position``
* ``waiting_list_entry``
* ``invoice_address``
* ``payment``
There are a few more that are only to be used internally but not by plugins.
The placeholder class
---------------------
.. class:: pretix.base.email.BaseMailTextPlaceholder
.. autoattribute:: identifier
This is an abstract attribute, you **must** override this!
.. autoattribute:: required_context
This is an abstract attribute, you **must** override this!
.. automethod:: render
This is an abstract method, you **must** implement this!
.. automethod:: render_sample
This is an abstract method, you **must** implement this!
Helper class for simple placeholders
------------------------------------
pretix ships with a helper class that makes it easy to provide placeholders based on simple
functions::
placeholder = SimpleFunctionalMailTextPlaceholder(
'code', ['order'], lambda order: order.code, sample='F8VVL'
)

View File

@@ -49,19 +49,15 @@ description string A more verbose description of what your
visible boolean (optional) ``True`` by default, can hide a plugin so it cannot be normally activated. visible boolean (optional) ``True`` by default, can hide a plugin so it cannot be normally activated.
restricted boolean (optional) ``False`` by default, restricts a plugin such that it can only be enabled restricted boolean (optional) ``False`` by default, restricts a plugin such that it can only be enabled
for an event by system administrators / superusers. for an event by system administrators / superusers.
compatibility string Specifier for compatible pretix versions.
================== ==================== =========================================================== ================== ==================== ===========================================================
A working example would be:: A working example would be::
try: from django.apps import AppConfig
from pretix.base.plugins import PluginConfig
except ImportError:
raise RuntimeError("Please use pretix 2.7 or above to run this plugin!")
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
class PaypalApp(PluginConfig): class PaypalApp(AppConfig):
name = 'pretix_paypal' name = 'pretix_paypal'
verbose_name = _("PayPal") verbose_name = _("PayPal")
@@ -72,7 +68,6 @@ A working example would be::
visible = True visible = True
restricted = False restricted = False
description = _("This plugin allows you to receive payments via PayPal") description = _("This plugin allows you to receive payments via PayPal")
compatibility = "pretix>=2.7.0"
default_app_config = 'pretix_paypal.PaypalApp' default_app_config = 'pretix_paypal.PaypalApp'

View File

@@ -35,9 +35,9 @@ The shredder class
.. class:: pretix.base.shredder.BaseDataShredder .. class:: pretix.base.shredder.BaseDataShredder
The central object of each data shredder is the subclass of ``BaseDataShredder``. The central object of each invoice renderer is the subclass of ``BaseInvoiceRenderer``.
.. py:attribute:: BaseDataShredder.event .. py:attribute:: BaseInvoiceRenderer.event
The default constructor sets this property to the event we are currently The default constructor sets this property to the event we are currently
working for. working for.

View File

@@ -69,9 +69,3 @@ The output class
.. automethod:: generate_order .. automethod:: generate_order
.. autoattribute:: download_button_text .. autoattribute:: download_button_text
.. autoattribute:: download_button_icon
.. autoattribute:: preview_allowed
.. autoattribute:: javascript_required

View File

@@ -23,7 +23,7 @@ Organizers and events
:members: :members:
.. autoclass:: pretix.base.models.Event .. autoclass:: pretix.base.models.Event
:members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running, cache, lock, get_plugins, get_mail_backend, payment_term_last, get_payment_providers, get_invoice_renderers, invoice_renderer, settings :members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running, cache, lock, get_plugins, get_mail_backend, payment_term_last, get_payment_providers, get_invoice_renderers, active_subevents, invoice_renderer, settings
.. autoclass:: pretix.base.models.SubEvent .. autoclass:: pretix.base.models.SubEvent
:members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running :members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running

View File

@@ -21,12 +21,10 @@ Your should install the following on your system:
* Python 3.5 or newer * Python 3.5 or newer
* ``pip`` for Python 3 (Debian package: ``python3-pip``) * ``pip`` for Python 3 (Debian package: ``python3-pip``)
* ``python-dev`` for Python 3 (Debian package: ``python3-dev``) * ``python-dev`` for Python 3 (Debian package: ``python3-dev``)
* On Debian/Ubuntu: ``python-venv`` for Python 3 (Debian package: ``python3-venv``)
* ``libffi`` (Debian package: ``libffi-dev``) * ``libffi`` (Debian package: ``libffi-dev``)
* ``libssl`` (Debian package: ``libssl-dev``) * ``libssl`` (Debian package: ``libssl-dev``)
* ``libxml2`` (Debian package ``libxml2-dev``) * ``libxml2`` (Debian package ``libxml2-dev``)
* ``libxslt`` (Debian package ``libxslt1-dev``) * ``libxslt`` (Debian package ``libxslt1-dev``)
* ``libenchant1c2a`` (Debian package ``libenchant1c2a``)
* ``msgfmt`` (Debian package ``gettext``) * ``msgfmt`` (Debian package ``gettext``)
* ``git`` * ``git``
@@ -65,7 +63,9 @@ Then, create the local database::
python manage.py migrate python manage.py migrate
A first user with username ``admin@localhost`` and password ``admin`` will be automatically A first user with username ``admin@localhost`` and password ``admin`` will be automatically
created. created. If you want to generate more test data, run::
python make_testdata.py
If you want to see pretix in a different language than English, you have to compile our language If you want to see pretix in a different language than English, you have to compile our language
files:: files::
@@ -81,7 +81,8 @@ To run the local development webserver, execute::
and head to http://localhost:8000/ and head to http://localhost:8000/
As we did not implement an overall front page yet, you need to go directly to As we did not implement an overall front page yet, you need to go directly to
http://localhost:8000/control/ for the admin view. http://localhost:8000/control/ for the admin view or, if you imported the test
data as suggested above, to the event page at http://localhost:8000/bigevents/2019/
.. note:: If you want the development server to listen on a different interface or .. note:: If you want the development server to listen on a different interface or
port (for example because you develop on `pretixdroid`_), you can check port (for example because you develop on `pretixdroid`_), you can check

View File

@@ -1 +0,0 @@
.. include:: contents.rst

View File

@@ -15,7 +15,6 @@ boolean
booleans booleans
cancelled cancelled
casted casted
Ceph
checkbox checkbox
checksum checksum
config config
@@ -31,24 +30,18 @@ deprovision
discoverable discoverable
django django
dockerfile dockerfile
downloadable
durations durations
eu eu
filename filename
filesystem filesystem
fontawesome fontawesome
formset
formsets
frontend frontend
frontpage frontpage
gettext gettext
giftcard
gunicorn gunicorn
guid
hardcoded hardcoded
hostname hostname
idempotency idempotency
iframe
incrementing incrementing
inofficial inofficial
invalidations invalidations
@@ -57,11 +50,9 @@ Jimdo
libpretixprint libpretixprint
libsass libsass
linters linters
login
memcached memcached
metadata metadata
middleware middleware
Minio
mixin mixin
mixins mixins
multi multi
@@ -79,7 +70,6 @@ overpayment
param param
passphrase passphrase
percental percental
pluggable
positionid positionid
pre pre
prepend prepend
@@ -104,12 +94,10 @@ renderer
renderers renderers
reportlab reportlab
SaaS SaaS
scalability
screenshot screenshot
scss scss
searchable searchable
selectable selectable
serializable
serializers serializers
serializers serializers
sexualized sexualized
@@ -136,15 +124,12 @@ unconfigured
unix unix
unprefixed unprefixed
untrusted untrusted
uptime
username username
url url
versa versa
versioning versioning
viewable
viewset viewset
viewsets viewsets
waitinglist
webhook webhook
webhooks webhooks
webserver webserver

View File

@@ -1,71 +0,0 @@
.. spelling::
Warengutschein
Wertgutschein
Gift cards
==========
Gift cards, also known as "gift coupons" or "gift certificates" are a mechanism that allows you to sell tokens that
can later be used to pay for tickets.
Gift cards are very different feature than **vouchers**. The difference is:
* Vouchers can be used to give a discount. When a voucher is used, the price of a ticket is reduced by the configured
discount and sold at a lower price. They therefore reduce both revenue as well as taxes. Vouchers (in pretix) are
always specific to a certain product in an order. Vouchers are usually not sold but given out as part of a
marketing campaign or to specific groups of people. Vouchers in pretix are bound to a specific event.
* Gift cards are not a discount, but rather a means of payment. If you buy a €20 ticket with a €10 gift card, it is
still a €20 ticket and will still count towards your revenue with €20. Gift cards are usually bought for the money
that they are worth. Gift cards in pretix can be used across events (and even organizers).
Selling gift cards
------------------
Selling gift cards works like selling every other type of product in pretix: Create a new product, then head to
"Additional settings" and select the option "This product is a gift card". Whenever someone buys this product and
pays for it, a new gift card will be created.
In this case, the gift card code corresponds to the "ticket secret" in the PDF ticket. Therefore, if selling gift cards,
you can use ticket downloads just as with normal tickets and use our ticket editor to create beautiful gift certificates
people can give to their loved ones.
Of course, you can use pretix' flexible options to modify your product. For example, you can configure that the customer
can freely choose the price of the gift card.
.. note::
pretix currently does not support charging sales tax or VAT when selling gift cards, but instead charges VAT on
the full price when the gift card is redeemed. This is the correct behavior in Germany and some other countries for
gift cards which are not bound to a very specific service ("Warengutschein"), but instead to a monetary amount
("Wertgutschein").
.. note::
The ticket PDF will not contain the correct gift card code before the order has been paid, so we recommend not
selling gift cards in events where tickets are issued before payments arrive.
Accepting gift cards
--------------------
All your events have have the payment provider "Gift card" enabled by default, but it will only show up in the ticket
shop once the very first gift card has been issued on your organizer account. Of course, you can turn off gift card
payments if you do not want them for a specific event.
If gift card payments are enabled, buyers will be able to select "Gift card" as a payment method during checkout. If
a gift card with a value less than the order total is used, the buyer will be asked to select a second payment method
for the remaining payment. If a gift card with a value greater than the order total is used, the surplus amount
remains on the gift card and can be used in a different purchase.
If it possible to accept gift cards across organizer accounts. To do so, you need to have access to both organizer
accounts. Then, you will see a configuration section at the bottom of the "Gift cards" page of your organizer settings
where you can specify which gift cards should be accepted.
Manually issuing or using gift cards
------------------------------------
Of course, you can also issue or redeem gift cards manually through our backend using the "Gift cards" menu item in your
organizer profile or using our API. These gift cards will be tracked by pretix, but do not correspond to any purchase
within pretix. You will therefore need to account for them in your books separately.

View File

@@ -45,8 +45,8 @@ In addition, you will need quotas. If you do not care how many of your tickets a
If you want to limit the number of student tickets to 50 to ensure a certain minimum revenue, but do not want to limit the number of regular tickets artificially, we suggest you to create the same quota of 200 that is linked to both products, and then create a **second quota** of 50 that is only linked to the student ticket. This way, the system will reduce both quotas whenever a student ticket is sold and only the larger quota when a regular ticket is sold. If you want to limit the number of student tickets to 50 to ensure a certain minimum revenue, but do not want to limit the number of regular tickets artificially, we suggest you to create the same quota of 200 that is linked to both products, and then create a **second quota** of 50 that is only linked to the student ticket. This way, the system will reduce both quotas whenever a student ticket is sold and only the larger quota when a regular ticket is sold.
Use case: Early-bird tiers based on dates Use case: Early-bird tiers
----------------------------------------- --------------------------
Let's say you run a conference that has the following pricing scheme: Let's say you run a conference that has the following pricing scheme:
@@ -58,53 +58,9 @@ Of course, you could just set up one product and change its price at the given d
Create three products (e.g. "super early bird", "early bird", "regular ticket") with the respective prices and one shared quota of your total event capacity. Then, set the **available from** and **available until** configuration fields of the products to automatically turn them on and off based on the current date. Create three products (e.g. "super early bird", "early bird", "regular ticket") with the respective prices and one shared quota of your total event capacity. Then, set the **available from** and **available until** configuration fields of the products to automatically turn them on and off based on the current date.
Use case: Early-bird tiers based on ticket numbers .. note::
--------------------------------------------------
Let's say you run a conference with 400 tickets that has the following pricing scheme: pretix currently can't do early-bird tiers based on **ticket number** instead of time. We're planning this feature for later in 2019. For now, you'll need to monitor that manually.
* First 100 tickets ("super early bird"): € 450
* Next 100 tickets ("early bird"): € 550
* Remaining tickets ("regular"): € 650
First of all, create three products:
* "Super early bird ticket"
* "Early bird ticket"
* "Regular ticket"
Then, create three quotas:
* "Super early bird" with a **size of 100** and the "Super early bird ticket" product selected. At "Advanced options",
select the box "Close this quota permanently once it is sold out".
* "Early bird and lower" with a **size of 200** and both of the "Super early bird ticket" and "Early bird ticket"
products selected. At "Advanced options", select the box "Close this quota permanently once it is sold out".
* "All participants" with a **size of 400**, all three products selected and **no additional options**.
Next, modify the product "Regular ticket". In the section "Availability", you should look for the option "Only show
after sellout of" and select your quota "Early bird and lower". Do the same for the "Early bird ticket" with the quota
"Super early bird ticket".
This will ensure the following things:
* Each ticket level is only visible after the previous level is sold out.
* As soon as one level is really sold out, it's not coming back, because the quota "closes", i.e. locks in place.
* By creating a total quota of 400 with all tickets included, you can still make sure to sell the maximum number of
tickets, even if e.g. early-bird tickets are canceled.
Optionally, if you want to hide the early bird prices once they are sold out, go to "Settings", then "Display" and
select "Hide all products that are sold out". Of course, it might be a nice idea to keep showing the prices to remind
people to buy earlier next time ;)
Please note that there might be short time intervals where the prices switch back and forth: When the last early bird
tickets are in someone's cart (but not yet sold!), the early bird tickets will show as "Reserved" and the regular
tickets start showing up. However, if the customers holding the reservations do not complete their order,
the early bird tickets will become available again. This is not avoidable if we want to prevent malicious users
from blocking all the cheap tickets without an actual sale happening.
Use case: Up-selling of ticket extras Use case: Up-selling of ticket extras
------------------------------------- -------------------------------------
@@ -129,14 +85,8 @@ Use case: Conference with workshops
When running a conference, you might also organize a number of workshops with smaller capacity. To be able to plan, it would be great to know which workshops an attendee plans to attend. When running a conference, you might also organize a number of workshops with smaller capacity. To be able to plan, it would be great to know which workshops an attendee plans to attend.
Option A: Questions
"""""""""""""""""""
Your first and simplest option is to just create a multiple-choice question. This has the upside of making it easy for users to change their mind later on, but will not allow you to restrict the number of attendees signing up for a given workshop or even charge extra for a given workshop. Your first and simplest option is to just create a multiple-choice question. This has the upside of making it easy for users to change their mind later on, but will not allow you to restrict the number of attendees signing up for a given workshop or even charge extra for a given workshop.
Option B: Add-on products with fixed time slots
"""""""""""""""""""""""""""""""""""""""""""""""
The usually better option is to go with add-on products. Let's take for example the following conference schedule, in which the lecture can be attended by anyone, but the workshops only have space for 20 persons each: The usually better option is to go with add-on products. Let's take for example the following conference schedule, in which the lecture can be attended by anyone, but the workshops only have space for 20 persons each:
==================== =================================== =================================== ==================== =================================== ===================================
@@ -167,42 +117,6 @@ Assuming you already created one or more products for your general conference ad
* One add-on configuration on your base product that allows users to choose between 0 and 2 products from the category "Workshops" * One add-on configuration on your base product that allows users to choose between 0 and 2 products from the category "Workshops"
Option C: Add-on products with variable time slots
""""""""""""""""""""""""""""""""""""""""""""""""""
The above option only works if your conference uses fixed time slots and every workshop uses exactly one time slot. If
your schedule looks like this, it's not going to work great:
+-------------+------------+-----------+
| Time | Room A | Room B |
+=============+============+===========+
| 09:00-11:00 | Talk 1 | Long |
+-------------+------------+ Workshop 1|
| 11:00-13:00 | Talk 2 | |
+-------------+------------+-----------+
| 14:00-16:00 | Long | Talk 3 |
+-------------+ workshop 2 +-----------+
| 16:00-18:00 | | Talk 4 |
+-------------+------------+-----------+
In this case, we recommend that you go to *Settings*, then *Plugins* and activate the plugin **Agenda constraints**.
Then, create a product (without variations) for every single part that should be bookable (talks 1-4 and long workshops
1 and 2) as well as appropriate quotas for each of them.
All of these products should be part of the same category. In your base product (e.g. your conference ticket), you
can then create an add-on product configuration allowing users to add products from this category.
If you edit these products, you will be able to enter the "Start date" and "End date" of the talk or workshop close
to the bottom of the page. If you fill in these values, pretix will automatically ensure no overlapping talks are
booked.
.. note::
This option is currently only available on pretix Hosted. If you are interested in using it with pretix Enterprise,
please contact sales@pretix.eu.
Use case: Discounted packages Use case: Discounted packages
----------------------------- -----------------------------

View File

@@ -143,11 +143,6 @@ You can see an example here:
</div> </div>
</noscript> </noscript>
You can filter events by meta data attributes. You can create those attributes in your order profile and set their values in both event and series date
settings. For example, if you set up a meta data property called "Promoted" that you set to "Yes" on some events, you can pass a filter like this::
<pretix-widget event="https://pretix.eu/demo/series/" style="list" filter="attr[Promoted]=Yes"></pretix-widget>
pretix Button pretix Button
------------- -------------
@@ -206,21 +201,6 @@ If you want, you can suppress us loading the widget and/or modify the user data
If you then later want to trigger loading the widgets, just call ``window.PretixWidget.buildWidgets()``. If you then later want to trigger loading the widgets, just call ``window.PretixWidget.buildWidgets()``.
Waiting for the widget to load
------------------------------
If you want to run custom JavaScript once the widget is fully loaded, you can register a callback function. Note that
this function might be run multiple times, for example if you have multiple widgets on a page or if the user switches
e.g. from an event list to an event detail view::
<script type="text/javascript">
window.pretixWidgetCallback = function () {
window.PretixWidget.addLoadListener(function () {
console.log("Widget has loaded!");
});
}
</script>
Passing user data to the widget Passing user data to the widget
------------------------------- -------------------------------
@@ -256,11 +236,6 @@ This works for the pretix Button as well. Currently, the following attributes ar
naming scheme such as ``name-title`` or ``name-given-name`` (see above). ``country`` expects a two-character naming scheme such as ``name-title`` or ``name-given-name`` (see above). ``country`` expects a two-character
country code. country code.
* If ``data-fix="true"`` is given, the user will not be able to change the other given values later. This currently
only works for the order email address as well as the invoice address. Attendee-level fields and questions can
always be modified. Note that this is not a security feature and can easily be overridden by users, so do not rely
on this for authentication.
Any configured pretix plugins might understand more data fields. For example, if the appropriate plugins on pretix Any configured pretix plugins might understand more data fields. For example, if the appropriate plugins on pretix
Hosted or pretix Enterprise are active, you can pass the following fields: Hosted or pretix Enterprise are active, you can pass the following fields:
@@ -294,9 +269,6 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
}; };
</script> </script>
In some combinations with Google Tag Manager, the widget does not load this way. In this case, try replacing
``tracker.get('clientId')`` with ``ga.getAll()[0].get('clientId')``.
.. versionchanged:: 2.3 .. versionchanged:: 2.3

File diff suppressed because one or more lines are too long

View File

@@ -12,6 +12,5 @@ wanting to use pretix to sell tickets.
events/settings events/settings
events/structureguide events/structureguide
events/widget events/widget
events/giftcards
faq faq
markdown markdown

View File

@@ -22,5 +22,3 @@ recursive-include pretix/plugins/ticketoutputpdf/templates *
recursive-include pretix/plugins/ticketoutputpdf/static * recursive-include pretix/plugins/ticketoutputpdf/static *
recursive-include pretix/plugins/badges/templates * recursive-include pretix/plugins/badges/templates *
recursive-include pretix/plugins/badges/static * recursive-include pretix/plugins/badges/static *
recursive-include pretix/plugins/returnurl/templates *
recursive-include pretix/plugins/returnurl/static *

71
src/make_testdata.py Normal file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python
import os
import sys
from datetime import datetime
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pretix.settings")
import django
django.setup()
from pretix.base.models import * # NOQA
from django.utils.timezone import now
if Organizer.objects.exists():
print("There already is data in your DB!")
sys.exit(0)
user = User.objects.get_or_create(
email='admin@localhost',
)[0]
user.set_password('admin')
user.save()
organizer = Organizer.objects.create(
name='BigEvents LLC', slug='bigevents'
)
year = now().year + 1
event = Event.objects.create(
organizer=organizer, name='Demo Conference {}'.format(year),
slug=year, currency='EUR', live=True,
date_from=datetime(year, 9, 4, 17, 0, 0),
date_to=datetime(year, 9, 6, 17, 0, 0),
)
t = Team.objects.get_or_create(
organizer=organizer, name='Admin Team',
all_events=True, can_create_events=True, can_change_teams=True,
can_change_organizer_settings=True, can_change_event_settings=True, can_change_items=True,
can_view_orders=True, can_change_orders=True, can_view_vouchers=True, can_change_vouchers=True
)
t[0].members.add(user)
cat_tickets = ItemCategory.objects.create(
event=event, name='Tickets'
)
cat_merch = ItemCategory.objects.create(
event=event, name='Merchandise'
)
question = Question.objects.create(
event=event, question='Age',
type=Question.TYPE_NUMBER, required=False
)
tr19 = event.tax_rules.create(rate=19)
item_ticket = Item.objects.create(
event=event, category=cat_tickets, name='Ticket',
default_price=23, tax_rule=tr19, admission=True
)
item_ticket.questions.add(question)
item_shirt = Item.objects.create(
event=event, category=cat_merch, name='T-Shirt',
default_price=15, tax_rule=tr19
)
var_s = ItemVariation.objects.create(item=item_shirt, value='S')
var_m = ItemVariation.objects.create(item=item_shirt, value='M')
var_l = ItemVariation.objects.create(item=item_shirt, value='L')
ticket_quota = Quota.objects.create(
event=event, name='Ticket quota', size=400,
)
ticket_quota.items.add(item_ticket)
ticket_shirts = Quota.objects.create(
event=event, name='Shirt quota', size=200,
)
ticket_quota.items.add(item_shirt)
ticket_quota.variations.add(var_s, var_m, var_l)

View File

@@ -1 +1 @@
__version__ = "3.4.0" __version__ = "2.6.0"

View File

@@ -1,5 +1,4 @@
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django_scopes import scopes_disabled
from rest_framework import exceptions from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication from rest_framework.authentication import TokenAuthentication
@@ -13,8 +12,7 @@ class DeviceTokenAuthentication(TokenAuthentication):
def authenticate_credentials(self, key): def authenticate_credentials(self, key):
model = self.get_model() model = self.get_model()
try: try:
with scopes_disabled(): device = model.objects.select_related('organizer').get(api_token=key)
device = model.objects.select_related('organizer').get(api_token=key)
except model.DoesNotExist: except model.DoesNotExist:
raise exceptions.AuthenticationFailed('Invalid token.') raise exceptions.AuthenticationFailed('Invalid token.')

View File

@@ -1,9 +1,8 @@
from rest_framework.permissions import SAFE_METHODS, BasePermission from rest_framework.permissions import SAFE_METHODS, BasePermission
from pretix.api.models import OAuthAccessToken from pretix.api.models import OAuthAccessToken
from pretix.base.models import Device, Event, User from pretix.base.models import Device, Event
from pretix.base.models.auth import SuperuserPermissionSet from pretix.base.models.organizer import Organizer, TeamAPIToken
from pretix.base.models.organizer import TeamAPIToken
from pretix.helpers.security import ( from pretix.helpers.security import (
SessionInvalid, SessionReauthRequired, assert_session_valid, SessionInvalid, SessionReauthRequired, assert_session_valid,
) )
@@ -38,24 +37,21 @@ class EventPermission(BasePermission):
slug=request.resolver_match.kwargs['event'], slug=request.resolver_match.kwargs['event'],
organizer__slug=request.resolver_match.kwargs['organizer'], organizer__slug=request.resolver_match.kwargs['organizer'],
).select_related('organizer').first() ).select_related('organizer').first()
if not request.event or not perm_holder.has_event_permission(request.event.organizer, request.event, request=request): if not request.event or not perm_holder.has_event_permission(request.event.organizer, request.event):
return False return False
request.organizer = request.event.organizer request.organizer = request.event.organizer
if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key): request.eventpermset = perm_holder.get_event_permission_set(request.organizer, request.event)
request.eventpermset = SuperuserPermissionSet()
else:
request.eventpermset = perm_holder.get_event_permission_set(request.organizer, request.event)
if required_permission and required_permission not in request.eventpermset: if required_permission and required_permission not in request.eventpermset:
return False return False
elif 'organizer' in request.resolver_match.kwargs: elif 'organizer' in request.resolver_match.kwargs:
if not request.organizer or not perm_holder.has_organizer_permission(request.organizer, request=request): request.organizer = Organizer.objects.filter(
slug=request.resolver_match.kwargs['organizer'],
).first()
if not request.organizer or not perm_holder.has_organizer_permission(request.organizer):
return False return False
if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key): request.orgapermset = perm_holder.get_organizer_permission_set(request.organizer)
request.orgapermset = SuperuserPermissionSet()
else:
request.orgapermset = perm_holder.get_organizer_permission_set(request.organizer)
if required_permission and required_permission not in request.orgapermset: if required_permission and required_permission not in request.orgapermset:
return False return False

View File

@@ -4,13 +4,10 @@ from hashlib import sha1
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import transaction
from django.http import HttpRequest, HttpResponse, JsonResponse from django.http import HttpRequest, HttpResponse, JsonResponse
from django.urls import resolve
from django.utils.timezone import now from django.utils.timezone import now
from django_scopes import scope
from rest_framework import status from rest_framework import status
from pretix.api.models import ApiCall from pretix.api.models import ApiCall
from pretix.base.models import Organizer
class IdempotencyMiddleware: class IdempotencyMiddleware:
@@ -92,21 +89,3 @@ class IdempotencyMiddleware:
for k, v in json.loads(call.response_headers).values(): for k, v in json.loads(call.response_headers).values():
r[k] = v r[k] = v
return r return r
class ApiScopeMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request: HttpRequest):
if not request.path.startswith('/api/'):
return self.get_response(request)
url = resolve(request.path_info)
if 'organizer' in url.kwargs:
request.organizer = Organizer.objects.filter(
slug=url.kwargs['organizer'],
).first()
with scope(organizer=getattr(request, 'organizer', None)):
return self.get_response(request)

View File

@@ -1,18 +0,0 @@
# Generated by Django 2.2.1 on 2019-10-28 15:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixapi', '0004_auto_20190405_1048'),
]
operations = [
migrations.AlterField(
model_name='oauthgrant',
name='redirect_uri',
field=models.CharField(max_length=2500),
),
]

View File

@@ -43,7 +43,6 @@ class OAuthGrant(AbstractGrant):
OAuthApplication, on_delete=models.CASCADE OAuthApplication, on_delete=models.CASCADE
) )
organizers = models.ManyToManyField('pretixbase.Organizer') organizers = models.ManyToManyField('pretixbase.Organizer')
redirect_uri = models.CharField(max_length=2500) # Only 255 in AbstractGrant, which caused problems
class OAuthAccessToken(AbstractAccessToken): class OAuthAccessToken(AbstractAccessToken):

View File

@@ -8,33 +8,31 @@ from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import ( from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer, AnswerCreateSerializer, AnswerSerializer,
) )
from pretix.base.models import Quota, Seat from pretix.base.models import Quota
from pretix.base.models.orders import CartPosition from pretix.base.models.orders import CartPosition
class CartPositionSerializer(I18nAwareModelSerializer): class CartPositionSerializer(I18nAwareModelSerializer):
answers = AnswerSerializer(many=True) answers = AnswerSerializer(many=True)
seat = InlineSeatSerializer()
class Meta: class Meta:
model = CartPosition model = CartPosition
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'attendee_email', 'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax', 'attendee_email', 'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
'answers', 'seat') 'answers',)
class CartPositionCreateSerializer(I18nAwareModelSerializer): class CartPositionCreateSerializer(I18nAwareModelSerializer):
answers = AnswerCreateSerializer(many=True, required=False) answers = AnswerCreateSerializer(many=True, required=False)
expires = serializers.DateTimeField(required=False) expires = serializers.DateTimeField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True) attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
class Meta: class Meta:
model = CartPosition model = CartPosition
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email', fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'subevent', 'expires', 'includes_tax', 'answers', 'seat') 'subevent', 'expires', 'includes_tax', 'answers',)
def create(self, validated_data): def create(self, validated_data):
answers_data = validated_data.pop('answers') answers_data = validated_data.pop('answers')
@@ -73,24 +71,6 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
validated_data['attendee_name_parts'] = { validated_data['attendee_name_parts'] = {
'_legacy': attendee_name '_legacy': attendee_name
} }
seated = validated_data.get('item').seat_category_mappings.filter(subevent=validated_data.get('subevent')).exists()
if validated_data.get('seat'):
if not seated:
raise ValidationError('The specified product does not allow to choose a seat.')
try:
seat = self.context['event'].seats.get(seat_guid=validated_data['seat'], subevent=validated_data.get('subevent'))
except Seat.DoesNotExist:
raise ValidationError('The specified seat does not exist.')
except Seat.MultipleObjectsReturned:
raise ValidationError('The specified seat ID is not unique.')
else:
validated_data['seat'] = seat
if not seat.is_available():
raise ValidationError(ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name))
elif seated:
raise ValidationError('The specified product requires to choose a seat.')
cp = CartPosition.objects.create(event=self.context['event'], **validated_data) cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
for answ_data in answers_data: for answ_data in answers_data:

View File

@@ -3,7 +3,6 @@ from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.channels import get_all_sales_channels
from pretix.base.models import CheckinList from pretix.base.models import CheckinList
@@ -14,7 +13,7 @@ class CheckinListSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = CheckinList model = CheckinList
fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count', fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count',
'include_pending', 'auto_checkin_sales_channels') 'include_pending')
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
@@ -36,8 +35,4 @@ class CheckinListSerializer(I18nAwareModelSerializer):
if full_data.get('subevent'): if full_data.get('subevent'):
raise ValidationError(_('The subevent does not belong to this event.')) raise ValidationError(_('The subevent does not belong to this event.'))
for channel in full_data.get('auto_checkin_sales_channels') or []:
if channel not in get_all_sales_channels():
raise ValidationError(_('Unknown sales channel.'))
return data return data

View File

@@ -11,9 +11,6 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Event, TaxRule from pretix.base.models import Event, TaxRule
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.models.items import SubEventItem, SubEventItemVariation from pretix.base.models.items import SubEventItem, SubEventItemVariation
from pretix.base.services.seating import (
SeatProtected, generate_seats, validate_plan_change,
)
class MetaDataField(Field): class MetaDataField(Field):
@@ -29,22 +26,6 @@ class MetaDataField(Field):
} }
class SeatCategoryMappingField(Field):
def to_representation(self, value):
qs = value.seat_category_mappings.all()
if isinstance(value, Event):
qs = qs.filter(subevent=None)
return {
v.layout_category: v.product_id for v in qs
}
def to_internal_value(self, data):
return {
'seat_category_mapping': data or {}
}
class PluginsField(Field): class PluginsField(Field):
def to_representation(self, obj): def to_representation(self, obj):
@@ -64,14 +45,12 @@ class PluginsField(Field):
class EventSerializer(I18nAwareModelSerializer): class EventSerializer(I18nAwareModelSerializer):
meta_data = MetaDataField(required=False, source='*') meta_data = MetaDataField(required=False, source='*')
plugins = PluginsField(required=False, source='*') plugins = PluginsField(required=False, source='*')
seat_category_mapping = SeatCategoryMappingField(source='*', required=False)
class Meta: class Meta:
model = Event model = Event
fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from', fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from',
'date_to', 'date_admission', 'is_public', 'presale_start', 'date_to', 'date_admission', 'is_public', 'presale_start',
'presale_end', 'location', 'geo_lat', 'geo_lon', 'has_subevents', 'meta_data', 'seating_plan', 'presale_end', 'location', 'has_subevents', 'meta_data', 'plugins')
'plugins', 'seat_category_mapping')
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
@@ -82,9 +61,6 @@ class EventSerializer(I18nAwareModelSerializer):
Event.clean_dates(data.get('date_from'), data.get('date_to')) Event.clean_dates(data.get('date_from'), data.get('date_to'))
Event.clean_presale(data.get('presale_start'), data.get('presale_end')) Event.clean_presale(data.get('presale_start'), data.get('presale_end'))
if full_data.get('has_subevents') and full_data.get('seating_plan'):
raise ValidationError('Event series should not directly be assigned a seating plan.')
return data return data
def validate_has_subevents(self, value): def validate_has_subevents(self, value):
@@ -116,27 +92,6 @@ class EventSerializer(I18nAwareModelSerializer):
raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key)) raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key))
return value return value
def validate_seating_plan(self, value):
if value and value.organizer != self.context['request'].organizer:
raise ValidationError('Invalid seating plan.')
if self.instance and self.instance.pk:
try:
validate_plan_change(self.instance, None, value)
except SeatProtected as e:
raise ValidationError(str(e))
return value
def validate_seat_category_mapping(self, value):
if value and value['seat_category_mapping'] and (not self.instance or not self.instance.pk):
raise ValidationError('You cannot specify seat category mappings on event creation.')
item_cache = {i.pk: i for i in self.instance.items.all()}
result = {}
for k, item in value['seat_category_mapping'].items():
if item not in item_cache:
raise ValidationError('Item \'{id}\' does not exist.'.format(id=item))
result[k] = item_cache[item]
return {'seat_category_mapping': result}
def validate_plugins(self, value): def validate_plugins(self, value):
from pretix.base.plugins import get_all_plugins from pretix.base.plugins import get_all_plugins
@@ -154,7 +109,6 @@ class EventSerializer(I18nAwareModelSerializer):
@transaction.atomic @transaction.atomic
def create(self, validated_data): def create(self, validated_data):
meta_data = validated_data.pop('meta_data', None) meta_data = validated_data.pop('meta_data', None)
validated_data.pop('seat_category_mapping', None)
plugins = validated_data.pop('plugins', settings.PRETIX_PLUGINS_DEFAULT.split(',')) plugins = validated_data.pop('plugins', settings.PRETIX_PLUGINS_DEFAULT.split(','))
event = super().create(validated_data) event = super().create(validated_data)
@@ -166,10 +120,6 @@ class EventSerializer(I18nAwareModelSerializer):
value=value value=value
) )
# Seats
if event.seating_plan:
generate_seats(event, None, event.seating_plan, {})
# Plugins # Plugins
if plugins is not None: if plugins is not None:
event.set_active_plugins(plugins) event.set_active_plugins(plugins)
@@ -181,7 +131,6 @@ class EventSerializer(I18nAwareModelSerializer):
def update(self, instance, validated_data): def update(self, instance, validated_data):
meta_data = validated_data.pop('meta_data', None) meta_data = validated_data.pop('meta_data', None)
plugins = validated_data.pop('plugins', None) plugins = validated_data.pop('plugins', None)
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
event = super().update(instance, validated_data) event = super().update(instance, validated_data)
# Meta data # Meta data
@@ -202,29 +151,6 @@ class EventSerializer(I18nAwareModelSerializer):
if prop.name not in meta_data: if prop.name not in meta_data:
current_object.delete() current_object.delete()
# Seats
if seat_category_mapping is not None or ('seating_plan' in validated_data and validated_data['seating_plan'] is None):
current_mappings = {
m.layout_category: m
for m in event.seat_category_mappings.filter(subevent=None)
}
if not event.seating_plan:
seat_category_mapping = {}
for key, value in seat_category_mapping.items():
if key in current_mappings:
m = current_mappings.pop(key)
m.product = value
m.save()
else:
event.seat_category_mappings.create(product=value, layout_category=key)
for m in current_mappings.values():
m.delete()
if 'seating_plan' in validated_data or seat_category_mapping is not None:
generate_seats(event, None, event.seating_plan, {
m.layout_category: m.product
for m in event.seat_category_mappings.select_related('product').filter(subevent=None)
})
# Plugins # Plugins
if plugins is not None: if plugins is not None:
event.set_active_plugins(plugins) event.set_active_plugins(plugins)
@@ -238,8 +164,6 @@ class CloneEventSerializer(EventSerializer):
def create(self, validated_data): def create(self, validated_data):
plugins = validated_data.pop('plugins', None) plugins = validated_data.pop('plugins', None)
is_public = validated_data.pop('is_public', None) is_public = validated_data.pop('is_public', None)
testmode = validated_data.pop('testmode', None)
has_subevents = validated_data.pop('has_subevents', None)
new_event = super().create(validated_data) new_event = super().create(validated_data)
event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first() event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first()
@@ -249,10 +173,6 @@ class CloneEventSerializer(EventSerializer):
new_event.set_active_plugins(plugins) new_event.set_active_plugins(plugins)
if is_public is not None: if is_public is not None:
new_event.is_public = is_public new_event.is_public = is_public
if testmode is not None:
new_event.testmode = testmode
if has_subevents is not None:
new_event.has_subevents = has_subevents
new_event.save() new_event.save()
return new_event return new_event
@@ -273,16 +193,14 @@ class SubEventItemVariationSerializer(I18nAwareModelSerializer):
class SubEventSerializer(I18nAwareModelSerializer): class SubEventSerializer(I18nAwareModelSerializer):
item_price_overrides = SubEventItemSerializer(source='subeventitem_set', many=True, required=False) item_price_overrides = SubEventItemSerializer(source='subeventitem_set', many=True, required=False)
variation_price_overrides = SubEventItemVariationSerializer(source='subeventitemvariation_set', many=True, required=False) variation_price_overrides = SubEventItemVariationSerializer(source='subeventitemvariation_set', many=True, required=False)
seat_category_mapping = SeatCategoryMappingField(source='*', required=False)
event = SlugRelatedField(slug_field='slug', read_only=True) event = SlugRelatedField(slug_field='slug', read_only=True)
meta_data = MetaDataField(source='*') meta_data = MetaDataField(source='*')
class Meta: class Meta:
model = SubEvent model = SubEvent
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission', fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
'presale_start', 'presale_end', 'location', 'geo_lat', 'geo_lon', 'event', 'is_public', 'presale_start', 'presale_end', 'location', 'event',
'seating_plan', 'item_price_overrides', 'variation_price_overrides', 'meta_data', 'item_price_overrides', 'variation_price_overrides', 'meta_data')
'seat_category_mapping')
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
@@ -294,8 +212,8 @@ class SubEventSerializer(I18nAwareModelSerializer):
Event.clean_dates(data.get('date_from'), data.get('date_to')) Event.clean_dates(data.get('date_from'), data.get('date_to'))
Event.clean_presale(data.get('presale_start'), data.get('presale_end')) Event.clean_presale(data.get('presale_start'), data.get('presale_end'))
SubEvent.clean_items(event, [item['item'] for item in full_data.get('subeventitem_set', [])]) SubEvent.clean_items(event, [item['item'] for item in full_data.get('subeventitem_set')])
SubEvent.clean_variations(event, [item['variation'] for item in full_data.get('subeventitemvariation_set', [])]) SubEvent.clean_variations(event, [item['variation'] for item in full_data.get('subeventitemvariation_set')])
return data return data
def validate_item_price_overrides(self, data): def validate_item_price_overrides(self, data):
@@ -304,25 +222,6 @@ class SubEventSerializer(I18nAwareModelSerializer):
def validate_variation_price_overrides(self, data): def validate_variation_price_overrides(self, data):
return list(filter(lambda i: 'variation' in i, data)) return list(filter(lambda i: 'variation' in i, data))
def validate_seating_plan(self, value):
if value and value.organizer != self.context['request'].organizer:
raise ValidationError('Invalid seating plan.')
if self.instance and self.instance.pk:
try:
validate_plan_change(self.context['request'].event, self.instance, value)
except SeatProtected as e:
raise ValidationError(str(e))
return value
def validate_seat_category_mapping(self, value):
item_cache = {i.pk: i for i in self.context['request'].event.items.all()}
result = {}
for k, item in value['seat_category_mapping'].items():
if item not in item_cache:
raise ValidationError('Item \'{id}\' does not exist.'.format(id=item))
result[k] = item_cache[item]
return {'seat_category_mapping': result}
@cached_property @cached_property
def meta_properties(self): def meta_properties(self):
return { return {
@@ -340,7 +239,6 @@ class SubEventSerializer(I18nAwareModelSerializer):
item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {} item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {}
variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {} variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {}
meta_data = validated_data.pop('meta_data', None) meta_data = validated_data.pop('meta_data', None)
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
subevent = super().create(validated_data) subevent = super().create(validated_data)
for item_price_override_data in item_price_overrides_data: for item_price_override_data in item_price_overrides_data:
@@ -356,18 +254,6 @@ class SubEventSerializer(I18nAwareModelSerializer):
value=value value=value
) )
# Seats
if subevent.seating_plan:
if seat_category_mapping is not None:
for key, value in seat_category_mapping.items():
self.context['request'].event.seat_category_mappings.create(
product=value, layout_category=key, subevent=subevent
)
generate_seats(self.context['request'].event, subevent, subevent.seating_plan, {
m.layout_category: m.product
for m in self.context['request'].event.seat_category_mappings.select_related('product').filter(subevent=subevent)
})
return subevent return subevent
@transaction.atomic @transaction.atomic
@@ -375,7 +261,6 @@ class SubEventSerializer(I18nAwareModelSerializer):
item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {} item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {}
variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {} variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {}
meta_data = validated_data.pop('meta_data', None) meta_data = validated_data.pop('meta_data', None)
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
subevent = super().update(instance, validated_data) subevent = super().update(instance, validated_data)
existing_item_overrides = {item.item: item.id for item in SubEventItem.objects.filter(subevent=subevent)} existing_item_overrides = {item.item: item.id for item in SubEventItem.objects.filter(subevent=subevent)}
@@ -412,31 +297,6 @@ class SubEventSerializer(I18nAwareModelSerializer):
if prop.name not in meta_data: if prop.name not in meta_data:
current_object.delete() current_object.delete()
# Seats
if seat_category_mapping is not None or ('seating_plan' in validated_data and validated_data['seating_plan'] is None):
current_mappings = {
m.layout_category: m
for m in self.context['request'].event.seat_category_mappings.filter(subevent=subevent)
}
if not subevent.seating_plan:
seat_category_mapping = {}
for key, value in seat_category_mapping.items():
if key in current_mappings:
m = current_mappings.pop(key)
m.product = value
m.save()
else:
self.context['request'].event.seat_category_mappings.create(
product=value, layout_category=key, subevent=subevent
)
for m in current_mappings.values():
m.delete()
if 'seating_plan' in validated_data or seat_category_mapping is not None:
generate_seats(self.context['request'].event, subevent, subevent.seating_plan, {
m.layout_category: m.product
for m in self.context['request'].event.seat_category_mappings.select_related('product').filter(subevent=subevent)
})
return subevent return subevent

View File

@@ -19,7 +19,7 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = ItemVariation model = ItemVariation
fields = ('id', 'value', 'active', 'description', fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price', 'original_price') 'position', 'default_price', 'price')
class ItemVariationSerializer(I18nAwareModelSerializer): class ItemVariationSerializer(I18nAwareModelSerializer):
@@ -29,7 +29,7 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = ItemVariation model = ItemVariation
fields = ('id', 'value', 'active', 'description', fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price', 'original_price') 'position', 'default_price', 'price')
class InlineItemBundleSerializer(serializers.ModelSerializer): class InlineItemBundleSerializer(serializers.ModelSerializer):
@@ -118,10 +118,12 @@ class ItemSerializer(I18nAwareModelSerializer):
'position', 'picture', 'available_from', 'available_until', 'position', 'picture', 'available_from', 'available_until',
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling', 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations', 'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets', 'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets')
'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard')
read_only_fields = ('has_variations', 'picture') read_only_fields = ('has_variations', 'picture')
def get_serializer_context(self):
return {"has_variations": self.kwargs['has_variations']}
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data): if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data):
@@ -131,17 +133,6 @@ class ItemSerializer(I18nAwareModelSerializer):
Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order')) Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order'))
Item.clean_available(data.get('available_from'), data.get('available_until')) Item.clean_available(data.get('available_from'), data.get('available_until'))
if data.get('issue_giftcard'):
if data.get('tax_rule') and data.get('tax_rule').rate > 0:
raise ValidationError(
_("Gift card products should not be associated with non-zero tax rates since sales tax will be "
"applied when the gift card is redeemed.")
)
if data.get('admission'):
raise ValidationError(_(
"Gift card products should not be admission products at the same time."
))
return data return data
def validate_category(self, value): def validate_category(self, value):
@@ -209,25 +200,14 @@ class InlineQuestionOptionSerializer(I18nAwareModelSerializer):
fields = ('id', 'identifier', 'answer', 'position') fields = ('id', 'identifier', 'answer', 'position')
class LegacyDependencyValueField(serializers.CharField):
def to_representation(self, obj):
return obj[0] if obj else None
def to_internal_value(self, data):
return [data] if data else []
class QuestionSerializer(I18nAwareModelSerializer): class QuestionSerializer(I18nAwareModelSerializer):
options = InlineQuestionOptionSerializer(many=True, required=False) options = InlineQuestionOptionSerializer(many=True, required=False)
identifier = serializers.CharField(allow_null=True) identifier = serializers.CharField(allow_null=True)
dependency_value = LegacyDependencyValueField(source='dependency_values', required=False, allow_null=True)
class Meta: class Meta:
model = Question model = Question
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position', fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_values', 'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_value')
'hidden', 'dependency_value', 'print_on_invoice')
def validate_identifier(self, value): def validate_identifier(self, value):
Question._clean_identifier(self.context['event'], value, self.instance) Question._clean_identifier(self.context['event'], value, self.instance)
@@ -257,9 +237,6 @@ class QuestionSerializer(I18nAwareModelSerializer):
dep = full_data.get('dependency_question') dep = full_data.get('dependency_question')
if dep: if dep:
if dep.ask_during_checkin:
raise ValidationError(_('Question cannot depend on a question asked during check-in.'))
seen_ids = {self.instance.pk} if self.instance else set() seen_ids = {self.instance.pk} if self.instance else set()
while dep: while dep:
if dep.pk in seen_ids: if dep.pk in seen_ids:
@@ -267,9 +244,6 @@ class QuestionSerializer(I18nAwareModelSerializer):
seen_ids.add(dep.pk) seen_ids.add(dep.pk)
dep = dep.dependency_question dep = dep.dependency_question
if full_data.get('ask_during_checkin') and full_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
raise ValidationError(_('This type of question cannot be asked during check-in.'))
Question.clean_items(event, full_data.get('items')) Question.clean_items(event, full_data.get('items'))
return data return data
@@ -287,7 +261,6 @@ class QuestionSerializer(I18nAwareModelSerializer):
def create(self, validated_data): def create(self, validated_data):
options_data = validated_data.pop('options') if 'options' in validated_data else [] options_data = validated_data.pop('options') if 'options' in validated_data else []
items = validated_data.pop('items') items = validated_data.pop('items')
question = Question.objects.create(**validated_data) question = Question.objects.create(**validated_data)
question.items.set(items) question.items.set(items)
for opt_data in options_data: for opt_data in options_data:
@@ -299,7 +272,7 @@ class QuotaSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = Quota model = Quota
fields = ('id', 'name', 'size', 'items', 'variations', 'subevent', 'closed', 'close_when_sold_out') fields = ('id', 'name', 'size', 'items', 'variations', 'subevent')
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)

View File

@@ -1,9 +1,7 @@
import json import json
from collections import Counter, defaultdict from collections import Counter
from decimal import Decimal from decimal import Decimal
import pycountry
from django.db.models import F, Q
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext_lazy from django.utils.translation import ugettext_lazy
from django_countries.fields import Country from django_countries.fields import Country
@@ -14,21 +12,16 @@ from rest_framework.reverse import reverse
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.channels import get_all_sales_channels from pretix.base.channels import get_all_sales_channels
from pretix.base.decimal import round_decimal
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order, Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition,
OrderPosition, Question, QuestionAnswer, Seat, SubEvent, TaxRule, Voucher, Question, QuestionAnswer,
) )
from pretix.base.models.orders import ( from pretix.base.models.orders import (
CartPosition, OrderFee, OrderPayment, OrderRefund, CartPosition, OrderFee, OrderPayment, OrderRefund,
) )
from pretix.base.pdf import get_variables from pretix.base.pdf import get_variables
from pretix.base.services.cart import error_messages
from pretix.base.services.pricing import get_price
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
from pretix.base.signals import register_ticket_outputs from pretix.base.signals import register_ticket_outputs
from pretix.multidomain.urlreverse import build_absolute_uri
class CompatibleCountryField(serializers.Field): class CompatibleCountryField(serializers.Field):
@@ -49,8 +42,8 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = InvoiceAddress model = InvoiceAddress
fields = ('last_modified', 'is_business', 'company', 'name', 'name_parts', 'street', 'zipcode', 'city', 'country', fields = ('last_modified', 'is_business', 'company', 'name', 'name_parts', 'street', 'zipcode', 'city', 'country',
'state', 'vat_id', 'vat_id_validated', 'internal_reference') 'vat_id', 'vat_id_validated', 'internal_reference')
read_only_fields = ('last_modified',) read_only_fields = ('last_modified', 'vat_id_validated')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -65,24 +58,6 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
) )
if data.get('name_parts') and '_scheme' not in data.get('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 data['name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
if data.get('country'):
if not pycountry.countries.get(alpha_2=data.get('country')):
raise ValidationError(
{'country': ['Invalid country code.']}
)
if data.get('state'):
cc = str(data.get('country') or self.instance.country or '')
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
raise ValidationError(
{'state': ['States are not supported in country "{}".'.format(cc)]}
)
if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')):
raise ValidationError(
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
)
return data return data
@@ -96,13 +71,6 @@ class AnswerQuestionOptionsIdentifierField(serializers.Field):
return [o.identifier for o in instance.options.all()] return [o.identifier for o in instance.options.all()]
class InlineSeatSerializer(I18nAwareModelSerializer):
class Meta:
model = Seat
fields = ('id', 'name', 'seat_guid')
class AnswerSerializer(I18nAwareModelSerializer): class AnswerSerializer(I18nAwareModelSerializer):
question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True) question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True)
option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True) option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True)
@@ -115,7 +83,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
class CheckinSerializer(I18nAwareModelSerializer): class CheckinSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = Checkin model = Checkin
fields = ('datetime', 'list', 'auto_checked_in') fields = ('datetime', 'list')
class OrderDownloadsField(serializers.Field): class OrderDownloadsField(serializers.Field):
@@ -198,13 +166,12 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
downloads = PositionDownloadsField(source='*') downloads = PositionDownloadsField(source='*')
order = serializers.SlugRelatedField(slug_field='code', read_only=True) order = serializers.SlugRelatedField(slug_field='code', read_only=True)
pdf_data = PdfDataSerializer(source='*') pdf_data = PdfDataSerializer(source='*')
seat = InlineSeatSerializer(read_only=True)
class Meta: class Meta:
model = OrderPosition model = OrderPosition
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat') 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -212,55 +179,6 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
self.fields.pop('pdf_data') self.fields.pop('pdf_data')
class RequireAttentionField(serializers.Field):
def to_representation(self, instance: OrderPosition):
return instance.order.checkin_attention or instance.item.checkin_attention
class AttendeeNameField(serializers.Field):
def to_representation(self, instance: OrderPosition):
an = instance.attendee_name
if not an:
if instance.addon_to_id:
an = instance.addon_to.attendee_name
if not an:
try:
an = instance.order.invoice_address.name
except InvoiceAddress.DoesNotExist:
pass
return an
class AttendeeNamePartsField(serializers.Field):
def to_representation(self, instance: OrderPosition):
an = instance.attendee_name
p = instance.attendee_name_parts
if not an:
if instance.addon_to_id:
an = instance.addon_to.attendee_name
p = instance.addon_to.attendee_name_parts
if not an:
try:
p = instance.order.invoice_address.name_parts
except InvoiceAddress.DoesNotExist:
pass
return p
class CheckinListOrderPositionSerializer(OrderPositionSerializer):
require_attention = RequireAttentionField(source='*')
attendee_name = AttendeeNameField(source='*')
attendee_name_parts = AttendeeNamePartsField(source='*')
order__status = serializers.SlugRelatedField(read_only=True, slug_field='status', source='order')
class Meta:
model = OrderPosition
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', 'require_attention',
'order__status')
class OrderPaymentTypeField(serializers.Field): class OrderPaymentTypeField(serializers.Field):
# TODO: Remove after pretix 2.2 # TODO: Remove after pretix 2.2
def to_representation(self, instance: Order): def to_representation(self, instance: Order):
@@ -287,33 +205,10 @@ class OrderFeeSerializer(I18nAwareModelSerializer):
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule') fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule')
class PaymentURLField(serializers.URLField):
def to_representation(self, instance: OrderPayment):
if instance.state != OrderPayment.PAYMENT_STATE_CREATED:
return None
return build_absolute_uri(self.context['event'], 'presale:event.order.pay', kwargs={
'order': instance.order.code,
'secret': instance.order.secret,
'payment': instance.pk,
})
class PaymentDetailsField(serializers.Field):
def to_representation(self, value: OrderPayment):
pp = value.payment_provider
if not pp:
return {}
return pp.api_payment_details(value)
class OrderPaymentSerializer(I18nAwareModelSerializer): class OrderPaymentSerializer(I18nAwareModelSerializer):
payment_url = PaymentURLField(source='*', allow_null=True, read_only=True)
details = PaymentDetailsField(source='*', allow_null=True, read_only=True)
class Meta: class Meta:
model = OrderPayment model = OrderPayment
fields = ('local_id', 'state', 'amount', 'created', 'payment_date', 'provider', 'payment_url', fields = ('local_id', 'state', 'amount', 'created', 'payment_date', 'provider')
'details')
class OrderRefundSerializer(I18nAwareModelSerializer): class OrderRefundSerializer(I18nAwareModelSerializer):
@@ -324,14 +219,6 @@ class OrderRefundSerializer(I18nAwareModelSerializer):
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'provider') fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'provider')
class OrderURLField(serializers.URLField):
def to_representation(self, instance: Order):
return build_absolute_uri(self.context['event'], 'presale:event.order', kwargs={
'order': instance.code,
'secret': instance.secret,
})
class OrderSerializer(I18nAwareModelSerializer): class OrderSerializer(I18nAwareModelSerializer):
invoice_address = InvoiceAddressSerializer(allow_null=True) invoice_address = InvoiceAddressSerializer(allow_null=True)
positions = OrderPositionSerializer(many=True, read_only=True) positions = OrderPositionSerializer(many=True, read_only=True)
@@ -341,15 +228,13 @@ class OrderSerializer(I18nAwareModelSerializer):
refunds = OrderRefundSerializer(many=True, read_only=True) refunds = OrderRefundSerializer(many=True, read_only=True)
payment_date = OrderPaymentDateField(source='*', read_only=True) payment_date = OrderPaymentDateField(source='*', read_only=True)
payment_provider = OrderPaymentTypeField(source='*', read_only=True) payment_provider = OrderPaymentTypeField(source='*', read_only=True)
url = OrderURLField(source='*', read_only=True)
class Meta: class Meta:
model = Order model = Order
fields = ( fields = (
'code', 'status', 'testmode', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date', 'code', 'status', 'testmode', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads', 'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel'
'url'
) )
read_only_fields = ( read_only_fields = (
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date', 'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
@@ -371,6 +256,7 @@ class OrderSerializer(I18nAwareModelSerializer):
# Even though all fields that shouldn't be edited are marked as read_only in the serializer # Even though all fields that shouldn't be edited are marked as read_only in the serializer
# (hopefully), we'll be extra careful here and be explicit about the model fields we update. # (hopefully), we'll be extra careful here and be explicit about the model fields we update.
update_fields = ['comment', 'checkin_attention', 'email', 'locale'] update_fields = ['comment', 'checkin_attention', 'email', 'locale']
print(validated_data)
if 'invoice_address' in validated_data: if 'invoice_address' in validated_data:
iadata = validated_data.pop('invoice_address') iadata = validated_data.pop('invoice_address')
@@ -388,7 +274,7 @@ class OrderSerializer(I18nAwareModelSerializer):
} }
try: try:
ia = instance.invoice_address ia = instance.invoice_address
if iadata.get('vat_id') != ia.vat_id and 'vat_id_validated' not in iadata: if iadata.get('vat_id') != ia.vat_id:
ia.vat_id_validated = False ia.vat_id_validated = False
self.fields['invoice_address'].update(ia, iadata) self.fields['invoice_address'].update(ia, iadata)
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
@@ -402,23 +288,6 @@ class OrderSerializer(I18nAwareModelSerializer):
return instance return instance
class PriceCalcSerializer(serializers.Serializer):
item = serializers.PrimaryKeyRelatedField(queryset=Item.objects.none(), required=False, allow_null=True)
variation = serializers.PrimaryKeyRelatedField(queryset=ItemVariation.objects.none(), required=False, allow_null=True)
subevent = serializers.PrimaryKeyRelatedField(queryset=SubEvent.objects.none(), required=False, allow_null=True)
locale = serializers.CharField(allow_null=True, required=False)
def __init__(self, *args, **kwargs):
event = kwargs.pop('event')
super().__init__(*args, **kwargs)
self.fields['item'].queryset = event.items.all()
self.fields['variation'].queryset = ItemVariation.objects.filter(item__event=event)
if event.has_subevents:
self.fields['subevent'].queryset = event.subevents.all()
else:
del self.fields['subevent']
class AnswerCreateSerializer(I18nAwareModelSerializer): class AnswerCreateSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
@@ -478,13 +347,9 @@ class AnswerCreateSerializer(I18nAwareModelSerializer):
class OrderFeeCreateSerializer(I18nAwareModelSerializer): class OrderFeeCreateSerializer(I18nAwareModelSerializer):
_treat_value_as_percentage = serializers.BooleanField(default=False, required=False)
_split_taxes_like_products = serializers.BooleanField(default=False, required=False)
class Meta: class Meta:
model = OrderFee model = OrderFee
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rule', fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rule')
'_treat_value_as_percentage', '_split_taxes_like_products')
def validate_tax_rule(self, tr): def validate_tax_rule(self, tr):
if tr and tr.event != self.context['event']: if tr and tr.event != self.context['event']:
@@ -499,16 +364,11 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
addon_to = serializers.IntegerField(required=False, allow_null=True) addon_to = serializers.IntegerField(required=False, allow_null=True)
secret = serializers.CharField(required=False) secret = serializers.CharField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True) attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
price = serializers.DecimalField(required=False, allow_null=True, decimal_places=2,
max_digits=10)
voucher = serializers.SlugRelatedField(slug_field='code', queryset=Voucher.objects.none(),
required=False, allow_null=True)
class Meta: class Meta:
model = OrderPosition model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email', fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher') 'secret', 'addon_to', 'subevent', 'answers')
def validate_secret(self, secret): def validate_secret(self, secret):
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists(): if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
@@ -582,7 +442,7 @@ class CompatibleJSONField(serializers.JSONField):
class OrderCreateSerializer(I18nAwareModelSerializer): class OrderCreateSerializer(I18nAwareModelSerializer):
invoice_address = InvoiceAddressSerializer(required=False) invoice_address = InvoiceAddressSerializer(required=False)
positions = OrderPositionCreateSerializer(many=True, required=True) positions = OrderPositionCreateSerializer(many=True, required=False)
fees = OrderFeeCreateSerializer(many=True, required=False) fees = OrderFeeCreateSerializer(many=True, required=False)
status = serializers.ChoiceField(choices=( status = serializers.ChoiceField(choices=(
('n', Order.STATUS_PENDING), ('n', Order.STATUS_PENDING),
@@ -594,26 +454,18 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
min_length=5 min_length=5
) )
comment = serializers.CharField(required=False, allow_blank=True) comment = serializers.CharField(required=False, allow_blank=True)
payment_provider = serializers.CharField(required=False, allow_null=True) payment_provider = serializers.CharField(required=True)
payment_info = CompatibleJSONField(required=False) payment_info = CompatibleJSONField(required=False)
consume_carts = serializers.ListField(child=serializers.CharField(), required=False) consume_carts = serializers.ListField(child=serializers.CharField(), required=False)
force = serializers.BooleanField(default=False, required=False) force = serializers.BooleanField(default=False, required=False)
payment_date = serializers.DateTimeField(required=False, allow_null=True) payment_date = serializers.DateTimeField(required=False, allow_null=True)
send_mail = serializers.BooleanField(default=False, required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['positions'].child.fields['voucher'].queryset = self.context['event'].vouchers.all()
class Meta: class Meta:
model = Order model = Order
fields = ('code', 'status', 'testmode', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel', fields = ('code', 'status', 'testmode', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts', 'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts', 'force')
'force', 'send_mail')
def validate_payment_provider(self, pp): def validate_payment_provider(self, pp):
if pp is None:
return None
if pp not in self.context['event'].get_payment_providers(): if pp not in self.context['event'].get_payment_providers():
raise ValidationError('The given payment provider is not known.') raise ValidationError('The given payment provider is not known.')
return pp return pp
@@ -672,36 +524,18 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
{'positionid': ["If you set addon_to on any position, you need to specify position IDs manually."]} {'positionid': ["If you set addon_to on any position, you need to specify position IDs manually."]}
for p in data for p in data
] ]
else:
for i, p in enumerate(data):
p['positionid'] = i + 1
if any(errs): if any(errs):
raise ValidationError(errs) raise ValidationError(errs)
return data return data
def validate_testmode(self, testmode):
if 'sales_channel' in self.initial_data:
try:
sales_channel = get_all_sales_channels()[self.initial_data['sales_channel']]
if testmode and not sales_channel.testmode_supported:
raise ValidationError('This sales channel does not provide support for testmode.')
except KeyError:
# We do not need to raise a ValidationError here, since there is another check to validate the
# sales_channel
pass
return testmode
def create(self, validated_data): def create(self, validated_data):
fees_data = validated_data.pop('fees') if 'fees' in validated_data else [] fees_data = validated_data.pop('fees') if 'fees' in validated_data else []
positions_data = validated_data.pop('positions') if 'positions' in validated_data else [] positions_data = validated_data.pop('positions') if 'positions' in validated_data else []
payment_provider = validated_data.pop('payment_provider', None) payment_provider = validated_data.pop('payment_provider')
payment_info = validated_data.pop('payment_info', '{}') payment_info = validated_data.pop('payment_info', '{}')
payment_date = validated_data.pop('payment_date', now()) payment_date = validated_data.pop('payment_date', now())
force = validated_data.pop('force', False) force = validated_data.pop('force', False)
self._send_mail = validated_data.pop('send_mail', False)
if 'invoice_address' in validated_data: if 'invoice_address' in validated_data:
iadata = validated_data.pop('invoice_address') iadata = validated_data.pop('invoice_address')
@@ -715,16 +549,13 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
ia = None ia = None
with self.context['event'].lock() as now_dt: with self.context['event'].lock() as now_dt:
free_seats = set() quotadiff = Counter()
seats_seen = set()
consume_carts = validated_data.pop('consume_carts', []) consume_carts = validated_data.pop('consume_carts', [])
delete_cps = [] delete_cps = []
quota_avail_cache = {} quota_avail_cache = {}
voucher_usage = Counter()
if consume_carts: if consume_carts:
for cp in CartPosition.objects.filter( for cp in CartPosition.objects.filter(event=self.context['event'], cart_id__in=consume_carts):
event=self.context['event'], cart_id__in=consume_carts, expires__gt=now()
):
quotas = (cp.variation.quotas.filter(subevent=cp.subevent) quotas = (cp.variation.quotas.filter(subevent=cp.subevent)
if cp.variation else cp.item.quotas.filter(subevent=cp.subevent)) if cp.variation else cp.item.quotas.filter(subevent=cp.subevent))
for quota in quotas: for quota in quotas:
@@ -732,64 +563,14 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
quota_avail_cache[quota] = list(quota.availability()) quota_avail_cache[quota] = list(quota.availability())
if quota_avail_cache[quota][1] is not None: if quota_avail_cache[quota][1] is not None:
quota_avail_cache[quota][1] += 1 quota_avail_cache[quota][1] += 1
if cp.voucher:
voucher_usage[cp.voucher] -= 1
if cp.expires > now_dt: if cp.expires > now_dt:
if cp.seat: quotadiff.subtract(quotas)
free_seats.add(cp.seat)
delete_cps.append(cp) delete_cps.append(cp)
errs = [{} for p in positions_data] errs = [{} for p in positions_data]
for i, pos_data in enumerate(positions_data):
if pos_data.get('voucher'):
v = pos_data['voucher']
if not v.applies_to(pos_data['item'], pos_data.get('variation')):
errs[i]['voucher'] = [error_messages['voucher_invalid_item']]
continue
if v.subevent_id and pos_data.get('subevent').pk != v.subevent_id:
errs[i]['voucher'] = [error_messages['voucher_invalid_subevent']]
continue
if v.valid_until is not None and v.valid_until < now_dt:
errs[i]['voucher'] = [error_messages['voucher_expired']]
continue
voucher_usage[v] += 1
if voucher_usage[v] > 0:
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=pos_data['voucher']) & Q(event=self.context['event']) & Q(expires__gte=now_dt)
).exclude(pk__in=[cp.pk for cp in delete_cps])
v_avail = v.max_usages - v.redeemed - redeemed_in_carts.count()
if v_avail < voucher_usage[v]:
errs[i]['voucher'] = [
'The voucher has already been used the maximum number of times.'
]
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
if pos_data.get('seat'):
if not seated:
errs[i]['seat'] = ['The specified product does not allow to choose a seat.']
try:
seat = self.context['event'].seats.get(seat_guid=pos_data['seat'], subevent=pos_data.get('subevent'))
except Seat.DoesNotExist:
errs[i]['seat'] = ['The specified seat does not exist.']
else:
pos_data['seat'] = seat
if (seat not in free_seats and not seat.is_available()) or seat in seats_seen:
errs[i]['seat'] = [ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)]
seats_seen.add(seat)
elif seated:
errs[i]['seat'] = ['The specified product requires to choose a seat.']
if not force: if not force:
for i, pos_data in enumerate(positions_data): for i, pos_data in enumerate(positions_data):
if pos_data.get('voucher'):
if pos_data['voucher'].allow_ignore_quota or pos_data['voucher'].block_quota:
continue
new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent')) new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
if pos_data.get('variation') if pos_data.get('variation')
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent'))) else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')))
@@ -811,6 +592,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
) )
] ]
quotadiff.update(new_quotas)
if any(errs): if any(errs):
raise ValidationError({'positions': errs}) raise ValidationError({'positions': errs})
@@ -818,14 +601,38 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
validated_data['locale'] = self.context['event'].settings.locale validated_data['locale'] = self.context['event'].settings.locale
order = Order(event=self.context['event'], **validated_data) order = Order(event=self.context['event'], **validated_data)
order.set_expires(subevents=[p.get('subevent') for p in positions_data]) order.set_expires(subevents=[p.get('subevent') for p in positions_data])
order.total = sum([p['price'] for p in positions_data]) + sum([f['value'] for f in fees_data], Decimal('0.00'))
order.meta_info = "{}" order.meta_info = "{}"
order.total = Decimal('0.00')
order.save() order.save()
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
order.status = Order.STATUS_PAID
order.save()
order.payments.create(
amount=order.total, provider='free', state=OrderPayment.PAYMENT_STATE_CONFIRMED,
payment_date=now()
)
elif payment_provider == "free" and order.total != Decimal('0.00'):
raise ValidationError('You cannot use the "free" payment provider for non-free orders.')
elif validated_data.get('status') == Order.STATUS_PAID:
order.payments.create(
amount=order.total,
provider=payment_provider,
info=payment_info,
payment_date=payment_date,
state=OrderPayment.PAYMENT_STATE_CONFIRMED
)
elif payment_provider:
order.payments.create(
amount=order.total,
provider=payment_provider,
info=payment_info,
state=OrderPayment.PAYMENT_STATE_CREATED
)
if ia: if ia:
ia.order = order ia.order = order
ia.save() ia.save()
pos_map = {} pos_map = {}
for pos_data in positions_data: for pos_data in positions_data:
answers_data = pos_data.pop('answers', []) answers_data = pos_data.pop('answers', [])
@@ -837,27 +644,9 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
} }
pos = OrderPosition(**pos_data) pos = OrderPosition(**pos_data)
pos.order = order pos.order = order
pos._calculate_tax()
if addon_to: if addon_to:
pos.addon_to = pos_map[addon_to] pos.addon_to = pos_map[addon_to]
if pos.price is None:
price = get_price(
item=pos.item,
variation=pos.variation,
voucher=pos.voucher,
custom_price=None,
subevent=pos.subevent,
addon_to=pos.addon_to,
invoice_address=ia,
)
pos.price = price.gross
pos.tax_rate = price.rate
pos.tax_value = price.tax
pos.tax_rule = pos.item.tax_rule
else:
pos._calculate_tax()
if pos.voucher:
Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1)
pos.save() pos.save()
pos_map[pos.positionid] = pos pos_map[pos.positionid] = pos
for answ_data in answers_data: for answ_data in answers_data:
@@ -867,103 +656,19 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
for cp in delete_cps: for cp in delete_cps:
cp.delete() cp.delete()
order.total = sum([p.price for p in order.positions.all()])
for fee_data in fees_data: for fee_data in fees_data:
is_percentage = fee_data.pop('_treat_value_as_percentage', False) f = OrderFee(**fee_data)
if is_percentage: f.order = order
fee_data['value'] = round_decimal(order.total * (fee_data['value'] / Decimal('100.00')), f._calculate_tax()
self.context['event'].currency) f.save()
is_split_taxes = fee_data.pop('_split_taxes_like_products', False)
if is_split_taxes:
d = defaultdict(lambda: Decimal('0.00'))
trz = TaxRule.zero()
for p in pos_map.values():
tr = p.tax_rule
d[tr] += p.price - p.tax_value
base_values = sorted([tuple(t) for t in d.items()], key=lambda t: (t[0] or trz).rate)
sum_base = sum(t[1] for t in base_values)
fee_values = [(t[0], round_decimal(fee_data['value'] * t[1] / sum_base, self.context['event'].currency))
for t in base_values]
sum_fee = sum(t[1] for t in fee_values)
# If there are rounding differences, we fix them up, but always leaning to the benefit of the tax
# authorities
if sum_fee > fee_data['value']:
fee_values[0] = (fee_values[0][0], fee_values[0][1] + (fee_data['value'] - sum_fee))
elif sum_fee < fee_data['value']:
fee_values[-1] = (fee_values[-1][0], fee_values[-1][1] + (fee_data['value'] - sum_fee))
for tr, val in fee_values:
fee_data['tax_rule'] = tr
fee_data['value'] = val
f = OrderFee(**fee_data)
f.order = order
f._calculate_tax()
f.save()
else:
f = OrderFee(**fee_data)
f.order = order
f._calculate_tax()
f.save()
order.total += sum([f.value for f in order.fees.all()])
order.save(update_fields=['total'])
if order.total == Decimal('0.00') and validated_data.get('status') == Order.STATUS_PAID and not payment_provider:
payment_provider = 'free'
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
order.status = Order.STATUS_PAID
order.save()
order.payments.create(
amount=order.total, provider='free', state=OrderPayment.PAYMENT_STATE_CONFIRMED,
payment_date=now()
)
elif payment_provider == "free" and order.total != Decimal('0.00'):
raise ValidationError('You cannot use the "free" payment provider for non-free orders.')
elif validated_data.get('status') == Order.STATUS_PAID:
if not payment_provider:
raise ValidationError('You cannot create a paid order without a payment provider.')
order.payments.create(
amount=order.total,
provider=payment_provider,
info=payment_info,
payment_date=payment_date,
state=OrderPayment.PAYMENT_STATE_CONFIRMED
)
elif payment_provider:
order.payments.create(
amount=order.total,
provider=payment_provider,
info=payment_info,
state=OrderPayment.PAYMENT_STATE_CREATED
)
return order return order
class LinePositionField(serializers.IntegerField):
"""
Internally, the position field is stored starting at 0, but for the API, starting at 1 makes it
more consistent with other models
"""
def to_representation(self, value):
return super().to_representation(value) + 1
def to_internal_value(self, data):
return super().to_internal_value(data) - 1
class InlineInvoiceLineSerializer(I18nAwareModelSerializer): class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
position = LinePositionField(read_only=True)
class Meta: class Meta:
model = InvoiceLine model = InvoiceLine
fields = ('position', 'description', 'gross_value', 'tax_value', 'tax_rate', 'tax_name') fields = ('description', 'gross_value', 'tax_value', 'tax_rate', 'tax_name')
class InvoiceSerializer(I18nAwareModelSerializer): class InvoiceSerializer(I18nAwareModelSerializer):

View File

@@ -1,49 +1,8 @@
from django.db.models import Q
from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import CompatibleJSONField from pretix.base.models import Organizer
from pretix.base.models import GiftCard, Organizer, SeatingPlan
from pretix.base.models.seating import SeatingPlanLayoutValidator
class OrganizerSerializer(I18nAwareModelSerializer): class OrganizerSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = Organizer model = Organizer
fields = ('name', 'slug') fields = ('name', 'slug')
class SeatingPlanSerializer(I18nAwareModelSerializer):
layout = CompatibleJSONField(
validators=[SeatingPlanLayoutValidator()]
)
class Meta:
model = SeatingPlan
fields = ('id', 'name', 'layout')
class GiftCardSerializer(I18nAwareModelSerializer):
value = serializers.DecimalField(max_digits=10, decimal_places=2)
def validate(self, data):
data = super().validate(data)
s = data['secret']
qs = GiftCard.objects.filter(
secret=s
).filter(
Q(issuer=self.context["organizer"]) | Q(issuer__gift_card_collector_acceptance__collector=self.context["organizer"])
)
if self.instance:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise ValidationError(
{'secret': _('A gift card with the same secret already exists in your or an affiliated organizer account.')}
)
return data
class Meta:
model = GiftCard
fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode')

View File

@@ -2,23 +2,15 @@ from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Seat, Voucher from pretix.base.models import Voucher
class VoucherListSerializer(serializers.ListSerializer): class VoucherListSerializer(serializers.ListSerializer):
def create(self, validated_data): def create(self, validated_data):
codes = set() codes = set()
seats = set()
errs = [] errs = []
err = False err = False
for voucher_data in validated_data: for voucher_data in validated_data:
if voucher_data.get('seat') and (voucher_data.get('seat'), voucher_data.get('subevent')) in seats:
err = True
errs.append({'code': ['Duplicate seat ID in request.']})
continue
else:
seats.add((voucher_data.get('seat'), voucher_data.get('subevent')))
if voucher_data['code'] in codes: if voucher_data['code'] in codes:
err = True err = True
errs.append({'code': ['Duplicate voucher code in request.']}) errs.append({'code': ['Duplicate voucher code in request.']})
@@ -30,19 +22,12 @@ class VoucherListSerializer(serializers.ListSerializer):
return super().create(validated_data) return super().create(validated_data)
class SeatGuidField(serializers.CharField):
def to_representation(self, val: Seat):
return val.seat_guid
class VoucherSerializer(I18nAwareModelSerializer): class VoucherSerializer(I18nAwareModelSerializer):
seat = SeatGuidField(allow_null=True, required=False)
class Meta: class Meta:
model = Voucher model = Voucher
fields = ('id', 'code', 'max_usages', 'redeemed', 'valid_until', 'block_quota', fields = ('id', 'code', 'max_usages', 'redeemed', 'valid_until', 'block_quota',
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota', 'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
'tag', 'comment', 'subevent', 'show_hidden_items', 'seat') 'tag', 'comment', 'subevent')
read_only_fields = ('id', 'redeemed') read_only_fields = ('id', 'redeemed')
list_serializer_class = VoucherListSerializer list_serializer_class = VoucherListSerializer
@@ -54,8 +39,7 @@ class VoucherSerializer(I18nAwareModelSerializer):
Voucher.clean_item_properties( Voucher.clean_item_properties(
full_data, self.context.get('event'), full_data, self.context.get('event'),
full_data.get('quota'), full_data.get('item'), full_data.get('variation'), full_data.get('quota'), full_data.get('item'), full_data.get('variation')
block_quota=full_data.get('block_quota')
) )
Voucher.clean_subevent( Voucher.clean_subevent(
full_data, self.context.get('event') full_data, self.context.get('event')
@@ -77,10 +61,4 @@ class VoucherSerializer(I18nAwareModelSerializer):
) )
Voucher.clean_voucher_code(full_data, self.context.get('event'), self.instance.pk if self.instance else None) Voucher.clean_voucher_code(full_data, self.context.get('event'), self.instance.pk if self.instance else None)
if full_data.get('seat'):
data['seat'] = Voucher.clean_seat_id(
full_data, full_data.get('item'), full_data.get('quota'), self.context.get('event'),
self.instance.pk if self.instance else None
)
return data return data

View File

@@ -2,7 +2,6 @@ from datetime import timedelta
from django.dispatch import Signal, receiver from django.dispatch import Signal, receiver
from django.utils.timezone import now from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.api.models import ApiCall, WebHookCall from pretix.api.models import ApiCall, WebHookCall
from pretix.base.signals import periodic_task from pretix.base.signals import periodic_task
@@ -18,12 +17,10 @@ instances.
@receiver(periodic_task) @receiver(periodic_task)
@scopes_disabled()
def cleanup_webhook_logs(sender, **kwargs): def cleanup_webhook_logs(sender, **kwargs):
WebHookCall.objects.filter(datetime__lte=now() - timedelta(days=30)).delete() WebHookCall.objects.filter(datetime__lte=now() - timedelta(days=30)).delete()
@receiver(periodic_task) @receiver(periodic_task)
@scopes_disabled()
def cleanup_api_logs(sender, **kwargs): def cleanup_api_logs(sender, **kwargs):
ApiCall.objects.filter(created__lte=now() - timedelta(hours=24)).delete() ApiCall.objects.filter(datetime__lte=now() - timedelta(hours=24)).delete()

View File

@@ -18,8 +18,6 @@ orga_router = routers.DefaultRouter()
orga_router.register(r'events', event.EventViewSet) orga_router.register(r'events', event.EventViewSet)
orga_router.register(r'subevents', event.SubEventViewSet) orga_router.register(r'subevents', event.SubEventViewSet)
orga_router.register(r'webhooks', webhooks.WebHookViewSet) orga_router.register(r'webhooks', webhooks.WebHookViewSet)
orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
orga_router.register(r'giftcards', organizer.GiftCardViewSet)
event_router = routers.DefaultRouter() event_router = routers.DefaultRouter()
event_router.register(r'subevents', event.SubEventViewSet) event_router.register(r'subevents', event.SubEventViewSet)

View File

@@ -24,7 +24,7 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
return CartPosition.objects.filter( return CartPosition.objects.filter(
event=self.request.event, event=self.request.event,
cart_id__endswith="@api" cart_id__endswith="@api"
).select_related('seat').prefetch_related('answers') )
def get_serializer_context(self): def get_serializer_context(self):
ctx = super().get_serializer_context() ctx = super().get_serializer_context()

View File

@@ -6,7 +6,6 @@ from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import now from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.fields import DateTimeField from rest_framework.fields import DateTimeField
@@ -14,7 +13,7 @@ from rest_framework.response import Response
from pretix.api.serializers.checkin import CheckinListSerializer from pretix.api.serializers.checkin import CheckinListSerializer
from pretix.api.serializers.item import QuestionSerializer from pretix.api.serializers.item import QuestionSerializer
from pretix.api.serializers.order import CheckinListOrderPositionSerializer from pretix.api.serializers.order import OrderPositionSerializer
from pretix.api.views import RichOrderingFilter from pretix.api.views import RichOrderingFilter
from pretix.api.views.order import OrderPositionFilter from pretix.api.views.order import OrderPositionFilter
from pretix.base.models import ( from pretix.base.models import (
@@ -25,11 +24,11 @@ from pretix.base.services.checkin import (
) )
from pretix.helpers.database import FixedOrderBy from pretix.helpers.database import FixedOrderBy
with scopes_disabled():
class CheckinListFilter(FilterSet): class CheckinListFilter(FilterSet):
class Meta: class Meta:
model = CheckinList model = CheckinList
fields = ['subevent'] fields = ['subevent']
class CheckinListViewSet(viewsets.ModelViewSet): class CheckinListViewSet(viewsets.ModelViewSet):
@@ -44,6 +43,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
qs = self.request.event.checkin_lists.prefetch_related( qs = self.request.event.checkin_lists.prefetch_related(
'limit_products', 'limit_products',
) )
qs = CheckinList.annotate_with_numbers(qs, self.request.event)
return qs return qs
def perform_create(self, serializer): def perform_create(self, serializer):
@@ -92,7 +92,6 @@ class CheckinListViewSet(viewsets.ModelViewSet):
) )
if not clist.all_products: if not clist.all_products:
pqs = pqs.filter(item__in=clist.limit_products.values_list('id', flat=True)) pqs = pqs.filter(item__in=clist.limit_products.values_list('id', flat=True))
cqs = cqs.filter(position__item__in=clist.limit_products.values_list('id', flat=True))
ev = clist.subevent or clist.event ev = clist.subevent or clist.event
response = { response = {
@@ -147,16 +146,15 @@ class CheckinListViewSet(viewsets.ModelViewSet):
return Response(response) return Response(response)
with scopes_disabled(): class CheckinOrderPositionFilter(OrderPositionFilter):
class CheckinOrderPositionFilter(OrderPositionFilter):
def has_checkin_qs(self, queryset, name, value): def has_checkin_qs(self, queryset, name, value):
return queryset.filter(last_checked_in__isnull=not value) return queryset.filter(last_checked_in__isnull=not value)
class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet): class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = CheckinListOrderPositionSerializer serializer_class = OrderPositionSerializer
queryset = OrderPosition.all.none() queryset = OrderPosition.objects.none()
filter_backends = (DjangoFilterBackend, RichOrderingFilter) filter_backends = (DjangoFilterBackend, RichOrderingFilter)
ordering = ('attendee_name_cached', 'positionid') ordering = ('attendee_name_cached', 'positionid')
ordering_fields = ( ordering_fields = (
@@ -191,7 +189,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
except ValueError: except ValueError:
raise Http404() raise Http404()
def get_queryset(self, ignore_status=False): def get_queryset(self):
cqs = Checkin.objects.filter( cqs = Checkin.objects.filter(
position_id=OuterRef('pk'), position_id=OuterRef('pk'),
list_id=self.checkinlist.pk list_id=self.checkinlist.pk
@@ -201,15 +199,11 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
qs = OrderPosition.objects.filter( qs = OrderPosition.objects.filter(
order__event=self.request.event, order__event=self.request.event,
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.checkinlist.include_pending else [Order.STATUS_PAID],
subevent=self.checkinlist.subevent subevent=self.checkinlist.subevent
).annotate( ).annotate(
last_checked_in=Subquery(cqs) last_checked_in=Subquery(cqs)
) )
if self.request.query_params.get('ignore_status', 'false') != 'true' and not ignore_status:
qs = qs.filter(
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.checkinlist.include_pending else [Order.STATUS_PAID]
)
if self.request.query_params.get('pdf_data', 'false') == 'true': if self.request.query_params.get('pdf_data', 'false') == 'true':
qs = qs.prefetch_related( qs = qs.prefetch_related(
Prefetch( Prefetch(
@@ -231,7 +225,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
) )
)) ))
).select_related( ).select_related(
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address', 'seat' 'item', 'variation', 'item__category', 'addon_to'
) )
else: else:
qs = qs.prefetch_related( qs = qs.prefetch_related(
@@ -241,7 +235,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
), ),
'answers', 'answers__options', 'answers__question', 'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')) Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat') ).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address')
if not self.checkinlist.all_products: if not self.checkinlist.all_products:
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True)) qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
@@ -253,7 +247,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
force = bool(self.request.data.get('force', False)) force = bool(self.request.data.get('force', False))
ignore_unpaid = bool(self.request.data.get('ignore_unpaid', False)) ignore_unpaid = bool(self.request.data.get('ignore_unpaid', False))
nonce = self.request.data.get('nonce') nonce = self.request.data.get('nonce')
op = self.get_object(ignore_status=True) op = self.get_object()
if 'datetime' in self.request.data: if 'datetime' in self.request.data:
dt = DateTimeField().to_internal_value(self.request.data.get('datetime')) dt = DateTimeField().to_internal_value(self.request.data.get('datetime'))
@@ -280,7 +274,6 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
nonce=nonce, nonce=nonce,
datetime=dt, datetime=dt,
questions_supported=self.request.data.get('questions_supported', True), questions_supported=self.request.data.get('questions_supported', True),
canceled_supported=self.request.data.get('canceled_supported', False),
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
) )
@@ -288,7 +281,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
return Response({ return Response({
'status': 'incomplete', 'status': 'incomplete',
'require_attention': op.item.checkin_attention or op.order.checkin_attention, 'require_attention': op.item.checkin_attention or op.order.checkin_attention,
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data, 'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data,
'questions': [ 'questions': [
QuestionSerializer(q).data for q in e.questions QuestionSerializer(q).data for q in e.questions
] ]
@@ -298,17 +291,17 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
'status': 'error', 'status': 'error',
'reason': e.code, 'reason': e.code,
'require_attention': op.item.checkin_attention or op.order.checkin_attention, 'require_attention': op.item.checkin_attention or op.order.checkin_attention,
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data 'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data
}, status=400) }, status=400)
else: else:
return Response({ return Response({
'status': 'ok', 'status': 'ok',
'require_attention': op.item.checkin_attention or op.order.checkin_attention, 'require_attention': op.item.checkin_attention or op.order.checkin_attention,
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data 'position': OrderPositionSerializer(op, context=self.get_serializer_context()).data
}, status=201) }, status=201)
def get_object(self, ignore_status=False): def get_object(self):
queryset = self.filter_queryset(self.get_queryset(ignore_status=ignore_status)) queryset = self.filter_queryset(self.get_queryset())
if self.kwargs['pk'].isnumeric(): if self.kwargs['pk'].isnumeric():
obj = get_object_or_404(queryset, Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk'])) obj = get_object_or_404(queryset, Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
else: else:

View File

@@ -3,7 +3,6 @@ from django.db import transaction
from django.db.models import ProtectedError, Q from django.db.models import ProtectedError, Q
from django.utils.timezone import now from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import filters, viewsets from rest_framework import filters, viewsets
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
@@ -18,53 +17,52 @@ from pretix.base.models import (
) )
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.helpers.dicts import merge_dicts from pretix.helpers.dicts import merge_dicts
from pretix.presale.views.organizer import filter_qs_by_attr
with scopes_disabled():
class EventFilter(FilterSet):
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
class Meta: class EventFilter(FilterSet):
model = Event is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
fields = ['is_public', 'live', 'has_subevents'] is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
def ends_after_qs(self, queryset, name, value): class Meta:
expr = ( model = Event
Q(has_subevents=False) & fields = ['is_public', 'live', 'has_subevents']
Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=value)) def ends_after_qs(self, queryset, name, value):
| Q(Q(date_to__isnull=False) & Q(date_to__gte=value)) expr = (
) Q(has_subevents=False) &
Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=value))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=value))
) )
)
return queryset.filter(expr)
def is_past_qs(self, queryset, name, value):
expr = (
Q(has_subevents=False) &
Q(
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
)
)
if value:
return queryset.filter(expr) return queryset.filter(expr)
else:
return queryset.exclude(expr)
def is_past_qs(self, queryset, name, value): def is_future_qs(self, queryset, name, value):
expr = ( expr = (
Q(has_subevents=False) & Q(has_subevents=False) &
Q( Q(
Q(Q(date_to__isnull=True) & Q(date_from__lt=now())) Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now())) | Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
)
) )
if value: )
return queryset.filter(expr) if value:
else: return queryset.filter(expr)
return queryset.exclude(expr) else:
return queryset.exclude(expr)
def is_future_qs(self, queryset, name, value):
expr = (
Q(has_subevents=False) &
Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
)
)
if value:
return queryset.filter(expr)
else:
return queryset.exclude(expr)
class EventViewSet(viewsets.ModelViewSet): class EventViewSet(viewsets.ModelViewSet):
@@ -74,8 +72,6 @@ class EventViewSet(viewsets.ModelViewSet):
lookup_url_kwarg = 'event' lookup_url_kwarg = 'event'
permission_classes = (EventCRUDPermission,) permission_classes = (EventCRUDPermission,)
filter_backends = (DjangoFilterBackend, filters.OrderingFilter) filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
ordering = ('slug',)
ordering_fields = ('date_from', 'slug')
filterset_class = EventFilter filterset_class = EventFilter
def get_queryset(self): def get_queryset(self):
@@ -86,10 +82,8 @@ class EventViewSet(viewsets.ModelViewSet):
organizer=self.request.organizer organizer=self.request.organizer
) )
qs = filter_qs_by_attr(qs, self.request)
return qs.prefetch_related( return qs.prefetch_related(
'meta_values', 'meta_values__property', 'seat_category_mappings' 'meta_values', 'meta_values__property'
) )
def perform_update(self, serializer): def perform_update(self, serializer):
@@ -186,42 +180,41 @@ class CloneEventViewSet(viewsets.ModelViewSet):
) )
with scopes_disabled(): class SubEventFilter(FilterSet):
class SubEventFilter(FilterSet): is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs') is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs') ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
class Meta: class Meta:
model = SubEvent model = SubEvent
fields = ['active', 'event__live'] fields = ['active', 'event__live']
def ends_after_qs(self, queryset, name, value): def ends_after_qs(self, queryset, name, value):
expr = Q( expr = Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=value)) Q(Q(date_to__isnull=True) & Q(date_from__gte=value))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=value)) | Q(Q(date_to__isnull=False) & Q(date_to__gte=value))
) )
return queryset.filter(expr)
def is_past_qs(self, queryset, name, value):
expr = Q(
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
)
if value:
return queryset.filter(expr) return queryset.filter(expr)
else:
return queryset.exclude(expr)
def is_past_qs(self, queryset, name, value): def is_future_qs(self, queryset, name, value):
expr = Q( expr = Q(
Q(Q(date_to__isnull=True) & Q(date_from__lt=now())) Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now())) | Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
) )
if value: if value:
return queryset.filter(expr) return queryset.filter(expr)
else: else:
return queryset.exclude(expr) return queryset.exclude(expr)
def is_future_qs(self, queryset, name, value):
expr = Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
)
if value:
return queryset.filter(expr)
else:
return queryset.exclude(expr)
class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet): class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
@@ -244,22 +237,13 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
event__organizer=self.request.organizer, event__organizer=self.request.organizer,
event__in=self.request.user.get_events_with_any_permission() event__in=self.request.user.get_events_with_any_permission()
) )
qs = filter_qs_by_attr(qs, self.request)
return qs.prefetch_related( return qs.prefetch_related(
'subeventitem_set', 'subeventitemvariation_set', 'seat_category_mappings' 'subeventitem_set', 'subeventitemvariation_set'
) )
def perform_update(self, serializer): def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data
super().perform_update(serializer) super().perform_update(serializer)
if serializer.data == original_data:
# Performance optimization: If nothing was changed, we do not need to save or log anything.
# This costs us a few cycles on save, but avoids thousands of lines in our log.
return
serializer.instance.log_action( serializer.instance.log_action(
'pretix.subevent.changed', 'pretix.subevent.changed',
user=self.request.user, user=self.request.user,

View File

@@ -3,7 +3,6 @@ from django.db.models import Q
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
@@ -22,19 +21,19 @@ from pretix.base.models import (
) )
from pretix.helpers.dicts import merge_dicts from pretix.helpers.dicts import merge_dicts
with scopes_disabled():
class ItemFilter(FilterSet):
tax_rate = django_filters.CharFilter(method='tax_rate_qs')
def tax_rate_qs(self, queryset, name, value): class ItemFilter(FilterSet):
if value in ("0", "None", "0.00"): tax_rate = django_filters.CharFilter(method='tax_rate_qs')
return queryset.filter(Q(tax_rule__isnull=True) | Q(tax_rule__rate=0))
else:
return queryset.filter(tax_rule__rate=value)
class Meta: def tax_rate_qs(self, queryset, name, value):
model = Item if value in ("0", "None", "0.00"):
fields = ['active', 'category', 'admission', 'tax_rate', 'free_price'] return queryset.filter(Q(tax_rule__isnull=True) | Q(tax_rule__rate=0))
else:
return queryset.filter(tax_rule__rate=value)
class Meta:
model = Item
fields = ['active', 'category', 'admission', 'tax_rate', 'free_price']
class ItemViewSet(ConditionalListView, viewsets.ModelViewSet): class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
@@ -62,17 +61,11 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
def get_serializer_context(self): def get_serializer_context(self):
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
ctx['event'] = self.request.event ctx['event'] = self.request.event
ctx['has_variations'] = self.request.data.get('has_variations')
return ctx return ctx
def perform_update(self, serializer): def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
if serializer.data == original_data:
# Performance optimization: If nothing was changed, we do not need to save or log anything.
# This costs us a few cycles on save, but avoids thousands of lines in our log.
return
serializer.instance.log_action( serializer.instance.log_action(
'pretix.event.item.changed', 'pretix.event.item.changed',
user=self.request.user, user=self.request.user,
@@ -319,11 +312,10 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
super().perform_destroy(instance) super().perform_destroy(instance)
with scopes_disabled(): class QuestionFilter(FilterSet):
class QuestionFilter(FilterSet): class Meta:
class Meta: model = Question
model = Question fields = ['ask_during_checkin', 'required', 'identifier']
fields = ['ask_during_checkin', 'required', 'identifier']
class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet): class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
@@ -419,11 +411,10 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
super().perform_destroy(instance) super().perform_destroy(instance)
with scopes_disabled(): class QuotaFilter(FilterSet):
class QuotaFilter(FilterSet): class Meta:
class Meta: model = Quota
model = Quota fields = ['subevent']
fields = ['subevent']
class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet): class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
@@ -461,30 +452,9 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
return ctx return ctx
def perform_update(self, serializer): def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data
current_subevent = serializer.instance.subevent current_subevent = serializer.instance.subevent
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
request_subevent = serializer.instance.subevent request_subevent = serializer.instance.subevent
if serializer.data == original_data:
# Performance optimization: If nothing was changed, we do not need to save or log anything.
# This costs us a few cycles on save, but avoids thousands of lines in our log.
return
if original_data['closed'] is True and serializer.instance.closed is False:
serializer.instance.log_action(
'pretix.event.quota.opened',
user=self.request.user,
auth=self.request.auth,
)
elif original_data['closed'] is False and serializer.instance.closed is True:
serializer.instance.log_action(
'pretix.event.quota.closed',
user=self.request.user,
auth=self.request.auth,
)
serializer.instance.log_action( serializer.instance.log_action(
'pretix.event.quota.changed', 'pretix.event.quota.changed',
user=self.request.user, user=self.request.user,

View File

@@ -6,12 +6,11 @@ import pytz
from django.db import transaction from django.db import transaction
from django.db.models import F, Prefetch, Q from django.db.models import F, Prefetch, Q
from django.db.models.functions import Coalesce, Concat from django.db.models.functions import Coalesce, Concat
from django.http import FileResponse, HttpResponse from django.http import FileResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import mixins, serializers, status, viewsets from rest_framework import mixins, serializers, status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import ( from rest_framework.exceptions import (
@@ -25,44 +24,39 @@ from pretix.api.models import OAuthAccessToken
from pretix.api.serializers.order import ( from pretix.api.serializers.order import (
InvoiceSerializer, OrderCreateSerializer, OrderPaymentSerializer, InvoiceSerializer, OrderCreateSerializer, OrderPaymentSerializer,
OrderPositionSerializer, OrderRefundCreateSerializer, OrderPositionSerializer, OrderRefundCreateSerializer,
OrderRefundSerializer, OrderSerializer, PriceCalcSerializer, OrderRefundSerializer, OrderSerializer,
) )
from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, InvoiceAddress, CachedCombinedTicket, CachedTicket, Device, Event, Invoice, Order,
Order, OrderPayment, OrderPosition, OrderRefund, Quota, TeamAPIToken, OrderPayment, OrderPosition, OrderRefund, Quota, TeamAPIToken,
generate_position_secret, generate_secret, generate_position_secret, generate_secret,
) )
from pretix.base.payment import PaymentException from pretix.base.payment import PaymentException
from pretix.base.services import tickets
from pretix.base.services.invoices import ( from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified, generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
regenerate_invoice, regenerate_invoice,
) )
from pretix.base.services.mail import SendMailException from pretix.base.services.mail import SendMailException
from pretix.base.services.orders import ( from pretix.base.services.orders import (
OrderChangeManager, OrderError, _order_placed_email, OrderChangeManager, OrderError, approve_order, cancel_order, deny_order,
_order_placed_email_attendee, approve_order, cancel_order, deny_order,
extend_order, mark_order_expired, mark_order_refunded, extend_order, mark_order_expired, mark_order_refunded,
) )
from pretix.base.services.pricing import get_price
from pretix.base.services.tickets import generate from pretix.base.services.tickets import generate
from pretix.base.signals import ( from pretix.base.signals import (
order_modified, order_paid, order_placed, register_ticket_outputs, order_modified, order_placed, register_ticket_outputs,
) )
from pretix.base.templatetags.money import money_filter
with scopes_disabled():
class OrderFilter(FilterSet):
email = django_filters.CharFilter(field_name='email', lookup_expr='iexact')
code = django_filters.CharFilter(field_name='code', lookup_expr='iexact')
status = django_filters.CharFilter(field_name='status', lookup_expr='iexact')
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
created_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
class Meta: class OrderFilter(FilterSet):
model = Order email = django_filters.CharFilter(field_name='email', lookup_expr='iexact')
fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval'] code = django_filters.CharFilter(field_name='code', lookup_expr='iexact')
status = django_filters.CharFilter(field_name='status', lookup_expr='iexact')
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
created_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
class Meta:
model = Order
fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval']
class OrderViewSet(viewsets.ModelViewSet): class OrderViewSet(viewsets.ModelViewSet):
@@ -94,8 +88,8 @@ class OrderViewSet(viewsets.ModelViewSet):
'positions', 'positions',
OrderPosition.objects.all().prefetch_related( OrderPosition.objects.all().prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question', 'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
'item__category', 'addon_to', 'seat', 'item__category', 'addon_to',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation', 'seat')) Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
) )
) )
) )
@@ -104,7 +98,7 @@ class OrderViewSet(viewsets.ModelViewSet):
Prefetch( Prefetch(
'positions', 'positions',
OrderPosition.objects.all().prefetch_related( OrderPosition.objects.all().prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question', 'seat', 'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
) )
) )
) )
@@ -148,16 +142,12 @@ class OrderViewSet(viewsets.ModelViewSet):
generate.apply_async(args=('order', order.pk, provider.identifier)) generate.apply_async(args=('order', order.pk, provider.identifier))
raise RetryException() raise RetryException()
else: else:
if ct.type == 'text/uri-list': resp = FileResponse(ct.file.file, content_type=ct.type)
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list') resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
return resp self.request.event.slug.upper(), order.code,
else: provider.identifier, ct.extension
resp = FileResponse(ct.file.file, content_type=ct.type) )
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format( return resp
self.request.event.slug.upper(), order.code,
provider.identifier, ct.extension
)
return resp
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def mark_paid(self, request, **kwargs): def mark_paid(self, request, **kwargs):
@@ -173,26 +163,9 @@ class OrderViewSet(viewsets.ModelViewSet):
amount=ps amount=ps
) )
except OrderPayment.DoesNotExist: except OrderPayment.DoesNotExist:
for p in order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_PENDING, order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_PENDING,
OrderPayment.PAYMENT_STATE_CREATED)): OrderPayment.PAYMENT_STATE_CREATED)) \
try: .update(state=OrderPayment.PAYMENT_STATE_CANCELED)
with transaction.atomic():
p.payment_provider.cancel_payment(p)
order.log_action('pretix.event.order.payment.canceled', {
'local_id': p.local_id,
'provider': p.provider,
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
except PaymentException as e:
order.log_action(
'pretix.event.order.payment.canceled.failed',
{
'local_id': p.local_id,
'provider': p.provider,
'error': str(e)
},
user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth
)
p = order.payments.create( p = order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED, state=OrderPayment.PAYMENT_STATE_CREATED,
provider='manual', provider='manual',
@@ -397,8 +370,6 @@ class OrderViewSet(viewsets.ModelViewSet):
order.save(update_fields=['secret']) order.save(update_fields=['secret'])
CachedTicket.objects.filter(order_position__order=order).delete() CachedTicket.objects.filter(order_position__order=order).delete()
CachedCombinedTicket.objects.filter(order=order).delete() CachedCombinedTicket.objects.filter(order=order).delete()
tickets.invalidate_cache.apply_async(kwargs={'event': self.request.event.pk,
'order': order.pk})
order.log_action( order.log_action(
'pretix.event.order.secret.changed', 'pretix.event.order.secret.changed',
user=self.request.user, user=self.request.user,
@@ -453,7 +424,6 @@ class OrderViewSet(viewsets.ModelViewSet):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
with transaction.atomic(): with transaction.atomic():
self.perform_create(serializer) self.perform_create(serializer)
send_mail = serializer._send_mail
order = serializer.instance order = serializer.instance
serializer = OrderSerializer(order, context=serializer.context) serializer = OrderSerializer(order, context=serializer.context)
@@ -462,52 +432,14 @@ class OrderViewSet(viewsets.ModelViewSet):
user=request.user if request.user.is_authenticated else None, user=request.user if request.user.is_authenticated else None,
auth=request.auth, auth=request.auth,
) )
order_placed.send(self.request.event, order=order)
with language(order.locale): gen_invoice = invoice_qualified(order) and (
order_placed.send(self.request.event, order=order) (order.event.settings.get('invoice_generate') == 'True') or
if order.status == Order.STATUS_PAID: (order.event.settings.get('invoice_generate') == 'paid' and order.status == Order.STATUS_PAID)
order_paid.send(self.request.event, order=order) ) and not order.invoices.last()
if gen_invoice:
gen_invoice = invoice_qualified(order) and ( generate_invoice(order, trigger_pdf=True)
(order.event.settings.get('invoice_generate') == 'True') or
(order.event.settings.get('invoice_generate') == 'paid' and order.status == Order.STATUS_PAID)
) and not order.invoices.last()
invoice = None
if gen_invoice:
invoice = generate_invoice(order, trigger_pdf=True)
if send_mail:
payment = order.payments.last()
free_flow = (
payment and order.total == Decimal('0.00') and order.status == Order.STATUS_PAID and
not order.require_approval and payment.provider == "free"
)
if free_flow:
email_template = request.event.settings.mail_text_order_free
log_entry = 'pretix.event.order.email.order_free'
email_attendees = request.event.settings.mail_send_order_free_attendee
email_attendees_template = request.event.settings.mail_text_order_free_attendee
else:
email_template = request.event.settings.mail_text_order_placed
log_entry = 'pretix.event.order.email.order_placed'
email_attendees = request.event.settings.mail_send_order_placed_attendee
email_attendees_template = request.event.settings.mail_text_order_placed_attendee
_order_placed_email(
request.event, order, payment.payment_provider if payment else None, email_template,
log_entry, invoice, payment
)
if email_attendees:
for p in order.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
_order_placed_email_attendee(request.event, order, p, email_attendees_template, log_entry)
if not free_flow and order.status == Order.STATUS_PAID and payment:
payment._send_paid_mail(invoice, None, '')
if self.request.event.settings.mail_send_order_paid_attendee:
for p in order.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
payment._send_paid_mail_attendee(p, None)
headers = self.get_success_headers(serializer.data) headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
@@ -544,7 +476,6 @@ class OrderViewSet(viewsets.ModelViewSet):
) )
if 'email' in self.request.data and serializer.instance.email != self.request.data.get('email'): if 'email' in self.request.data and serializer.instance.email != self.request.data.get('email'):
serializer.instance.email_known_to_work = False
serializer.instance.log_action( serializer.instance.log_action(
'pretix.event.order.contact.changed', 'pretix.event.order.contact.changed',
user=self.request.user, user=self.request.user,
@@ -577,7 +508,6 @@ class OrderViewSet(viewsets.ModelViewSet):
) )
serializer.save() serializer.save()
tickets.invalidate_cache.apply_async(kwargs={'event': serializer.instance.event.pk, 'order': serializer.instance.pk})
if 'invoice_address' in self.request.data: if 'invoice_address' in self.request.data:
order_modified.send(sender=serializer.instance.event, order=serializer.instance) order_modified.send(sender=serializer.instance.event, order=serializer.instance)
@@ -593,49 +523,48 @@ class OrderViewSet(viewsets.ModelViewSet):
self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth) self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
with scopes_disabled(): class OrderPositionFilter(FilterSet):
class OrderPositionFilter(FilterSet): order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact') has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs')
has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs') attendee_name = django_filters.CharFilter(method='attendee_name_qs')
attendee_name = django_filters.CharFilter(method='attendee_name_qs') search = django_filters.CharFilter(method='search_qs')
search = django_filters.CharFilter(method='search_qs')
def search_qs(self, queryset, name, value): def search_qs(self, queryset, name, value):
return queryset.filter( return queryset.filter(
Q(secret__istartswith=value) Q(secret__istartswith=value)
| Q(attendee_name_cached__icontains=value) | Q(attendee_name_cached__icontains=value)
| Q(addon_to__attendee_name_cached__icontains=value) | Q(addon_to__attendee_name_cached__icontains=value)
| Q(attendee_email__icontains=value) | Q(attendee_email__icontains=value)
| Q(addon_to__attendee_email__icontains=value) | Q(addon_to__attendee_email__icontains=value)
| Q(order__code__istartswith=value) | Q(order__code__istartswith=value)
| Q(order__invoice_address__name_cached__icontains=value) | Q(order__invoice_address__name_cached__icontains=value)
| Q(order__email__icontains=value) | Q(order__email__icontains=value)
) )
def has_checkin_qs(self, queryset, name, value): def has_checkin_qs(self, queryset, name, value):
return queryset.filter(checkins__isnull=not value) return queryset.filter(checkins__isnull=not value)
def attendee_name_qs(self, queryset, name, value): def attendee_name_qs(self, queryset, name, value):
return queryset.filter(Q(attendee_name_cached__iexact=value) | Q(addon_to__attendee_name_cached__iexact=value)) return queryset.filter(Q(attendee_name_cached__iexact=value) | Q(addon_to__attendee_name_cached__iexact=value))
class Meta: class Meta:
model = OrderPosition model = OrderPosition
fields = { fields = {
'item': ['exact', 'in'], 'item': ['exact', 'in'],
'variation': ['exact', 'in'], 'variation': ['exact', 'in'],
'secret': ['exact'], 'secret': ['exact'],
'order__status': ['exact', 'in'], 'order__status': ['exact', 'in'],
'addon_to': ['exact', 'in'], 'addon_to': ['exact', 'in'],
'subevent': ['exact', 'in'], 'subevent': ['exact', 'in'],
'pseudonymization_id': ['exact'], 'pseudonymization_id': ['exact'],
'voucher__code': ['exact'], 'voucher__code': ['exact'],
'voucher': ['exact'], 'voucher': ['exact'],
} }
class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet): class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPositionSerializer serializer_class = OrderPositionSerializer
queryset = OrderPosition.all.none() queryset = OrderPosition.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter) filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('order__datetime', 'positionid') ordering = ('order__datetime', 'positionid')
ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',) ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',)
@@ -672,13 +601,13 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
) )
)) ))
).select_related( ).select_related(
'item', 'variation', 'item__category', 'addon_to', 'seat' 'item', 'variation', 'item__category', 'addon_to'
) )
else: else:
qs = qs.prefetch_related( qs = qs.prefetch_related(
'checkins', 'answers', 'answers__options', 'answers__question' 'checkins', 'answers', 'answers__options', 'answers__question'
).select_related( ).select_related(
'item', 'order', 'order__event', 'order__event__organizer', 'seat' 'item', 'order', 'order__event', 'order__event__organizer'
) )
return qs return qs
@@ -690,83 +619,6 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
return prov return prov
raise NotFound('Unknown output provider.') raise NotFound('Unknown output provider.')
@action(detail=True, methods=['POST'], url_name='price_calc')
def price_calc(self, request, *args, **kwargs):
"""
This calculates the price assuming a change of product or subevent. This endpoint
is deliberately not documented and considered a private API, only to be used by
pretix' web interface.
Sample input:
{
"item": 2,
"variation": null,
"subevent": 3
}
Sample output:
{
"gross": "2.34",
"gross_formatted": "2,34",
"net": "2.34",
"tax": "0.00",
"rate": "0.00",
"name": "VAT"
}
"""
serializer = PriceCalcSerializer(data=request.data, event=request.event)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
pos = self.get_object()
try:
ia = pos.order.invoice_address
except InvoiceAddress.DoesNotExist:
ia = InvoiceAddress()
kwargs = {
'item': pos.item,
'variation': pos.variation,
'voucher': pos.voucher,
'subevent': pos.subevent,
'addon_to': pos.addon_to,
'invoice_address': ia,
}
if data.get('item'):
item = data.get('item')
kwargs['item'] = item
if item.has_variations:
variation = data.get('variation') or pos.variation
if not variation:
raise ValidationError('No variation given')
if variation.item != item:
raise ValidationError('Variation does not belong to item')
kwargs['variation'] = variation
else:
variation = None
kwargs['variation'] = None
if pos.voucher and not pos.voucher.applies_to(item, variation):
kwargs['voucher'] = None
if data.get('subevent'):
kwargs['subevent'] = data.get('subevent')
price = get_price(**kwargs)
with language(data.get('locale') or self.request.event.settings.locale):
return Response({
'gross': price.gross,
'gross_formatted': money_filter(price.gross, self.request.event.currency, hide_currency=True),
'net': price.net,
'rate': price.rate,
'name': str(price.name),
'tax': price.tax,
})
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)') @action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
def download(self, request, output, **kwargs): def download(self, request, output, **kwargs):
provider = self._get_output_provider(output) provider = self._get_output_provider(output)
@@ -784,16 +636,12 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
generate.apply_async(args=('orderposition', pos.pk, provider.identifier)) generate.apply_async(args=('orderposition', pos.pk, provider.identifier))
raise RetryException() raise RetryException()
else: else:
if ct.type == 'text/uri-list': resp = FileResponse(ct.file.file, content_type=ct.type)
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list') resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
return resp self.request.event.slug.upper(), pos.order.code, pos.positionid,
else: provider.identifier, ct.extension
resp = FileResponse(ct.file.file, content_type=ct.type) )
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format( return resp
self.request.event.slug.upper(), pos.order.code, pos.positionid,
provider.identifier, ct.extension
)
return resp
def perform_destroy(self, instance): def perform_destroy(self, instance):
try: try:
@@ -915,16 +763,13 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
if payment.state not in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED): if payment.state not in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED):
return Response({'detail': 'Invalid state of payment'}, status=status.HTTP_400_BAD_REQUEST) return Response({'detail': 'Invalid state of payment'}, status=status.HTTP_400_BAD_REQUEST)
try: with transaction.atomic():
with transaction.atomic(): payment.state = OrderPayment.PAYMENT_STATE_CANCELED
payment.payment_provider.cancel_payment(payment) payment.save()
payment.order.log_action('pretix.event.order.payment.canceled', { payment.order.log_action('pretix.event.order.payment.canceled', {
'local_id': payment.local_id, 'local_id': payment.local_id,
'provider': payment.provider, 'provider': payment.provider,
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth) }, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
except PaymentException as e:
return Response({'detail': 'External error: {}'.format(str(e))},
status=status.HTTP_400_BAD_REQUEST)
return self.retrieve(request, [], **kwargs) return self.retrieve(request, [], **kwargs)
@@ -1030,23 +875,22 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer.save() serializer.save()
with scopes_disabled(): class InvoiceFilter(FilterSet):
class InvoiceFilter(FilterSet): refers = django_filters.CharFilter(method='refers_qs')
refers = django_filters.CharFilter(method='refers_qs') number = django_filters.CharFilter(method='nr_qs')
number = django_filters.CharFilter(method='nr_qs') order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
def refers_qs(self, queryset, name, value): def refers_qs(self, queryset, name, value):
return queryset.annotate( return queryset.annotate(
refers_nr=Concat('refers__prefix', 'refers__invoice_no') refers_nr=Concat('refers__prefix', 'refers__invoice_no')
).filter(refers_nr__iexact=value) ).filter(refers_nr__iexact=value)
def nr_qs(self, queryset, name, value): def nr_qs(self, queryset, name, value):
return queryset.filter(nr__iexact=value) return queryset.filter(nr__iexact=value)
class Meta: class Meta:
model = Invoice model = Invoice
fields = ['order', 'number', 'is_cancellation', 'refers', 'locale'] fields = ['order', 'number', 'is_cancellation', 'refers', 'locale']
class RetryException(APIException): class RetryException(APIException):

View File

@@ -1,15 +1,8 @@
from django.db import transaction from rest_framework import viewsets
from rest_framework import filters, serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied
from rest_framework.response import Response
from pretix.api.models import OAuthAccessToken from pretix.api.models import OAuthAccessToken
from pretix.api.serializers.organizer import ( from pretix.api.serializers.organizer import OrganizerSerializer
GiftCardSerializer, OrganizerSerializer, SeatingPlanSerializer, from pretix.base.models import Organizer
)
from pretix.base.models import GiftCard, Organizer, SeatingPlan
from pretix.helpers.dicts import merge_dicts
class OrganizerViewSet(viewsets.ReadOnlyModelViewSet): class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
@@ -17,9 +10,6 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Organizer.objects.none() queryset = Organizer.objects.none()
lookup_field = 'slug' lookup_field = 'slug'
lookup_url_kwarg = 'organizer' lookup_url_kwarg = 'organizer'
filter_backends = (filters.OrderingFilter,)
ordering = ('slug',)
ordering_fields = ('name', 'slug')
def get_queryset(self): def get_queryset(self):
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
@@ -37,113 +27,3 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
return Organizer.objects.filter(pk=self.request.auth.organizer_id) return Organizer.objects.filter(pk=self.request.auth.organizer_id)
else: else:
return Organizer.objects.filter(pk=self.request.auth.team.organizer_id) return Organizer.objects.filter(pk=self.request.auth.team.organizer_id)
class SeatingPlanViewSet(viewsets.ModelViewSet):
serializer_class = SeatingPlanSerializer
queryset = SeatingPlan.objects.none()
permission = 'can_change_organizer_settings'
write_permission = 'can_change_organizer_settings'
def get_queryset(self):
return self.request.organizer.seating_plans.all()
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.seatingplan.added',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': inst.pk})
)
def perform_update(self, serializer):
if serializer.instance.events.exists() or serializer.instance.subevents.exists():
raise PermissionDenied('This plan can not be changed while it is in use for an event.')
inst = serializer.save(organizer=self.request.organizer)
self.request.organizer.log_action(
'pretix.seatingplan.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):
if instance.events.exists() or instance.subevents.exists():
raise PermissionDenied('This plan can not be deleted while it is in use for an event.')
instance.log_action(
'pretix.seatingplan.deleted',
user=self.request.user,
auth=self.request.auth,
data={'id': instance.pk}
)
instance.delete()
class GiftCardViewSet(viewsets.ModelViewSet):
serializer_class = GiftCardSerializer
queryset = GiftCard.objects.none()
permission = 'can_manage_gift_cards'
write_permission = 'can_manage_gift_cards'
def get_queryset(self):
return self.request.organizer.issued_gift_cards.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
@transaction.atomic()
def perform_create(self, serializer):
value = serializer.validated_data.pop('value')
inst = serializer.save(issuer=self.request.organizer)
inst.transactions.create(value=value)
inst.log_action(
'pretix.giftcards.transaction.manual',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': inst.pk})
)
@transaction.atomic()
def perform_update(self, serializer):
GiftCard.objects.select_for_update().get(pk=self.get_object().pk)
old_value = serializer.instance.value
value = serializer.validated_data.pop('value')
inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency,
testmode=serializer.instance.testmode)
diff = value - old_value
inst.transactions.create(value=diff)
inst.log_action(
'pretix.giftcards.transaction.manual',
user=self.request.user,
auth=self.request.auth,
data={'value': diff}
)
return inst
@action(detail=True, methods=["POST"])
@transaction.atomic()
def transact(self, request, **kwargs):
gc = GiftCard.objects.select_for_update().get(pk=self.get_object().pk)
value = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
request.data.get('value')
)
gc.transactions.create(value=value)
gc.log_action(
'pretix.giftcards.transaction.manual',
user=self.request.user,
auth=self.request.auth,
data={'value': value}
)
return Response(GiftCardSerializer(gc).data, status=status.HTTP_200_OK)
def perform_destroy(self, instance):
raise MethodNotAllowed("Gift cards cannot be deleted.")

View File

@@ -6,7 +6,6 @@ from django.utils.timezone import now
from django_filters.rest_framework import ( from django_filters.rest_framework import (
BooleanFilter, DjangoFilterBackend, FilterSet, BooleanFilter, DjangoFilterBackend, FilterSet,
) )
from django_scopes import scopes_disabled
from rest_framework import status, viewsets from rest_framework import status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
@@ -16,22 +15,22 @@ from rest_framework.response import Response
from pretix.api.serializers.voucher import VoucherSerializer from pretix.api.serializers.voucher import VoucherSerializer
from pretix.base.models import Voucher from pretix.base.models import Voucher
with scopes_disabled():
class VoucherFilter(FilterSet):
active = BooleanFilter(method='filter_active')
class Meta: class VoucherFilter(FilterSet):
model = Voucher active = BooleanFilter(method='filter_active')
fields = ['code', 'max_usages', 'redeemed', 'block_quota', 'allow_ignore_quota',
'price_mode', 'value', 'item', 'variation', 'quota', 'tag', 'subevent']
def filter_active(self, queryset, name, value): class Meta:
if value: model = Voucher
return queryset.filter(Q(redeemed__lt=F('max_usages')) & fields = ['code', 'max_usages', 'redeemed', 'block_quota', 'allow_ignore_quota',
(Q(valid_until__isnull=True) | Q(valid_until__gt=now()))) 'price_mode', 'value', 'item', 'variation', 'quota', 'tag', 'subevent']
else:
return queryset.filter(Q(redeemed__gte=F('max_usages')) | def filter_active(self, queryset, name, value):
(Q(valid_until__isnull=False) & Q(valid_until__lte=now()))) if value:
return queryset.filter(Q(redeemed__lt=F('max_usages')) &
(Q(valid_until__isnull=True) | Q(valid_until__gt=now())))
else:
return queryset.filter(Q(redeemed__gte=F('max_usages')) |
(Q(valid_until__isnull=False) & Q(valid_until__lte=now())))
class VoucherViewSet(viewsets.ModelViewSet): class VoucherViewSet(viewsets.ModelViewSet):
@@ -45,7 +44,7 @@ class VoucherViewSet(viewsets.ModelViewSet):
write_permission = 'can_change_vouchers' write_permission = 'can_change_vouchers'
def get_queryset(self): def get_queryset(self):
return self.request.event.vouchers.select_related('seat').all() return self.request.event.vouchers.all()
def _predict_quota_check(self, data, instance): def _predict_quota_check(self, data, instance):
# This method predicts if Voucher.clean_quota_needs_checking # This method predicts if Voucher.clean_quota_needs_checking

View File

@@ -1,6 +1,5 @@
import django_filters import django_filters
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.exceptions import PermissionDenied, ValidationError
@@ -11,16 +10,16 @@ from pretix.api.serializers.waitinglist import WaitingListSerializer
from pretix.base.models import WaitingListEntry from pretix.base.models import WaitingListEntry
from pretix.base.models.waitinglist import WaitingListException from pretix.base.models.waitinglist import WaitingListException
with scopes_disabled():
class WaitingListFilter(FilterSet):
has_voucher = django_filters.rest_framework.BooleanFilter(method='has_voucher_qs')
def has_voucher_qs(self, queryset, name, value): class WaitingListFilter(FilterSet):
return queryset.filter(voucher__isnull=not value) has_voucher = django_filters.rest_framework.BooleanFilter(method='has_voucher_qs')
class Meta: def has_voucher_qs(self, queryset, name, value):
model = WaitingListEntry return queryset.filter(voucher__isnull=not value)
fields = ['item', 'variation', 'email', 'locale', 'has_voucher', 'subevent']
class Meta:
model = WaitingListEntry
fields = ['item', 'variation', 'email', 'locale', 'has_voucher', 'subevent']
class WaitingListViewSet(viewsets.ModelViewSet): class WaitingListViewSet(viewsets.ModelViewSet):

View File

@@ -8,7 +8,6 @@ from celery.exceptions import MaxRetriesExceededError
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_scopes import scope, scopes_disabled
from requests import RequestException from requests import RequestException
from pretix.api.models import WebHook, WebHookCall, WebHookEventListener from pretix.api.models import WebHook, WebHookCall, WebHookEventListener
@@ -204,52 +203,51 @@ def notify_webhooks(logentry_id: int):
@app.task(base=ProfiledTask, bind=True, max_retries=9) @app.task(base=ProfiledTask, bind=True, max_retries=9)
def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int): def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
# 9 retries with 2**(2*x) timing is roughly 72 hours # 9 retries with 2**(2*x) timing is roughly 72 hours
with scopes_disabled(): logentry = LogEntry.all.get(id=logentry_id)
webhook = WebHook.objects.get(id=webhook_id) webhook = WebHook.objects.get(id=webhook_id)
with scope(organizer=webhook.organizer):
logentry = LogEntry.all.get(id=logentry_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) types = get_all_webhook_events()
t = time.time() 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: try:
try: resp = requests.post(
resp = requests.post( webhook.target_url,
webhook.target_url, json=payload,
json=payload, allow_redirects=False
allow_redirects=False )
) WebHookCall.objects.create(
WebHookCall.objects.create( webhook=webhook,
webhook=webhook, action_type=logentry.action_type,
action_type=logentry.action_type, target_url=webhook.target_url,
target_url=webhook.target_url, is_retry=self.request.retries > 0,
is_retry=self.request.retries > 0, execution_time=time.time() - t,
execution_time=time.time() - t, return_code=resp.status_code,
return_code=resp.status_code, payload=json.dumps(payload),
payload=json.dumps(payload), response_body=resp.text[:1024 * 1024],
response_body=resp.text[:1024 * 1024], success=200 <= resp.status_code <= 299
success=200 <= resp.status_code <= 299 )
) if resp.status_code == 410:
if resp.status_code == 410: webhook.enabled = False
webhook.enabled = False webhook.save()
webhook.save() elif resp.status_code > 299:
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)) raise self.retry(countdown=2 ** (self.request.retries * 2))
except MaxRetriesExceededError: except RequestException as e:
pass 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

View File

@@ -13,7 +13,7 @@ class PretixBaseConfig(AppConfig):
from . import invoice # NOQA from . import invoice # NOQA
from . import notifications # NOQA from . import notifications # NOQA
from . import email # NOQA from . import email # NOQA
from .services import auth, checkin, export, mail, tickets, cart, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA from .services import auth, export, mail, tickets, cart, orders, invoices, cleanup, update_check, quotas, notifications # NOQA
try: try:
from .celery_app import app as celery_app # NOQA from .celery_app import app as celery_app # NOQA

View File

@@ -1,109 +0,0 @@
from collections import OrderedDict
from importlib import import_module
from django import forms
from django.conf import settings
from django.contrib.auth import authenticate
from django.utils.translation import gettext_lazy as _
def get_auth_backends():
backends = {}
for b in settings.PRETIX_AUTH_BACKENDS:
mod, name = b.rsplit('.', 1)
b = getattr(import_module(mod), name)()
backends[b.identifier] = b
return backends
class BaseAuthBackend:
"""
This base class defines the interface that needs to be implemented by every class that supplies
an authentication method to pretix. Please note that pretix authentication backends are different
from plain Django authentication backends! Be sure to read the documentation chapter on authentication
backends before you implement one.
"""
@property
def identifier(self):
"""
A short and unique identifier for this authentication backend.
This should only contain lowercase letters and in most cases will
be the same as your package name.
"""
raise NotImplementedError()
@property
def verbose_name(self):
"""
A human-readable name of this authentication backend.
"""
raise NotImplementedError()
@property
def visible(self):
"""
Whether or not this backend can be selected by users actively. Set this to ``False``
if you only implement ``request_authenticate``.
"""
return True
@property
def login_form_fields(self) -> dict:
"""
This property may return form fields that the user needs to fill in to log in.
"""
return {}
def form_authenticate(self, request, form_data):
"""
This method will be called after the user filled in the login form. ``request`` will contain
the current request and ``form_data`` the input for the form fields defined in ``login_form_fields``.
You are expected to either return a ``User`` object (if login was successful) or ``None``.
"""
return
def request_authenticate(self, request):
"""
This method will be called when the user opens the login form. If the user already has a valid session
according to your login mechanism, for example a cookie set by a different system or HTTP header set by a
reverse proxy, you can directly return a ``User`` object that will be logged in.
``request`` will contain the current request.
You are expected to either return a ``User`` object (if login was successful) or ``None``.
"""
return
def authentication_url(self, request):
"""
This method will be called to populate the URL for your authentication method's tab on the login page.
For example, if your method works through OAuth, you could return the URL of the OAuth authorization URL the
user needs to visit.
If you return ``None`` (the default), the link will point to a page that shows the form defined by
``login_form_fields``.
"""
return
class NativeAuthBackend(BaseAuthBackend):
identifier = 'native'
verbose_name = _('pretix User')
@property
def login_form_fields(self) -> dict:
"""
This property may return form fields that the user needs to fill in
to log in.
"""
d = OrderedDict([
('email', forms.EmailField(label=_("E-mail"), max_length=254,
widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))),
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput)),
])
return d
def form_authenticate(self, request, form_data):
u = authenticate(request=request, email=form_data['email'].lower(), password=form_data['password'])
if u and u.auth_backend == self.identifier:
return u

View File

@@ -1,80 +0,0 @@
import re
# banlist based on http://www.bannedwordlist.com/lists/swearWords.txt
banlist = [
"anal",
"anus",
"arse",
"ass",
"balls",
"bastard",
"bitch",
"biatch",
"bloody",
"blowjob",
"bollock",
"bollok",
"boner",
"boob",
"bugger",
"bum",
"butt",
"clitoris",
"cock",
"coon",
"crap",
"cunt",
"damn",
"dick",
"dildo",
"dyke",
"fag",
"feck",
"fellate",
"fellatio",
"felching",
"fuck",
"fudgepacker",
"flange",
"goddamn",
"hell",
"homo",
"jerk",
"jizz",
"knobend",
"labia",
"lmao",
"lmfao",
"muff",
"nigger",
"nigga",
"omg",
"penis",
"piss",
"poop",
"prick",
"pube",
"pussy",
"queer",
"scrotum",
"sex",
"shit",
"sh1t",
"slut",
"smegma",
"spunk",
"tit",
"tosser",
"turd",
"twat",
"vagina",
"wank",
"whore",
"wtf"
]
blacklist_regex = re.compile('(' + '|'.join(banlist) + ')')
def banned(string):
return bool(blacklist_regex.search(string.lower()))

View File

@@ -35,32 +35,6 @@ class SalesChannel:
""" """
return "circle" return "circle"
@property
def testmode_supported(self) -> bool:
"""
Indication, if a saleschannels supports test mode orders
"""
return True
@property
def payment_restrictions_supported(self) -> bool:
"""
If this property is ``True``, organizers can restrict the usage of payment providers to this sales channel.
Example: pretixPOS provides its own sales channel, ignores the configured payment providers completely and
handles payments locally. Therefor, this property should be set to ``False`` for the pretixPOS sales channel as
the event organizer cannot restrict the usage of any payment provider through the backend.
"""
return True
@property
def unlimited_items_per_order(self) -> bool:
"""
If this property is ``True``, purchases made using this sales channel are not limited to the maximum amount of
items defined in the event settings.
"""
return False
def get_all_sales_channels(): def get_all_sales_channels():
global _ALL_CHANNELS global _ALL_CHANNELS

View File

@@ -1,15 +0,0 @@
import sys
from django.conf import settings
def contextprocessor(request):
ctx = {
'rtl': getattr(request, 'LANGUAGE_CODE', 'en') in settings.LANGUAGES_RTL,
}
if settings.DEBUG and 'runserver' not in sys.argv:
ctx['debug_warning'] = True
elif 'runserver' in sys.argv:
ctx['development_warning'] = True
return ctx

View File

@@ -1,23 +1,15 @@
import inspect
import logging import logging
from datetime import timedelta
from decimal import Decimal
from smtplib import SMTPResponseException from smtplib import SMTPResponseException
from django.conf import settings from django.conf import settings
from django.core.mail.backends.smtp import EmailBackend from django.core.mail.backends.smtp import EmailBackend
from django.dispatch import receiver from django.dispatch import receiver
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from inlinestyler.utils import inline_css from inlinestyler.utils import inline_css
from pretix.base.i18n import LazyCurrencyNumber, LazyDate, LazyNumber from pretix.base.models import Event, Order
from pretix.base.models import Event from pretix.base.signals import register_html_mail_renderers
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import (
register_html_mail_renderers, register_mail_placeholders,
)
from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.base.templatetags.rich_text import markdown_compile_email
logger = logging.getLogger('pretix.base.email') logger = logging.getLogger('pretix.base.email')
@@ -52,8 +44,7 @@ class BaseHTMLMailRenderer:
def __str__(self): def __str__(self):
return self.identifier return self.identifier
def render(self, plain_body: str, plain_signature: str, subject: str, order=None, def render(self, plain_body: str, plain_signature: str, subject: str, order: Order=None) -> str:
position=None) -> str:
""" """
This method should generate the HTML part of the email. This method should generate the HTML part of the email.
@@ -61,7 +52,6 @@ class BaseHTMLMailRenderer:
:param plain_signature: The signature with event organizer contact details in plain text. :param plain_signature: The signature with event organizer contact details in plain text.
:param subject: The email subject. :param subject: The email subject.
:param order: The order if this email is connected to one, otherwise ``None``. :param order: The order if this email is connected to one, otherwise ``None``.
:param position: The order position if this email is connected to one, otherwise ``None``.
:return: An HTML string :return: An HTML string
""" """
raise NotImplementedError() raise NotImplementedError()
@@ -105,7 +95,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
def template_name(self): def template_name(self):
raise NotImplementedError() raise NotImplementedError()
def render(self, plain_body: str, plain_signature: str, subject: str, order, position) -> str: def render(self, plain_body: str, plain_signature: str, subject: str, order: Order) -> str:
body_md = markdown_compile_email(plain_body) body_md = markdown_compile_email(plain_body)
htmlctx = { htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME, 'site': settings.PRETIX_INSTANCE_NAME,
@@ -126,9 +116,6 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
if order: if order:
htmlctx['order'] = order htmlctx['order'] = order
if position:
htmlctx['position'] = position
tpl = get_template(self.template_name) tpl = get_template(self.template_name)
body_html = inline_css(tpl.render(htmlctx)) body_html = inline_css(tpl.render(htmlctx))
return body_html return body_html
@@ -144,305 +131,3 @@ class ClassicMailRenderer(TemplateBasedMailRenderer):
@receiver(register_html_mail_renderers, dispatch_uid="pretixbase_email_renderers") @receiver(register_html_mail_renderers, dispatch_uid="pretixbase_email_renderers")
def base_renderers(sender, **kwargs): def base_renderers(sender, **kwargs):
return [ClassicMailRenderer] return [ClassicMailRenderer]
class BaseMailTextPlaceholder:
"""
This is the base class for for all email text placeholders.
"""
@property
def required_context(self):
"""
This property should return a list of all attribute names that need to be
contained in the base context so that this placeholder is available. By default,
it returns a list containing the string "event".
"""
return ["event"]
@property
def identifier(self):
"""
This should return the identifier of this placeholder in the email.
"""
raise NotImplementedError()
def render(self, context):
"""
This method is called to generate the actual text that is being
used in the email. You will be passed a context dictionary with the
base context attributes specified in ``required_context``. You are
expected to return a plain-text string.
"""
raise NotImplementedError()
def render_sample(self, event):
"""
This method is called to generate a text to be used in email previews.
This may only depend on the event.
"""
raise NotImplementedError()
class SimpleFunctionalMailTextPlaceholder(BaseMailTextPlaceholder):
def __init__(self, identifier, args, func, sample):
self._identifier = identifier
self._args = args
self._func = func
self._sample = sample
@property
def identifier(self):
return self._identifier
@property
def required_context(self):
return self._args
def render(self, context):
return self._func(**{k: context[k] for k in self._args})
def render_sample(self, event):
if callable(self._sample):
return self._sample(event)
else:
return self._sample
def get_available_placeholders(event, base_parameters):
if 'order' in base_parameters:
base_parameters.append('invoice_address')
params = {}
for r, val in register_mail_placeholders.send(sender=event):
if not isinstance(val, (list, tuple)):
val = [val]
for v in val:
if all(rp in base_parameters for rp in v.required_context):
params[v.identifier] = v
return params
def get_email_context(**kwargs):
from pretix.base.models import InvoiceAddress
event = kwargs['event']
if 'order' in kwargs:
try:
kwargs['invoice_address'] = kwargs['order'].invoice_address
except InvoiceAddress.DoesNotExist:
kwargs['invoice_address'] = InvoiceAddress()
ctx = {}
for r, val in register_mail_placeholders.send(sender=event):
if not isinstance(val, (list, tuple)):
val = [val]
for v in val:
if all(rp in kwargs for rp in v.required_context):
ctx[v.identifier] = v.render(kwargs)
return ctx
def _placeholder_payment(order, payment):
if not payment:
return None
if 'payment' in inspect.signature(payment.payment_provider.order_pending_mail_render).parameters:
return str(payment.payment_provider.order_pending_mail_render(order, payment))
else:
return str(payment.payment_provider.order_pending_mail_render(order))
@receiver(register_mail_placeholders, dispatch_uid="pretixbase_register_mail_placeholders")
def base_placeholders(sender, **kwargs):
from pretix.base.models import InvoiceAddress
from pretix.multidomain.urlreverse import build_absolute_uri
ph = [
SimpleFunctionalMailTextPlaceholder(
'event', ['event'], lambda event: event.name, lambda event: event.name
),
SimpleFunctionalMailTextPlaceholder(
'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug
),
SimpleFunctionalMailTextPlaceholder(
'code', ['order'], lambda order: order.code, 'F8VVL'
),
SimpleFunctionalMailTextPlaceholder(
'total', ['order'], lambda order: LazyNumber(order.total), lambda event: LazyNumber(Decimal('42.23'))
),
SimpleFunctionalMailTextPlaceholder(
'currency', ['event'], lambda event: event.currency, lambda event: event.currency
),
SimpleFunctionalMailTextPlaceholder(
'total_with_currency', ['event', 'order'], lambda event, order: LazyCurrencyNumber(order.total,
event.currency),
lambda event: LazyCurrencyNumber(Decimal('42.23'), event.currency)
),
SimpleFunctionalMailTextPlaceholder(
'expire_date', ['event', 'order'], lambda event, order: LazyDate(order.expires.astimezone(event.timezone)),
lambda event: LazyDate(now() + timedelta(days=15))
# TODO: This used to be "date" in some placeholders, add a migration!
),
SimpleFunctionalMailTextPlaceholder(
'url', ['order', 'event'], lambda order, event: build_absolute_uri(
event,
'presale:event.order.open', kwargs={
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_hash()
}
), lambda event: build_absolute_uri(
event,
'presale:event.order.open', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
'hash': '98kusd8ofsj8dnkd'
}
),
),
SimpleFunctionalMailTextPlaceholder(
'url', ['event', 'position'], lambda event, position: build_absolute_uri(
event,
'presale:event.order.position',
kwargs={
'order': position.order.code,
'secret': position.web_secret,
'position': position.positionid
}
),
lambda event: build_absolute_uri(
event,
'presale:event.order.position', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
'position': '123'
}
),
),
SimpleFunctionalMailTextPlaceholder(
'url', ['waiting_list_entry', 'event'],
lambda waiting_list_entry, event: build_absolute_uri(
event, 'presale:event.redeem'
) + '?voucher=' + waiting_list_entry.voucher.code,
lambda event: build_absolute_uri(
event,
'presale:event.redeem',
) + '?voucher=68CYU2H6ZTP3WLK5',
),
SimpleFunctionalMailTextPlaceholder(
'invoice_name', ['invoice_address'], lambda invoice_address: invoice_address.name or '',
_('John Doe')
),
SimpleFunctionalMailTextPlaceholder(
'invoice_company', ['invoice_address'], lambda invoice_address: invoice_address.company or '',
_('Sample Corporation')
),
SimpleFunctionalMailTextPlaceholder(
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
'* {} - {}'.format(
order.full_code,
build_absolute_uri(event, 'presale:event.order', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'order': order.code,
'secret': order.secret
}),
)
for order in orders
), lambda event: '\n' + '\n\n'.join(
'* {} - {}'.format(
'{}-{}'.format(event.slug.upper(), order['code']),
build_absolute_uri(event, 'presale:event.order', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'order': order['code'],
'secret': order['secret']
}),
)
for order in [
{'code': 'F8VVL', 'secret': '6zzjnumtsx136ddy'},
{'code': 'HIDHK', 'secret': '98kusd8ofsj8dnkd'},
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd'}
]
),
),
SimpleFunctionalMailTextPlaceholder(
'hours', ['event', 'waiting_list_entry'], lambda event, waiting_list_entry:
event.settings.waiting_list_hours,
lambda event: event.settings.waiting_list_hours
),
SimpleFunctionalMailTextPlaceholder(
'product', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.item.name,
_('Sample Admission Ticket')
),
SimpleFunctionalMailTextPlaceholder(
'code', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.voucher.code,
'68CYU2H6ZTP3WLK5'
),
SimpleFunctionalMailTextPlaceholder(
'voucher_list', ['voucher_list'], lambda voucher_list: '\n'.join(voucher_list),
' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2'
),
SimpleFunctionalMailTextPlaceholder(
'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
}), lambda event: build_absolute_uri(event, 'presale:event.index', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
})
),
SimpleFunctionalMailTextPlaceholder(
'name', ['name'], lambda name: name,
_('John Doe')
),
SimpleFunctionalMailTextPlaceholder(
'comment', ['comment'], lambda comment: comment,
_('An individual text with a reason can be inserted here.'),
),
SimpleFunctionalMailTextPlaceholder(
'payment_info', ['order', 'payment'], _placeholder_payment,
_('The amount has been charged to your card.'),
),
SimpleFunctionalMailTextPlaceholder(
'payment_info', ['payment_info'], lambda payment_info: payment_info,
_('Please transfer money to this bank account: 9999-9999-9999-9999'),
),
SimpleFunctionalMailTextPlaceholder(
'attendee_name', ['position'], lambda position: position.attendee_name,
_('John Doe'),
),
SimpleFunctionalMailTextPlaceholder(
'name', ['position_or_address'],
lambda position_or_address: (
position_or_address.name
if isinstance(position_or_address, InvoiceAddress)
else position_or_address.attendee_name
),
_('John Doe'),
),
]
name_scheme = PERSON_NAME_SCHEMES[sender.settings.name_scheme]
for f, l, w in name_scheme['fields']:
if f == 'full_name':
continue
ph.append(SimpleFunctionalMailTextPlaceholder(
'attendee_name_%s' % f, ['position'], lambda position, f=f: position.attendee_name_parts.get(f, ''),
name_scheme['sample'][f]
))
ph.append(SimpleFunctionalMailTextPlaceholder(
'name_%s' % f, ['position_or_address'],
lambda position_or_address, f=f: (
position_or_address.name_parts.get(f, '')
if isinstance(position_or_address, InvoiceAddress)
else position_or_address.attendee_name_parts.get(f, '')
),
name_scheme['sample'][f]
))
for k, v in sender.meta_data.items():
ph.append(SimpleFunctionalMailTextPlaceholder(
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
v
))
return ph

View File

@@ -71,8 +71,6 @@ class BaseExporter:
:type form_data: dict :type form_data: dict
:param form_data: The form data of the export details form :param form_data: The form data of the export details form
:param output_file: You can optionally accept a parameter that will be given a file handle to write the
output to. In this case, you can return None instead of the file content.
Note: If you use a ``ModelChoiceField`` (or a ``ModelMultipleChoiceField``), the Note: If you use a ``ModelChoiceField`` (or a ``ModelMultipleChoiceField``), the
``form_data`` will not contain the model instance but only it's primary key (or ``form_data`` will not contain the model instance but only it's primary key (or
@@ -111,22 +109,16 @@ class ListExporter(BaseExporter):
raise NotImplementedError() # noqa raise NotImplementedError() # noqa
def get_filename(self): def get_filename(self):
return 'export' return 'export.csv'
def _render_csv(self, form_data, output_file=None, **kwargs): def _render_csv(self, form_data, **kwargs):
if output_file: output = io.StringIO()
writer = csv.writer(output_file, **kwargs) writer = csv.writer(output, **kwargs)
for line in self.iterate_list(form_data): for line in self.iterate_list(form_data):
writer.writerow(line) writer.writerow(line)
return self.get_filename() + '.csv', 'text/csv', None return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
else:
output = io.StringIO()
writer = csv.writer(output, **kwargs)
for line in self.iterate_list(form_data):
writer.writerow(line)
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
def _render_xlsx(self, form_data, output_file=None): def _render_xlsx(self, form_data):
wb = Workbook() wb = Workbook()
ws = wb.get_active_sheet() ws = wb.get_active_sheet()
try: try:
@@ -137,24 +129,20 @@ class ListExporter(BaseExporter):
for j, val in enumerate(line): for j, val in enumerate(line):
ws.cell(row=i + 1, column=j + 1).value = str(val) if not isinstance(val, KNOWN_TYPES) else val ws.cell(row=i + 1, column=j + 1).value = str(val) if not isinstance(val, KNOWN_TYPES) else val
if output_file: with tempfile.NamedTemporaryFile(suffix='.xlsx') as f:
wb.save(output_file) wb.save(f.name)
return self.get_filename() + '.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', None f.seek(0)
else: return self.get_filename() + '.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', f.read()
with tempfile.NamedTemporaryFile(suffix='.xlsx') as f:
wb.save(f.name)
f.seek(0)
return self.get_filename() + '.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', f.read()
def render(self, form_data: dict, output_file=None) -> Tuple[str, str, bytes]: def render(self, form_data: dict) -> Tuple[str, str, bytes]:
if form_data.get('_format') == 'xlsx': if form_data.get('_format') == 'xlsx':
return self._render_xlsx(form_data, output_file=output_file) return self._render_xlsx(form_data)
elif form_data.get('_format') == 'default': elif form_data.get('_format') == 'default':
return self._render_csv(form_data, quoting=csv.QUOTE_NONNUMERIC, delimiter=',', output_file=output_file) return self._render_csv(form_data, quoting=csv.QUOTE_NONNUMERIC, delimiter=',')
elif form_data.get('_format') == 'csv-excel': elif form_data.get('_format') == 'csv-excel':
return self._render_csv(form_data, dialect='excel', output_file=output_file) return self._render_csv(form_data, dialect='excel')
elif form_data.get('_format') == 'semicolon': elif form_data.get('_format') == 'semicolon':
return self._render_csv(form_data, dialect='excel', delimiter=';', output_file=output_file) return self._render_csv(form_data, dialect='excel', delimiter=';')
class MultiSheetListExporter(ListExporter): class MultiSheetListExporter(ListExporter):
@@ -192,20 +180,14 @@ class MultiSheetListExporter(ListExporter):
def iterate_sheet(self, form_data, sheet): def iterate_sheet(self, form_data, sheet):
raise NotImplementedError() # noqa raise NotImplementedError() # noqa
def _render_sheet_csv(self, form_data, sheet, output_file=None, **kwargs): def _render_sheet_csv(self, form_data, sheet, **kwargs):
if output_file: output = io.StringIO()
writer = csv.writer(output_file, **kwargs) writer = csv.writer(output, **kwargs)
for line in self.iterate_sheet(form_data, sheet): for line in self.iterate_sheet(form_data, sheet):
writer.writerow(line) writer.writerow(line)
return self.get_filename() + '.csv', 'text/csv', None return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
else:
output = io.StringIO()
writer = csv.writer(output, **kwargs)
for line in self.iterate_sheet(form_data, sheet):
writer.writerow(line)
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
def _render_xlsx(self, form_data, output_file=None): def _render_xlsx(self, form_data):
wb = Workbook() wb = Workbook()
ws = wb.get_active_sheet() ws = wb.get_active_sheet()
wb.remove(ws) wb.remove(ws)
@@ -215,24 +197,19 @@ class MultiSheetListExporter(ListExporter):
for j, val in enumerate(line): for j, val in enumerate(line):
ws.cell(row=i + 1, column=j + 1).value = str(val) if not isinstance(val, KNOWN_TYPES) else val ws.cell(row=i + 1, column=j + 1).value = str(val) if not isinstance(val, KNOWN_TYPES) else val
if output_file: with tempfile.NamedTemporaryFile(suffix='.xlsx') as f:
wb.save(output_file) wb.save(f.name)
return self.get_filename() + '.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', None f.seek(0)
else: return self.get_filename() + '.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', f.read()
with tempfile.NamedTemporaryFile(suffix='.xlsx') as f:
wb.save(f.name)
f.seek(0)
return self.get_filename() + '.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', f.read()
def render(self, form_data: dict, output_file=None) -> Tuple[str, str, bytes]: def render(self, form_data: dict) -> Tuple[str, str, bytes]:
if form_data.get('_format') == 'xlsx': if form_data.get('_format') == 'xlsx':
return self._render_xlsx(form_data, output_file=output_file) return self._render_xlsx(form_data)
elif ':' in form_data.get('_format'): elif ':' in form_data.get('_format'):
sheet, f = form_data.get('_format').split(':') sheet, f = form_data.get('_format').split(':')
if f == 'default': if f == 'default':
return self._render_sheet_csv(form_data, sheet, quoting=csv.QUOTE_NONNUMERIC, delimiter=',', return self._render_sheet_csv(form_data, sheet, quoting=csv.QUOTE_NONNUMERIC, delimiter=',')
output_file=output_file)
elif f == 'excel': elif f == 'excel':
return self._render_sheet_csv(form_data, sheet, dialect='excel', output_file=output_file) return self._render_sheet_csv(form_data, sheet, dialect='excel')
elif f == 'semicolon': elif f == 'semicolon':
return self._render_sheet_csv(form_data, sheet, dialect='excel', delimiter=';', output_file=output_file) return self._render_sheet_csv(form_data, sheet, dialect='excel', delimiter=';')

View File

@@ -1,5 +1,4 @@
from .answers import * # noqa from .answers import * # noqa
from .dekodi import * # noqa
from .invoices import * # noqa from .invoices import * # noqa
from .json import * # noqa from .json import * # noqa
from .mail import * # noqa from .mail import * # noqa

View File

@@ -40,7 +40,6 @@ class AnswerFilesExporter(BaseExporter):
if form_data.get('questions'): if form_data.get('questions'):
qs = qs.filter(question__in=form_data['questions']) qs = qs.filter(question__in=form_data['questions'])
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
any = False
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf: with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
for i in qs: for i in qs:
if i.file: if i.file:
@@ -52,12 +51,9 @@ class AnswerFilesExporter(BaseExporter):
i.question.pk, i.question.pk,
os.path.basename(i.file.name).split('.', 1)[1] os.path.basename(i.file.name).split('.', 1)[1]
) )
any = True
zipf.writestr(fname, i.file.read()) zipf.writestr(fname, i.file.read())
i.file.close() i.file.close()
if not any:
return None
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf: with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
return '{}_answers.zip'.format(self.event.slug), 'application/zip', zipf.read() return '{}_answers.zip'.format(self.event.slug), 'application/zip', zipf.read()

View File

@@ -1,222 +0,0 @@
import json
from collections import OrderedDict
from decimal import Decimal
import dateutil
from django import forms
from django.core.serializers.json import DjangoJSONEncoder
from django.dispatch import receiver
from django.utils.translation import ugettext, ugettext_lazy
from pretix.base.i18n import language
from pretix.base.models import Invoice, OrderPayment
from ..exporter import BaseExporter
from ..signals import register_data_exporters
class DekodiNREIExporter(BaseExporter):
identifier = 'dekodi_nrei'
verbose_name = 'dekodi NREI (JSON)'
# Specification: http://manuals.dekodi.de/nexuspub/schnittstellenbuch/
def _encode_invoice(self, invoice: Invoice):
p_last = invoice.order.payments.filter(state=[OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED]).last()
gross_total = Decimal('0.00')
net_total = Decimal('0.00')
positions = []
for l in invoice.lines.all():
positions.append({
'ADes': l.description.replace("<br />", "\n"),
'ANetA': round(float((-1 if invoice.is_cancellation else 1) * l.net_value), 2),
'ANo': self.event.slug,
'AQ': -1 if invoice.is_cancellation else 1,
'AVatP': round(float(l.tax_rate), 2),
'DIDt': (l.subevent or invoice.order.event).date_from.isoformat().replace('Z', '+00:00'),
'PosGrossA': round(float(l.gross_value), 2),
'PosNetA': round(float(l.net_value), 2),
})
gross_total += l.gross_value
net_total += l.net_value
payments = []
paypal_email = None
for p in invoice.order.payments.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_PENDING,
OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_REFUNDED)
):
if p.provider == 'paypal':
paypal_email = p.info_data.get('payer', {}).get('payer_info', {}).get('email')
try:
ppid = p.info_data['transactions'][0]['related_resources'][0]['sale']['id']
except:
ppid = p.info_data.get('id')
payments.append({
'PTID': '1',
'PTN': 'PayPal',
'PTNo1': ppid,
'PTNo2': p.info_data.get('id'),
'PTNo7': round(float(p.amount), 2),
'PTNo8': str(self.event.currency),
'PTNo11': paypal_email or '',
'PTNo15': p.full_id or '',
})
elif p.provider == 'banktransfer':
payments.append({
'PTID': '4',
'PTN': 'Vorkasse',
'PTNo4': p.info_data.get('reference') or p.payment_provider._code(invoice.order),
'PTNo7': round(float(p.amount), 2),
'PTNo8': str(self.event.currency),
'PTNo10': p.info_data.get('payer') or '',
'PTNo14': p.info_data.get('date') or '',
'PTNo15': p.full_id or '',
})
elif p.provider == 'sepadebit':
with language(invoice.order.locale):
payments.append({
'PTID': '5',
'PTN': 'Lastschrift',
'PTNo4': ugettext('Event ticket {event}-{code}').format(
event=self.event.slug.upper(),
code=invoice.order.code
),
'PTNo5': p.info_data.get('iban') or '',
'PTNo6': p.info_data.get('bic') or '',
'PTNo7': round(float(p.amount), 2),
'PTNo8': str(self.event.currency) or '',
'PTNo9': p.info_data.get('date') or '',
'PTNo10': p.info_data.get('account') or '',
'PTNo14': p.info_data.get('reference') or '',
'PTNo15': p.full_id or '',
})
elif p.provider.startswith('stripe'):
src = p.info_data.get("source", p.info_data)
payments.append({
'PTID': '81',
'PTN': 'Stripe',
'PTNo1': p.info_data.get("id") or '',
'PTNo5': src.get("card", {}).get("last4") or '',
'PTNo7': round(float(p.amount), 2) or '',
'PTNo8': str(self.event.currency) or '',
'PTNo10': src.get('owner', {}).get('verified_name') or src.get('owner', {}).get('name') or '',
'PTNo15': p.full_id or '',
})
else:
payments.append({
'PTID': '0',
'PTN': p.provider,
'PTNo7': round(float(p.amount), 2) or '',
'PTNo8': str(self.event.currency) or '',
'PTNo15': p.full_id or '',
})
payments = [
{
k: v for k, v in p.items() if v is not None
} for p in payments
]
hdr = {
'C': str(invoice.invoice_to_country) or self.event.settings.invoice_address_from_country,
'CC': self.event.currency,
'City': invoice.invoice_to_city,
'CN': invoice.invoice_to_company,
'DIC': self.event.settings.invoice_address_from_country,
# DIC is a little bit unclean, should be the event location's country
'DIDt': invoice.order.datetime.isoformat().replace('Z', '+00:00'),
'DT': '30' if invoice.is_cancellation else '10',
'EM': invoice.order.email,
'FamN': invoice.invoice_to_name.rsplit(' ', 1)[-1] if invoice.invoice_to_name else '',
'FN': (
invoice.invoice_to_name.rsplit(' ', 1)[0]
if invoice.invoice_to_name and ' ' in invoice.invoice_to_name else ''
),
'IDt': invoice.date.isoformat() + 'T08:00:00+01:00',
'INo': invoice.full_invoice_no,
'IsNet': invoice.reverse_charge,
'ODt': invoice.order.datetime.isoformat().replace('Z', '+00:00'),
'OID': invoice.order.code,
'SID': self.event.slug,
'SN': str(self.event),
'Str': invoice.invoice_to_street or '',
'TGrossA': round(float(gross_total), 2),
'TNetA': round(float(net_total), 2),
'TVatA': round(float(gross_total - net_total), 2),
'VatDp': False,
'Zip': invoice.invoice_to_zipcode
}
if not hdr['FamN'] and not hdr['CN']:
hdr['CN'] = "Unbekannter Kunde"
if invoice.refers:
hdr['PvrINo'] = invoice.refers.full_invoice_no
if p_last:
hdr['PmDt'] = p_last.payment_date.isoformat().replace('Z', '+00:00')
if paypal_email:
hdr['PPEm'] = paypal_email
if invoice.invoice_to_vat_id:
hdr['VatID'] = invoice.invoice_to_vat_id
return {
'IsValid': True,
'Hdr': hdr,
'InvcPstns': positions,
'PmIs': payments,
'ValidationMessage': ''
}
def render(self, form_data):
qs = self.event.invoices.select_related('order').prefetch_related('lines', 'lines__subevent')
if form_data.get('date_from'):
date_value = form_data.get('date_from')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(date__gte=date_value)
if form_data.get('date_to'):
date_value = form_data.get('date_to')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(date__lte=date_value)
jo = {
'Format': 'NREI',
'Version': '18.10.2.0',
'SourceSystem': 'pretix',
'Data': [
self._encode_invoice(i) for i in qs
]
}
return '{}_nrei.json'.format(self.event.slug), 'application/json', json.dumps(jo, cls=DjangoJSONEncoder, indent=4)
@property
def export_form_fields(self):
return OrderedDict(
[
('date_from',
forms.DateField(
label=ugettext_lazy('Start date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
help_text=ugettext_lazy('Only include invoices issued on or after this date. Note that the invoice date does '
'not always correspond to the order or payment date.')
)),
('date_to',
forms.DateField(
label=ugettext_lazy('End date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
help_text=ugettext_lazy('Only include invoices issued on or before this date. Note that the invoice date '
'does not always correspond to the order or payment date.')
)),
]
)
@receiver(register_data_exporters, dispatch_uid="exporter_dekodi_nrei")
def register_dekodi_export(sender, **kwargs):
return DekodiNREIExporter

View File

@@ -20,7 +20,7 @@ class InvoiceExporter(BaseExporter):
identifier = 'invoices' identifier = 'invoices'
verbose_name = _('All invoices') verbose_name = _('All invoices')
def render(self, form_data: dict, output_file=None): def render(self, form_data: dict):
qs = self.event.invoices.filter(shredded=False) qs = self.event.invoices.filter(shredded=False)
if form_data.get('payment_provider'): if form_data.get('payment_provider'):
@@ -46,8 +46,7 @@ class InvoiceExporter(BaseExporter):
qs = qs.filter(date__lte=date_value) qs = qs.filter(date__lte=date_value)
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
any = False with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
with ZipFile(output_file or os.path.join(d, 'tmp.zip'), 'w') as zipf:
for i in qs: for i in qs:
try: try:
if not i.file: if not i.file:
@@ -55,24 +54,16 @@ class InvoiceExporter(BaseExporter):
i.refresh_from_db() i.refresh_from_db()
i.file.open('rb') i.file.open('rb')
zipf.writestr('{}.pdf'.format(i.number), i.file.read()) zipf.writestr('{}.pdf'.format(i.number), i.file.read())
any = True
i.file.close() i.file.close()
except FileNotFoundError: except FileNotFoundError:
invoice_pdf_task.apply(args=(i.pk,)) invoice_pdf_task.apply(args=(i.pk,))
i.refresh_from_db() i.refresh_from_db()
i.file.open('rb') i.file.open('rb')
zipf.writestr('{}.pdf'.format(i.number), i.file.read()) zipf.writestr('{}.pdf'.format(i.number), i.file.read())
any = True
i.file.close() i.file.close()
if not any: with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
return None return '{}_invoices.zip'.format(self.event.slug), 'application/zip', zipf.read()
if output_file:
return '{}_invoices.zip'.format(self.event.slug), 'application/zip', None
else:
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
return '{}_invoices.zip'.format(self.event.slug), 'application/zip', zipf.read()
@property @property
def export_form_fields(self): def export_form_fields(self):

View File

@@ -6,10 +6,10 @@ from django import forms
from django.db.models import DateTimeField, F, Max, OuterRef, Subquery, Sum from django.db.models import DateTimeField, F, Max, OuterRef, Subquery, Sum
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.formats import date_format, localize from django.utils.formats import date_format, localize
from django.utils.translation import pgettext, ugettext as _, ugettext_lazy from django.utils.translation import ugettext as _, ugettext_lazy
from pretix.base.models import ( from pretix.base.models import (
InvoiceAddress, InvoiceLine, Order, OrderPosition, Question, InvoiceAddress, InvoiceLine, Order, OrderPosition,
) )
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
@@ -96,7 +96,7 @@ class OrderListExporter(MultiSheetListExporter):
for k, label, w in name_scheme['fields']: for k, label, w in name_scheme['fields']:
headers.append(label) headers.append(label)
headers += [ headers += [
_('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'), _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
_('Date of last payment'), _('Fees'), _('Order locale') _('Date of last payment'), _('Fees'), _('Order locale')
] ]
@@ -109,8 +109,6 @@ class OrderListExporter(MultiSheetListExporter):
headers.append(_('Invoice numbers')) headers.append(_('Invoice numbers'))
headers.append(_('Sales channel')) headers.append(_('Sales channel'))
headers.append(_('Requires special attention'))
headers.append(_('Comment'))
yield headers yield headers
@@ -155,11 +153,10 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.city, order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old, order.invoice_address.country_old,
order.invoice_address.state,
order.invoice_address.vat_id, order.invoice_address.vat_id,
] ]
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0)) row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
row += [ row += [
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '', order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
@@ -181,8 +178,6 @@ class OrderListExporter(MultiSheetListExporter):
row.append(', '.join([i.number for i in order.invoices.all()])) row.append(', '.join([i.number for i in order.invoices.all()]))
row.append(order.sales_channel) row.append(order.sales_channel)
row.append(_('Yes') if order.checkin_attention else _('No'))
row.append(order.comment or "")
yield row yield row
def iterate_fees(self, form_data: dict): def iterate_fees(self, form_data: dict):
@@ -213,7 +208,7 @@ class OrderListExporter(MultiSheetListExporter):
for k, label, w in name_scheme['fields']: for k, label, w in name_scheme['fields']:
headers.append(_('Invoice address name') + ': ' + str(label)) headers.append(_('Invoice address name') + ': ' + str(label))
headers += [ headers += [
_('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'), _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
] ]
yield headers yield headers
@@ -248,11 +243,10 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.city, order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old, order.invoice_address.country_old,
order.invoice_address.state,
order.invoice_address.vat_id, order.invoice_address.vat_id,
] ]
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0)) row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
yield row yield row
def iterate_positions(self, form_data: dict): def iterate_positions(self, form_data: dict):
@@ -275,12 +269,6 @@ class OrderListExporter(MultiSheetListExporter):
_('Status'), _('Status'),
_('Email'), _('Email'),
_('Order date'), _('Order date'),
]
if self.event.has_subevents:
headers.append(pgettext('subevent', 'Date'))
headers.append(_('Start date'))
headers.append(_('End date'))
headers += [
_('Product'), _('Product'),
_('Variation'), _('Variation'),
_('Price'), _('Price'),
@@ -309,7 +297,7 @@ class OrderListExporter(MultiSheetListExporter):
for k, label, w in name_scheme['fields']: for k, label, w in name_scheme['fields']:
headers.append(_('Invoice address name') + ': ' + str(label)) headers.append(_('Invoice address name') + ': ' + str(label))
headers += [ headers += [
_('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'), _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
] ]
headers.append(_('Sales channel')) headers.append(_('Sales channel'))
@@ -323,15 +311,6 @@ class OrderListExporter(MultiSheetListExporter):
order.get_status_display(), order.get_status_display(),
order.email, order.email,
order.datetime.astimezone(tz).strftime('%Y-%m-%d'), order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
]
if self.event.has_subevents:
row.append(op.subevent.name)
row.append(op.subevent.date_from.astimezone(self.event.timezone).strftime('%Y-%m-%d %H:%M:%S'))
if op.subevent.date_to:
row.append(op.subevent.date_to.astimezone(self.event.timezone).strftime('%Y-%m-%d %H:%M:%S'))
else:
row.append('')
row += [
str(op.item), str(op.item),
str(op.variation) if op.variation else '', str(op.variation) if op.variation else '',
op.price, op.price,
@@ -352,12 +331,7 @@ class OrderListExporter(MultiSheetListExporter):
] ]
acache = {} acache = {}
for a in op.answers.all(): for a in op.answers.all():
# We do not want to localize Date, Time and Datetime question answers, as those can lead acache[a.question_id] = str(a)
# to difficulties parsing the data (for example 2019-02-01 may become Février, 2019 01 in French).
if a.question.type in Question.UNLOCALIZED_TYPES:
acache[a.question_id] = a.answer
else:
acache[a.question_id] = str(a)
for q in questions: for q in questions:
row.append(acache.get(q.pk, '')) row.append(acache.get(q.pk, ''))
try: try:
@@ -376,11 +350,10 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.city, order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old, order.invoice_address.country_old,
order.invoice_address.state,
order.invoice_address.vat_id, order.invoice_address.vat_id,
] ]
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0)) row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
row.append(order.sales_channel) row.append(order.sales_channel)
yield row yield row
@@ -522,7 +495,6 @@ class InvoiceDataExporter(MultiSheetListExporter):
_('Invoice recipient:') + ' ' + _('ZIP code'), _('Invoice recipient:') + ' ' + _('ZIP code'),
_('Invoice recipient:') + ' ' + _('City'), _('Invoice recipient:') + ' ' + _('City'),
_('Invoice recipient:') + ' ' + _('Country'), _('Invoice recipient:') + ' ' + _('Country'),
_('Invoice recipient:') + ' ' + pgettext('address', 'State'),
_('Invoice recipient:') + ' ' + _('VAT ID'), _('Invoice recipient:') + ' ' + _('VAT ID'),
_('Invoice recipient:') + ' ' + _('Beneficiary'), _('Invoice recipient:') + ' ' + _('Beneficiary'),
_('Invoice recipient:') + ' ' + _('Internal reference'), _('Invoice recipient:') + ' ' + _('Internal reference'),
@@ -572,7 +544,6 @@ class InvoiceDataExporter(MultiSheetListExporter):
i.invoice_to_zipcode, i.invoice_to_zipcode,
i.invoice_to_city, i.invoice_to_city,
i.invoice_to_country, i.invoice_to_country,
i.invoice_to_state,
i.invoice_to_vat_id, i.invoice_to_vat_id,
i.invoice_to_beneficiary, i.invoice_to_beneficiary,
i.internal_reference, i.internal_reference,
@@ -612,7 +583,6 @@ class InvoiceDataExporter(MultiSheetListExporter):
_('Invoice recipient:') + ' ' + _('ZIP code'), _('Invoice recipient:') + ' ' + _('ZIP code'),
_('Invoice recipient:') + ' ' + _('City'), _('Invoice recipient:') + ' ' + _('City'),
_('Invoice recipient:') + ' ' + _('Country'), _('Invoice recipient:') + ' ' + _('Country'),
_('Invoice recipient:') + ' ' + pgettext('address', 'State'),
_('Invoice recipient:') + ' ' + _('VAT ID'), _('Invoice recipient:') + ' ' + _('VAT ID'),
_('Invoice recipient:') + ' ' + _('Beneficiary'), _('Invoice recipient:') + ' ' + _('Beneficiary'),
_('Invoice recipient:') + ' ' + _('Internal reference'), _('Invoice recipient:') + ' ' + _('Internal reference'),
@@ -652,7 +622,6 @@ class InvoiceDataExporter(MultiSheetListExporter):
i.invoice_to_zipcode, i.invoice_to_zipcode,
i.invoice_to_city, i.invoice_to_city,
i.invoice_to_country, i.invoice_to_country,
i.invoice_to_state,
i.invoice_to_vat_id, i.invoice_to_vat_id,
i.invoice_to_beneficiary, i.invoice_to_beneficiary,
i.internal_reference, i.internal_reference,

View File

@@ -1,5 +1,6 @@
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth import authenticate
from django.contrib.auth.password_validation import ( from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password, password_validators_help_texts, validate_password,
) )
@@ -13,33 +14,32 @@ class LoginForm(forms.Form):
Base class for authenticating users. Extend this to get a form that accepts Base class for authenticating users. Extend this to get a form that accepts
username/password logins. username/password logins.
""" """
email = forms.EmailField(label=_("E-mail"), max_length=254)
password = forms.CharField(label=_("Password"), widget=forms.PasswordInput)
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False) keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
error_messages = { error_messages = {
'invalid_login': _("This combination of credentials is not known to our system."), 'invalid_login': _("Please enter a correct email address and password."),
'inactive': _("This account is inactive.") 'inactive': _("This account is inactive.")
} }
def __init__(self, backend, request=None, *args, **kwargs): def __init__(self, request=None, *args, **kwargs):
""" """
The 'request' parameter is set for custom auth use by subclasses. The 'request' parameter is set for custom auth use by subclasses.
The form data comes in via the standard 'data' kwarg. The form data comes in via the standard 'data' kwarg.
""" """
self.request = request self.request = request
self.user_cache = None self.user_cache = None
self.backend = backend
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
for k, f in backend.login_form_fields.items():
self.fields[k] = f
if not settings.PRETIX_LONG_SESSIONS: if not settings.PRETIX_LONG_SESSIONS:
del self.fields['keep_logged_in'] del self.fields['keep_logged_in']
else:
self.fields.move_to_end('keep_logged_in')
def clean(self): def clean(self):
if all(k in self.cleaned_data for k, f in self.fields.items() if f.required): email = self.cleaned_data.get('email')
self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data) password = self.cleaned_data.get('password')
if email and password:
self.user_cache = authenticate(request=self.request, email=email.lower(), password=password)
if self.user_cache is None: if self.user_cache is None:
raise forms.ValidationError( raise forms.ValidationError(
self.error_messages['invalid_login'], self.error_messages['invalid_login'],
@@ -181,44 +181,3 @@ class PasswordForgotForm(forms.Form):
def clean_email(self): def clean_email(self):
return self.cleaned_data['email'] return self.cleaned_data['email']
class ReauthForm(forms.Form):
error_messages = {
'invalid_login': _("This combination of credentials is not known to our system."),
'inactive': _("This account is inactive.")
}
def __init__(self, backend, user, request=None, *args, **kwargs):
"""
The 'request' parameter is set for custom auth use by subclasses.
The form data comes in via the standard 'data' kwarg.
"""
self.request = request
self.user = user
self.backend = backend
super().__init__(*args, **kwargs)
for k, f in backend.login_form_fields.items():
self.fields[k] = f
if 'email' in self.fields:
self.fields['email'].disabled = True
def clean(self):
self.cleaned_data['email'] = self.user.email
user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
if user_cache != self.user:
raise forms.ValidationError(
self.error_messages['invalid_login'],
code='invalid_login'
)
else:
self.confirm_login_allowed(user_cache)
return self.cleaned_data
def confirm_login_allowed(self, user: User):
if not user.is_active:
raise forms.ValidationError(
self.error_messages['inactive'],
code='inactive',
)

View File

@@ -1,79 +1,44 @@
import copy import copy
import json
import logging import logging
from decimal import Decimal from decimal import Decimal
from urllib.error import HTTPError
import dateutil.parser import dateutil.parser
import pycountry
import pytz import pytz
import vat_moss.errors import vat_moss.errors
import vat_moss.id import vat_moss.id
from babel import localedata
from django import forms from django import forms
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import QuerySet
from django.forms import Select
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import ( from django.utils.translation import ugettext_lazy as _
get_language, pgettext_lazy, ugettext_lazy as _,
)
from django_countries import countries
from django_countries.fields import Country, CountryField
from phonenumber_field.formfields import PhoneNumberField
from phonenumber_field.phonenumber import PhoneNumber
from phonenumber_field.widgets import PhoneNumberPrefixWidget
from phonenumbers import NumberParseException
from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE
from pretix.base.forms.widgets import ( from pretix.base.forms.widgets import (
BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget, BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget,
TimePickerWidget, UploadedFileWidget, TimePickerWidget, UploadedFileWidget,
) )
from pretix.base.i18n import language
from pretix.base.models import InvoiceAddress, Question, QuestionOption from pretix.base.models import InvoiceAddress, Question, QuestionOption
from pretix.base.models.tax import EU_COUNTRIES, cc_to_vat_prefix from pretix.base.models.tax import EU_COUNTRIES
from pretix.base.settings import ( from pretix.base.settings import PERSON_NAME_SCHEMES
COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SCHEMES,
PERSON_NAME_TITLE_GROUPS,
)
from pretix.base.templatetags.rich_text import rich_text from pretix.base.templatetags.rich_text import rich_text
from pretix.control.forms import SplitDateTimeField from pretix.control.forms import SplitDateTimeField
from pretix.helpers.escapejson import escapejson_attr
from pretix.helpers.i18n import get_format_without_seconds from pretix.helpers.i18n import get_format_without_seconds
from pretix.presale.signals import question_form_fields from pretix.presale.signals import question_form_fields
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
REQUIRED_NAME_PARTS = ['given_name', 'family_name', 'full_name']
class NamePartsWidget(forms.MultiWidget): class NamePartsWidget(forms.MultiWidget):
widget = forms.TextInput widget = forms.TextInput
autofill_map = {
'given_name': 'given-name',
'family_name': 'family-name',
'middle_name': 'additional-name',
'title': 'honorific-prefix',
'full_name': 'name',
'calling_name': 'nickname',
}
def __init__(self, scheme: dict, field: forms.Field, attrs=None, titles: list=None): def __init__(self, scheme: dict, field: forms.Field, attrs=None):
widgets = [] widgets = []
self.scheme = scheme self.scheme = scheme
self.field = field self.field = field
self.titles = titles
for fname, label, size in self.scheme['fields']: for fname, label, size in self.scheme['fields']:
a = copy.copy(attrs) or {} a = copy.copy(attrs) or {}
a['data-fname'] = fname a['data-fname'] = fname
if fname == 'title' and self.titles: widgets.append(self.widget(attrs=a))
widgets.append(Select(attrs=a, choices=[('', '')] + [(d, d) for d in self.titles[1]]))
else:
widgets.append(self.widget(attrs=a))
super().__init__(widgets, attrs) super().__init__(widgets, attrs)
def decompress(self, value): def decompress(self, value):
@@ -101,21 +66,14 @@ class NamePartsWidget(forms.MultiWidget):
except (IndexError, TypeError): except (IndexError, TypeError):
widget_value = None widget_value = None
if id_: if id_:
these_attrs = dict( final_attrs = dict(
final_attrs, final_attrs,
id='%s_%s' % (id_, i), id='%s_%s' % (id_, i),
title=self.scheme['fields'][i][1], title=self.scheme['fields'][i][1],
placeholder=self.scheme['fields'][i][1], placeholder=self.scheme['fields'][i][1],
) )
if self.scheme['fields'][i][0] in REQUIRED_NAME_PARTS: final_attrs['data-size'] = self.scheme['fields'][i][2]
if self.field.required: output.append(widget.render(name + '_%s' % i, widget_value, final_attrs, renderer=renderer))
these_attrs['required'] = 'required'
these_attrs.pop('data-no-required-attr', None)
these_attrs['autocomplete'] = (self.attrs.get('autocomplete', '') + ' ' + self.autofill_map.get(self.scheme['fields'][i][0], 'off')).strip()
these_attrs['data-size'] = self.scheme['fields'][i][2]
else:
these_attrs = final_attrs
output.append(widget.render(name + '_%s' % i, widget_value, these_attrs, renderer=renderer))
return mark_safe(self.format_output(output)) return mark_safe(self.format_output(output))
def format_output(self, rendered_widgets) -> str: def format_output(self, rendered_widgets) -> str:
@@ -139,34 +97,19 @@ class NamePartsFormField(forms.MultiValueField):
'max_length': kwargs.pop('max_length', None), 'max_length': kwargs.pop('max_length', None),
} }
self.scheme_name = kwargs.pop('scheme') self.scheme_name = kwargs.pop('scheme')
self.titles = kwargs.pop('titles')
self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name) self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name)
if self.titles:
self.scheme_titles = PERSON_NAME_TITLE_GROUPS.get(self.titles)
else:
self.scheme_titles = None
self.one_required = kwargs.get('required', True) self.one_required = kwargs.get('required', True)
require_all_fields = kwargs.pop('require_all_fields', False) require_all_fields = kwargs.pop('require_all_fields', False)
kwargs['required'] = False kwargs['required'] = False
kwargs['widget'] = (kwargs.get('widget') or self.widget)( kwargs['widget'] = (kwargs.get('widget') or self.widget)(
scheme=self.scheme, titles=self.scheme_titles, field=self, **kwargs.pop('widget_kwargs', {}) scheme=self.scheme, field=self, **kwargs.pop('widget_kwargs', {})
) )
defaults.update(**kwargs) defaults.update(**kwargs)
for fname, label, size in self.scheme['fields']: for fname, label, size in self.scheme['fields']:
defaults['label'] = label defaults['label'] = label
if fname == 'title' and self.scheme_titles: field = forms.CharField(**defaults)
d = dict(defaults) field.part_name = fname
d.pop('max_length', None) fields.append(field)
field = forms.ChoiceField(
**d,
choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]]
)
field.part_name = fname
fields.append(field)
else:
field = forms.CharField(**defaults)
field.part_name = fname
fields.append(field)
super().__init__( super().__init__(
fields=fields, require_all_fields=False, *args, **kwargs fields=fields, require_all_fields=False, *args, **kwargs
) )
@@ -175,45 +118,13 @@ class NamePartsFormField(forms.MultiValueField):
def clean(self, value) -> dict: def clean(self, value) -> dict:
value = super().clean(value) value = super().clean(value)
if self.one_required and (not value or not any(v for v in value.values())): if self.one_required and (not value or not any(v for v in value)):
raise forms.ValidationError(self.error_messages['required'], code='required') raise forms.ValidationError(self.error_messages['required'], code='required')
if self.one_required:
for k, v in value.items():
if k in REQUIRED_NAME_PARTS and not v:
raise forms.ValidationError(self.error_messages['required'], code='required')
if self.require_all_fields and not all(v for v in value): if self.require_all_fields and not all(v for v in value):
raise forms.ValidationError(self.error_messages['incomplete'], code='required') raise forms.ValidationError(self.error_messages['incomplete'], code='required')
return value return value
class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
def render(self, name, value, attrs=None, renderer=None):
output = super().render(name, value, attrs, 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)
def guess_country(event):
# Try to guess the initial country from either the country of the merchant
# or the locale. This will hopefully save at least some users some scrolling :)
locale = get_language()
country = event.settings.invoice_address_from_country
if not country:
valid_countries = countries.countries
if '-' in locale:
parts = locale.split('-')
if parts[1].upper() in valid_countries:
country = Country(parts[1].upper())
elif parts[0].upper() in valid_countries:
country = Country(parts[0].upper())
else:
if locale in valid_countries:
country = Country(locale.upper())
return country
class BaseQuestionsForm(forms.Form): class BaseQuestionsForm(forms.Form):
""" """
This form class is responsible for asking order-related questions. This includes This form class is responsible for asking order-related questions. This includes
@@ -243,7 +154,6 @@ class BaseQuestionsForm(forms.Form):
max_length=255, max_length=255,
required=event.settings.attendee_names_required, required=event.settings.attendee_names_required,
scheme=event.settings.name_scheme, scheme=event.settings.name_scheme,
titles=event.settings.name_scheme_titles,
label=_('Attendee name'), label=_('Attendee name'),
initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts), initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts),
) )
@@ -251,12 +161,7 @@ class BaseQuestionsForm(forms.Form):
self.fields['attendee_email'] = forms.EmailField( self.fields['attendee_email'] = forms.EmailField(
required=event.settings.attendee_emails_required, required=event.settings.attendee_emails_required,
label=_('Attendee email'), label=_('Attendee email'),
initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email), initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email)
widget=forms.EmailInput(
attrs={
'autocomplete': 'email'
}
)
) )
for q in questions: for q in questions:
@@ -308,14 +213,6 @@ class BaseQuestionsForm(forms.Form):
widget=forms.Textarea, widget=forms.Textarea,
initial=initial.answer if initial else None, initial=initial.answer if initial else None,
) )
elif q.type == Question.TYPE_COUNTRYCODE:
field = CountryField().formfield(
label=label, required=required,
help_text=help_text,
widget=forms.Select,
empty_label='',
initial=initial.answer if initial else None,
)
elif q.type == Question.TYPE_CHOICE: elif q.type == Question.TYPE_CHOICE:
field = forms.ModelChoiceField( field = forms.ModelChoiceField(
queryset=q.options, queryset=q.options,
@@ -363,32 +260,6 @@ class BaseQuestionsForm(forms.Form):
initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None, 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')), widget=SplitDateTimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
) )
elif q.type == Question.TYPE_PHONENUMBER:
babel_locale = 'en'
# Babel, and therefore django-phonenumberfield, do not support our custom locales such das de_Informal
if localedata.exists(get_language()):
babel_locale = get_language()
elif localedata.exists(get_language()[:2]):
babel_locale = get_language()[:2]
with language(babel_locale):
default_country = guess_country(event)
default_prefix = None
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
if str(default_country) in values:
default_prefix = prefix
try:
initial = PhoneNumber().from_string(initial.answer) if initial else "+{}.".format(default_prefix)
except NumberParseException:
initial = None
field = PhoneNumberField(
label=label, required=required,
help_text=help_text,
# We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just
# a country code but no number as an initial value. It's a bit hacky, but should be stable for
# the future.
initial=initial,
widget=WrappedPhoneNumberPrefixWidget()
)
field.question = q field.question = q
if answers: if answers:
# Cache the answer object for later use # Cache the answer object for later use
@@ -396,7 +267,7 @@ class BaseQuestionsForm(forms.Form):
if q.dependency_question_id: if q.dependency_question_id:
field.widget.attrs['data-question-dependency'] = q.dependency_question_id field.widget.attrs['data-question-dependency'] = q.dependency_question_id
field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(q.dependency_values)) field.widget.attrs['data-question-dependency-value'] = q.dependency_value
if q.type != 'M': if q.type != 'M':
field.widget.attrs['required'] = q.required and not self.all_optional field.widget.attrs['required'] = q.required and not self.all_optional
field._required = q.required and not self.all_optional field._required = q.required and not self.all_optional
@@ -412,35 +283,31 @@ class BaseQuestionsForm(forms.Form):
self.fields[key] = value self.fields[key] = value
value.initial = data.get('question_form_data', {}).get(key) value.initial = data.get('question_form_data', {}).get(key)
for k, v in self.fields.items():
if v.widget.attrs.get('autocomplete') or k == 'attendee_name_parts':
v.widget.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + v.widget.attrs.get('autocomplete', '')
def clean(self): def clean(self):
d = super().clean() d = super().clean()
question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)} question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)}
def question_is_visible(parentid, qvals): def question_is_visible(parentid, qval):
if parentid not in question_cache:
return False
parentq = question_cache[parentid] parentq = question_cache[parentid]
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values): if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_value):
return False return False
if 'question_%d' % parentid not in d: if 'question_%d' % parentid not in d:
return False return False
dval = d.get('question_%d' % parentid) dval = d.get('question_%d' % parentid)
return ( if qval == 'True':
('True' in qvals and dval) return dval
or ('False' in qvals and not dval) elif qval == 'False':
or (isinstance(dval, QuestionOption) and dval.identifier in qvals) return not dval
or (isinstance(dval, (list, QuerySet)) and any(qval in [o.identifier for o in dval] for qval in qvals)) elif isinstance(dval, QuestionOption):
) return dval.identifier == qval
else:
return qval in [o.identifier for o in dval]
def question_is_required(q): def question_is_required(q):
return ( return (
q.required and q.required and
(not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values)) (not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_value))
) )
if not self.all_optional: if not self.all_optional:
@@ -456,29 +323,13 @@ class BaseInvoiceAddressForm(forms.ModelForm):
class Meta: class Meta:
model = InvoiceAddress model = InvoiceAddress
fields = ('is_business', 'company', 'name_parts', 'street', 'zipcode', 'city', 'country', 'state', fields = ('is_business', 'company', 'name_parts', 'street', 'zipcode', 'city', 'country', 'vat_id',
'vat_id', 'internal_reference', 'beneficiary') 'internal_reference', 'beneficiary')
widgets = { widgets = {
'is_business': BusinessBooleanRadio, 'is_business': BusinessBooleanRadio,
'street': forms.Textarea(attrs={ 'street': forms.Textarea(attrs={'rows': 2, 'placeholder': _('Street and Number')}),
'rows': 2,
'placeholder': _('Street and Number'),
'autocomplete': 'street-address'
}),
'beneficiary': forms.Textarea(attrs={'rows': 3}), 'beneficiary': forms.Textarea(attrs={'rows': 3}),
'country': forms.Select(attrs={ 'company': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
'autocomplete': 'country',
}),
'zipcode': forms.TextInput(attrs={
'autocomplete': 'postal-code',
}),
'city': forms.TextInput(attrs={
'autocomplete': 'address-level2',
}),
'company': forms.TextInput(attrs={
'data-display-dependency': '#id_is_business_1',
'autocomplete': 'organization',
}),
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}), 'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
'internal_reference': forms.TextInput, 'internal_reference': forms.TextInput,
} }
@@ -491,42 +342,10 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.request = kwargs.pop('request', None) self.request = kwargs.pop('request', None)
self.validate_vat_id = kwargs.pop('validate_vat_id') self.validate_vat_id = kwargs.pop('validate_vat_id')
self.all_optional = kwargs.pop('all_optional', False) self.all_optional = kwargs.pop('all_optional', False)
kwargs.setdefault('initial', {})
if not kwargs.get('instance') or not kwargs['instance'].country:
kwargs['initial']['country'] = guess_country(self.event)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if not event.settings.invoice_address_vatid: if not event.settings.invoice_address_vatid:
del self.fields['vat_id'] del self.fields['vat_id']
c = [('', pgettext_lazy('address', 'Select state'))]
fprefix = self.prefix + '-' if self.prefix else ''
cc = None
if fprefix + 'country' in self.data:
cc = str(self.data[fprefix + 'country'])
elif 'country' in self.initial:
cc = str(self.initial['country'])
elif self.instance and self.instance.country:
cc = str(self.instance.country)
if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS:
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1])
elif fprefix + 'state' in self.data:
self.data = self.data.copy()
del self.data[fprefix + 'state']
self.fields['state'] = forms.ChoiceField(
label=pgettext_lazy('address', 'State'),
required=False,
choices=c,
widget=forms.Select(attrs={
'autocomplete': 'address-level1',
}),
)
self.fields['state'].widget.is_required = True
if not event.settings.invoice_address_required or self.all_optional: if not event.settings.invoice_address_required or self.all_optional:
for k, f in self.fields.items(): for k, f in self.fields.items():
f.required = False f.required = False
@@ -548,23 +367,17 @@ class BaseInvoiceAddressForm(forms.ModelForm):
max_length=255, max_length=255,
required=event.settings.invoice_name_required and not self.all_optional, required=event.settings.invoice_name_required and not self.all_optional,
scheme=event.settings.name_scheme, scheme=event.settings.name_scheme,
titles=event.settings.name_scheme_titles,
label=_('Name'), label=_('Name'),
initial=(self.instance.name_parts if self.instance else self.instance.name_parts), 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 and not self.all_optional: if event.settings.invoice_address_required and not event.settings.invoice_address_company_required and not self.all_optional:
if not event.settings.invoice_name_required: self.fields['name_parts'].widget.attrs['data-required-if'] = '#id_is_business_0'
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['name_parts'].widget.attrs['data-no-required-attr'] = '1'
self.fields['company'].widget.attrs['data-required-if'] = '#id_is_business_1' self.fields['company'].widget.attrs['data-required-if'] = '#id_is_business_1'
if not event.settings.invoice_address_beneficiary: if not event.settings.invoice_address_beneficiary:
del self.fields['beneficiary'] del self.fields['beneficiary']
for k, v in self.fields.items():
if v.widget.attrs.get('autocomplete') or k == 'name_parts':
v.widget.attrs['autocomplete'] = 'section-invoice billing ' + v.widget.attrs.get('autocomplete', '')
def clean(self): def clean(self):
data = self.cleaned_data data = self.cleaned_data
if not data.get('is_business'): if not data.get('is_business'):
@@ -578,22 +391,12 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if 'vat_id' in self.changed_data or not data.get('vat_id'): if 'vat_id' in self.changed_data or not data.get('vat_id'):
self.instance.vat_id_validated = False self.instance.vat_id_validated = False
if data.get('city') and data.get('country') and str(data['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS:
if not data.get('state'):
self.add_error('state', _('This field is required.'))
self.instance.name_parts = data.get('name_parts') self.instance.name_parts = data.get('name_parts')
if all(
not v for k, v in data.items() if k not in ('is_business', 'country', 'name_parts')
) and len(data.get('name_parts', {})) == 1:
# Do not save the country if it is the only field set -- we don't know the user even checked it!
self.cleaned_data['country'] = ''
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data: if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
pass pass
elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'): elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'):
if data.get('vat_id')[:2] != cc_to_vat_prefix(str(data.get('country'))): if data.get('vat_id')[:2] != str(data.get('country')):
raise ValidationError(_('Your VAT ID does not match the selected country.')) raise ValidationError(_('Your VAT ID does not match the selected country.'))
try: try:
result = vat_moss.id.validate(data.get('vat_id')) result = vat_moss.id.validate(data.get('vat_id'))
@@ -611,7 +414,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
'your country is currently not available. We will therefore ' 'your country is currently not available. We will therefore '
'need to charge VAT on your invoice. You can get the tax amount ' 'need to charge VAT on your invoice. You can get the tax amount '
'back via the VAT reimbursement process.')) 'back via the VAT reimbursement process.'))
except (vat_moss.errors.WebServiceError, HTTPError): except vat_moss.errors.WebServiceError:
logger.exception('VAT ID checking failed for country {}'.format(data.get('country'))) logger.exception('VAT ID checking failed for country {}'.format(data.get('country')))
self.instance.vat_id_validated = False self.instance.vat_id_validated = False
if self.request and self.vat_warning: if self.request and self.vat_warning:
@@ -624,8 +427,9 @@ class BaseInvoiceAddressForm(forms.ModelForm):
class BaseInvoiceNameForm(BaseInvoiceAddressForm): class BaseInvoiceNameForm(BaseInvoiceAddressForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
for f in list(self.fields.keys()): for f in list(self.fields.keys()):
if f != 'name_parts': if f != 'name':
del self.fields[f] del self.fields[f]

View File

@@ -56,11 +56,6 @@ class UserSettingsForm(forms.ModelForm):
self.user = kwargs.pop('user') self.user = kwargs.pop('user')
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['email'].required = True self.fields['email'].required = True
if self.user.auth_backend != 'native':
del self.fields['old_pw']
del self.fields['new_pw']
del self.fields['new_pw_repeat']
self.fields['email'].disabled = True
def clean_old_pw(self): def clean_old_pw(self):
old_pw = self.cleaned_data.get('old_pw') old_pw = self.cleaned_data.get('old_pw')
@@ -120,5 +115,5 @@ class User2FADeviceAddForm(forms.Form):
name = forms.CharField(label=_('Device name'), max_length=64) name = forms.CharField(label=_('Device name'), max_length=64)
devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=( devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=(
('totp', _('Smartphone with the Authenticator application')), ('totp', _('Smartphone with the Authenticator application')),
('webauthn', _('WebAuthn-compatible hardware token (e.g. Yubikey)')), ('u2f', _('U2F-compatible hardware token (e.g. Yubikey)')),
)) ))

View File

@@ -26,7 +26,7 @@ class PlaceholderValidator(BaseValidator):
if value.count('{') != value.count('}'): if value.count('{') != value.count('}'):
raise ValidationError( raise ValidationError(
_('Invalid placeholder syntax: You used a different number of "{" than of "}".'), _('Invalid placeholder syntax: You used a different number of "{" than of "}".'),
code='invalid_placeholder_syntax', code='invalid',
) )
data_placeholders = list(re.findall(r'({[^}]*})', value, re.X)) data_placeholders = list(re.findall(r'({[^}]*})', value, re.X))
@@ -37,7 +37,7 @@ class PlaceholderValidator(BaseValidator):
if invalid_placeholders: if invalid_placeholders:
raise ValidationError( raise ValidationError(
_('Invalid placeholder(s): %(value)s'), _('Invalid placeholder(s): %(value)s'),
code='invalid_placeholders', code='invalid',
params={'value': ", ".join(invalid_placeholders,)}) params={'value': ", ".join(invalid_placeholders,)})
def clean(self, x): def clean(self, x):

View File

@@ -46,14 +46,6 @@ class TimePickerWidget(forms.TimeInput):
class UploadedFileWidget(forms.ClearableFileInput): class UploadedFileWidget(forms.ClearableFileInput):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# Browsers can't recognize that the server already has a file uploaded
# Don't mark this input as being required if we already have an answer
# (this needs to be done via the attrs, otherwise we wouldn't get the "required" star on the field label)
attrs = kwargs.get('attrs', {})
if kwargs.get('required') and kwargs.get('initial'):
attrs.update({'required': None})
kwargs.update({'attrs': attrs})
self.position = kwargs.pop('position') self.position = kwargs.pop('position')
self.event = kwargs.pop('event') self.event = kwargs.pop('event')
self.answer = kwargs.pop('answer') self.answer = kwargs.pop('answer')

View File

@@ -9,17 +9,14 @@ import vat_moss.exchange_rates
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.formats import date_format, localize from django.utils.formats import date_format, localize
from django.utils.translation import ( from django.utils.translation import get_language, pgettext, ugettext
get_language, pgettext, ugettext, ugettext_lazy,
)
from PIL.Image import BICUBIC from PIL.Image import BICUBIC
from reportlab.lib import pagesizes from reportlab.lib import pagesizes
from reportlab.lib.enums import TA_LEFT, TA_RIGHT from reportlab.lib.enums import TA_LEFT
from reportlab.lib.styles import ParagraphStyle, StyleSheet1 from reportlab.lib.styles import ParagraphStyle, StyleSheet1
from reportlab.lib.units import mm from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen.canvas import Canvas from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import ( from reportlab.platypus import (
@@ -125,7 +122,6 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
""" """
stylesheet = StyleSheet1() stylesheet = StyleSheet1()
stylesheet.add(ParagraphStyle(name='Normal', fontName=self.font_regular, fontSize=10, leading=12)) stylesheet.add(ParagraphStyle(name='Normal', fontName=self.font_regular, fontSize=10, leading=12))
stylesheet.add(ParagraphStyle(name='InvoiceFrom', parent=stylesheet['Normal']))
stylesheet.add(ParagraphStyle(name='Heading1', fontName=self.font_bold, fontSize=15, leading=15 * 1.2)) stylesheet.add(ParagraphStyle(name='Heading1', fontName=self.font_bold, fontSize=15, leading=15 * 1.2))
stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName=self.font_bold, fontSize=8, leading=12)) stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName=self.font_bold, fontSize=8, leading=12))
stylesheet.add(ParagraphStyle(name='Fineprint', fontName=self.font_regular, fontSize=8, leading=10)) stylesheet.add(ParagraphStyle(name='Fineprint', fontName=self.font_regular, fontSize=8, leading=10))
@@ -258,64 +254,49 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
canvas.restoreState() canvas.restoreState()
invoice_to_width = 85 * mm
invoice_to_height = 50 * mm
invoice_to_left = 25 * mm
invoice_to_top = 52 * mm
def _draw_invoice_to(self, canvas): def _draw_invoice_to(self, canvas):
p = Paragraph(self.invoice.address_invoice_to.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal']) p = Paragraph(self.invoice.invoice_to.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height) p.wrapOn(canvas, 85 * mm, 50 * mm)
p_size = p.wrap(self.invoice_to_width, self.invoice_to_height) p_size = p.wrap(85 * mm, 50 * mm)
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top) p.drawOn(canvas, 25 * mm, (297 - 52) * mm - p_size[1])
invoice_from_width = 70 * mm
invoice_from_height = 50 * mm
invoice_from_left = 25 * mm
invoice_from_top = 17 * mm
def _draw_invoice_from(self, canvas): def _draw_invoice_from(self, canvas):
p = Paragraph(self.invoice.full_invoice_from.strip().replace('\n', '<br />\n'), style=self.stylesheet[ p = Paragraph(self.invoice.full_invoice_from.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
'InvoiceFrom']) p.wrapOn(canvas, 70 * mm, 50 * mm)
p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height) p_size = p.wrap(70 * mm, 50 * mm)
p_size = p.wrap(self.invoice_from_width, self.invoice_from_height) p.drawOn(canvas, 25 * mm, (297 - 17) * mm - p_size[1])
p.drawOn(canvas, self.invoice_from_left, self.pagesize[1] - p_size[1] - self.invoice_from_top)
def _on_first_page(self, canvas: Canvas, doc):
canvas.setCreator('pretix.eu')
canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=self.invoice.number))
canvas.saveState()
canvas.setFont(self.font_regular, 8)
if self.invoice.order.testmode:
canvas.saveState()
canvas.setFont('OpenSansBd', 30)
canvas.setFillColorRGB(32, 0, 0)
canvas.drawRightString(self.pagesize[0] - 20 * mm, (297 - 100) * mm, ugettext('TEST MODE'))
canvas.restoreState()
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]):
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip())
def _draw_invoice_from_label(self, canvas):
textobject = canvas.beginText(25 * mm, (297 - 15) * mm) textobject = canvas.beginText(25 * mm, (297 - 15) * mm)
textobject.setFont(self.font_bold, 8) textobject.setFont(self.font_bold, 8)
textobject.textLine(self._upper(pgettext('invoice', 'Invoice from'))) textobject.textLine(self._upper(pgettext('invoice', 'Invoice from')))
canvas.drawText(textobject) canvas.drawText(textobject)
def _draw_invoice_to_label(self, canvas): self._draw_invoice_from(canvas)
textobject = canvas.beginText(25 * mm, (297 - 50) * mm) textobject = canvas.beginText(25 * mm, (297 - 50) * mm)
textobject.setFont(self.font_bold, 8) textobject.setFont(self.font_bold, 8)
textobject.textLine(self._upper(pgettext('invoice', 'Invoice to'))) textobject.textLine(self._upper(pgettext('invoice', 'Invoice to')))
canvas.drawText(textobject) canvas.drawText(textobject)
logo_width = 25 * mm self._draw_invoice_to(canvas)
logo_height = 25 * mm
logo_left = 95 * mm
logo_top = 13 * mm
logo_anchor = 'n'
def _draw_logo(self, canvas):
if self.invoice.event.settings.invoice_logo_image:
logo_file = self.invoice.event.settings.get('invoice_logo_image', binary_file=True)
ir = ThumbnailingImageReader(logo_file)
try:
ir.resize(self.logo_width, self.logo_height, 300)
except:
logger.exception("Can not resize image")
pass
canvas.drawImage(ir,
self.logo_left,
self.pagesize[1] - self.logo_height - self.logo_top,
width=self.logo_width, height=self.logo_height,
preserveAspectRatio=True, anchor=self.logo_anchor,
mask='auto')
def _draw_metadata(self, canvas):
textobject = canvas.beginText(125 * mm, (297 - 38) * mm) textobject = canvas.beginText(125 * mm, (297 - 38) * mm)
textobject.setFont(self.font_bold, 8) textobject.setFont(self.font_bold, 8)
textobject.textLine(self._upper(pgettext('invoice', 'Order code'))) textobject.textLine(self._upper(pgettext('invoice', 'Order code')))
@@ -367,37 +348,37 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
canvas.drawText(textobject) canvas.drawText(textobject)
event_left = 125 * mm if self.invoice.event.settings.invoice_logo_image:
event_top = 17 * mm logo_file = self.invoice.event.settings.get('invoice_logo_image', binary_file=True)
event_width = 65 * mm ir = ThumbnailingImageReader(logo_file)
event_height = 50 * mm try:
ir.resize(25 * mm, 25 * mm, 300)
except:
logger.exception("Can not resize image")
pass
canvas.drawImage(ir,
95 * mm, (297 - 38) * mm,
width=25 * mm, height=25 * mm,
preserveAspectRatio=True, anchor='n',
mask='auto')
def _draw_event_label(self, canvas):
textobject = canvas.beginText(125 * mm, (297 - 15) * mm)
textobject.setFont(self.font_bold, 8)
textobject.textLine(self._upper(pgettext('invoice', 'Event')))
canvas.drawText(textobject)
def _draw_event(self, canvas):
def shorten(txt): def shorten(txt):
txt = str(txt) txt = str(txt)
p = Paragraph(txt.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal']) p = Paragraph(txt.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p_size = p.wrap(self.event_width, self.event_height) p_size = p.wrap(65 * mm, 50 * mm)
while p_size[1] > 2 * self.stylesheet['Normal'].leading: while p_size[1] > 2 * self.stylesheet['Normal'].leading:
txt = ' '.join(txt.replace('', '').split()[:-1]) + '' txt = ' '.join(txt.replace('', '').split()[:-1]) + ''
p = Paragraph(txt.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal']) p = Paragraph(txt.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p_size = p.wrap(self.event_width, self.event_height) p_size = p.wrap(65 * mm, 50 * mm)
return txt return txt
if not self.invoice.event.has_subevents: if not self.invoice.event.has_subevents:
if self.invoice.event.settings.show_date_to and self.invoice.event.date_to: if self.invoice.event.settings.show_date_to and self.invoice.event.date_to:
p_str = ( p_str = (
shorten(self.invoice.event.name) + '\n' + shorten(self.invoice.event.name) + '\n' + pgettext('invoice', '{from_date}\nuntil {to_date}').format(
pgettext('invoice', '{from_date}\nuntil {to_date}').format(
from_date=self.invoice.event.get_date_from_display(), from_date=self.invoice.event.get_date_from_display(),
to_date=self.invoice.event.get_date_to_display() to_date=self.invoice.event.get_date_to_display())
)
) )
else: else:
p_str = ( p_str = (
@@ -407,38 +388,15 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
p_str = shorten(self.invoice.event.name) p_str = shorten(self.invoice.event.name)
p = Paragraph(p_str.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal']) p = Paragraph(p_str.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.event_width, self.event_height) p.wrapOn(canvas, 65 * mm, 50 * mm)
p_size = p.wrap(self.event_width, self.event_height) p_size = p.wrap(65 * mm, 50 * mm)
p.drawOn(canvas, self.event_left, self.pagesize[1] - self.event_top - p_size[1]) p.drawOn(canvas, 125 * mm, (297 - 17) * mm - p_size[1])
self._draw_event_label(canvas)
def _draw_footer(self, canvas): textobject = canvas.beginText(125 * mm, (297 - 15) * mm)
canvas.setFont(self.font_regular, 8) textobject.setFont(self.font_bold, 8)
for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]): textobject.textLine(self._upper(pgettext('invoice', 'Event')))
canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, line.strip()) canvas.drawText(textobject)
def _draw_testmode(self, canvas):
if self.invoice.order.testmode:
canvas.saveState()
canvas.setFont('OpenSansBd', 30)
canvas.setFillColorRGB(32, 0, 0)
canvas.drawRightString(self.pagesize[0] - 20 * mm, (297 - 100) * mm, ugettext('TEST MODE'))
canvas.restoreState()
def _on_first_page(self, canvas: Canvas, doc):
canvas.setCreator('pretix.eu')
canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=self.invoice.number))
canvas.saveState()
self._draw_footer(canvas)
self._draw_testmode(canvas)
self._draw_invoice_from_label(canvas)
self._draw_invoice_from(canvas)
self._draw_invoice_to_label(canvas)
self._draw_invoice_to(canvas)
self._draw_metadata(canvas)
self._draw_logo(canvas)
self._draw_event(canvas)
canvas.restoreState() canvas.restoreState()
def _get_first_page_frames(self, doc): def _get_first_page_frames(self, doc):
@@ -457,21 +415,25 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
id='normal') id='normal')
] ]
def _get_intro(self): def _get_story(self, doc):
story = [] has_taxes = any(il.tax_value for il in self.invoice.lines.all())
story = [
NextPageTemplate('FirstPage'),
Paragraph(pgettext('invoice', 'Invoice')
if not self.invoice.is_cancellation
else pgettext('invoice', 'Cancellation'),
self.stylesheet['Heading1']),
Spacer(1, 5 * mm),
NextPageTemplate('OtherPages'),
]
if self.invoice.internal_reference: if self.invoice.internal_reference:
story.append(Paragraph( story.append(Paragraph(
pgettext('invoice', 'Customer reference: {reference}').format(reference=self.invoice.internal_reference), pgettext('invoice', 'Customer reference: {reference}').format(reference=self.invoice.internal_reference),
self.stylesheet['Normal'] self.stylesheet['Normal']
)) ))
if self.invoice.invoice_to_vat_id:
story.append(Paragraph(
pgettext('invoice', 'Customer VAT ID') + ': ' +
bleach.clean(self.invoice.invoice_to_vat_id, tags=[]).replace("\n", "<br />\n"),
self.stylesheet['Normal']
))
if self.invoice.invoice_to_beneficiary: if self.invoice.invoice_to_beneficiary:
story.append(Paragraph( story.append(Paragraph(
pgettext('invoice', 'Beneficiary') + ':<br />' + pgettext('invoice', 'Beneficiary') + ':<br />' +
@@ -483,22 +445,6 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(Paragraph(self.invoice.introductory_text, self.stylesheet['Normal'])) story.append(Paragraph(self.invoice.introductory_text, self.stylesheet['Normal']))
story.append(Spacer(1, 10 * mm)) story.append(Spacer(1, 10 * mm))
return story
def _get_story(self, doc):
has_taxes = any(il.tax_value for il in self.invoice.lines.all())
story = [
NextPageTemplate('FirstPage'),
Paragraph(pgettext('invoice', 'Invoice')
if not self.invoice.is_cancellation
else pgettext('invoice', 'Cancellation'),
self.stylesheet['Heading1']),
Spacer(1, 5 * mm),
NextPageTemplate('OtherPages'),
]
story += self._get_intro()
taxvalue_map = defaultdict(Decimal) taxvalue_map = defaultdict(Decimal)
grossvalue_map = defaultdict(Decimal) grossvalue_map = defaultdict(Decimal)
@@ -608,7 +554,6 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
colwidths = [a * doc.width for a in (.25, .15, .15, .15, .3)] colwidths = [a * doc.width for a in (.25, .15, .15, .15, .3)]
table = Table(tdata, colWidths=colwidths, repeatRows=2, hAlign=TA_LEFT) table = Table(tdata, colWidths=colwidths, repeatRows=2, hAlign=TA_LEFT)
table.setStyle(TableStyle(tstyledata)) table.setStyle(TableStyle(tstyledata))
story.append(Spacer(5 * mm, 5 * mm))
story.append(KeepTogether([ story.append(KeepTogether([
Paragraph(pgettext('invoice', 'Included taxes'), self.stylesheet['FineprintHeading']), Paragraph(pgettext('invoice', 'Included taxes'), self.stylesheet['FineprintHeading']),
table table
@@ -662,114 +607,6 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
return story return story
class Modern1Renderer(ClassicInvoiceRenderer):
identifier = 'modern1'
verbose_name = ugettext_lazy('Modern Invoice Renderer (pretix 2.7)')
bottom_margin = 16.9 * mm
top_margin = 16.9 * mm
right_margin = 20 * mm
invoice_to_height = 27.3 * mm
invoice_to_width = 80 * mm
invoice_to_left = 25 * mm
invoice_to_top = (40 + 17.7) * mm
invoice_from_left = 125 * mm
invoice_from_top = 50 * mm
invoice_from_width = pagesizes.A4[0] - invoice_from_left - right_margin
invoice_from_height = 50 * mm
logo_width = 75 * mm
logo_height = 25 * mm
logo_left = pagesizes.A4[0] - logo_width - right_margin
logo_top = top_margin
logo_anchor = 'e'
event_left = 25 * mm
event_top = top_margin
event_width = 80 * mm
event_height = 25 * mm
def _get_stylesheet(self):
stylesheet = super()._get_stylesheet()
stylesheet.add(ParagraphStyle(name='Sender', fontName=self.font_regular, fontSize=8, leading=10))
stylesheet['InvoiceFrom'].alignment = TA_RIGHT
return stylesheet
def _draw_invoice_from(self, canvas):
if not self.invoice.invoice_from:
return
c = self.invoice.address_invoice_from.strip().split('\n')
p = Paragraph(' · '.join(c), style=self.stylesheet['Sender'])
p.wrapOn(canvas, self.invoice_to_width, 15.7 * mm)
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - self.invoice_to_top + 2 * mm)
super()._draw_invoice_from(canvas)
def _draw_invoice_to_label(self, canvas):
pass
def _draw_invoice_from_label(self, canvas):
pass
def _draw_event_label(self, canvas):
pass
def _get_first_page_frames(self, doc):
footer_length = 3.5 * len(self.invoice.footer_text.split('\n')) * mm
return [
Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - 95 * mm,
leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length,
id='normal')
]
def _draw_metadata(self, canvas):
begin_top = 100 * mm
textobject = canvas.beginText(self.left_margin, self.pagesize[1] - begin_top)
textobject.setFont(self.font_regular, 8)
textobject.textLine(pgettext('invoice', 'Order code'))
textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10)
textobject.textLine(self.invoice.order.full_code)
canvas.drawText(textobject)
if self.invoice.is_cancellation:
textobject = canvas.beginText(self.left_margin + 50 * mm, self.pagesize[1] - begin_top)
textobject.setFont(self.font_regular, 8)
textobject.textLine(pgettext('invoice', 'Cancellation number'))
textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10)
textobject.textLine(self.invoice.number)
canvas.drawText(textobject)
textobject = canvas.beginText(self.left_margin + 100 * mm, self.pagesize[1] - begin_top)
textobject.setFont(self.font_regular, 8)
textobject.textLine(pgettext('invoice', 'Original invoice'))
textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10)
textobject.textLine(self.invoice.refers.number)
canvas.drawText(textobject)
else:
textobject = canvas.beginText(self.left_margin + 70 * mm, self.pagesize[1] - begin_top)
textobject.textLine(pgettext('invoice', 'Invoice number'))
textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10)
textobject.textLine(self.invoice.number)
canvas.drawText(textobject)
p = Paragraph(date_format(self.invoice.date, "DATE_FORMAT"), style=self.stylesheet['Normal'])
w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize)
p.wrapOn(canvas, w, 15 * mm)
date_x = self.pagesize[0] - w - self.right_margin
p.drawOn(canvas, date_x, self.pagesize[1] - begin_top - 10 - 6)
textobject = canvas.beginText(date_x, self.pagesize[1] - begin_top)
textobject.setFont(self.font_regular, 8)
if self.invoice.is_cancellation:
textobject.textLine(pgettext('invoice', 'Cancellation date'))
else:
textobject.textLine(pgettext('invoice', 'Invoice date'))
canvas.drawText(textobject)
@receiver(register_invoice_renderers, dispatch_uid="invoice_renderer_classic") @receiver(register_invoice_renderers, dispatch_uid="invoice_renderer_classic")
def recv_classic(sender, **kwargs): def recv_classic(sender, **kwargs):
return [ClassicInvoiceRenderer, Modern1Renderer] return ClassicInvoiceRenderer

View File

@@ -1,58 +0,0 @@
import json
import sys
from django.core.management.base import BaseCommand
from django.utils.timezone import override
from django_scopes import scope
from pretix.base.i18n import language
from pretix.base.models import Event, Organizer
from pretix.base.signals import register_data_exporters
class Command(BaseCommand):
help = "Run an exporter to get data out of pretix"
def add_arguments(self, parser):
parser.add_argument('organizer_slug', nargs=1, type=str)
parser.add_argument('event_slug', nargs=1, type=str)
parser.add_argument('export_provider', nargs=1, type=str)
parser.add_argument('output_file', nargs=1, type=str)
parser.add_argument('--parameters', action='store', type=str, help='JSON-formatted parameters')
def handle(self, *args, **options):
try:
o = Organizer.objects.get(slug=options['organizer_slug'][0])
except Organizer.DoesNotExist:
self.stderr.write(self.style.ERROR('Organizer not found.'))
sys.exit(1)
with scope(organizer=o):
try:
e = o.events.get(slug=options['event_slug'][0])
except Event.DoesNotExist:
self.stderr.write(self.style.ERROR('Event not found.'))
sys.exit(1)
with language(e.settings.locale), override(e.settings.timezone):
responses = register_data_exporters.send(e)
for receiver, response in responses:
ex = response(e)
if ex.identifier == options['export_provider'][0]:
params = json.loads(options.get('parameters') or '{}')
with open(options['output_file'][0], 'wb') as f:
try:
ex.render(form_data=params, output_file=f)
except TypeError:
self.stderr.write(self.style.WARNING(
'Provider does not support direct file writing, need to buffer export in memory.'))
d = ex.render(form_data=params)
if d is None:
self.stderr.write(self.style.ERROR('Empty export.'))
sys.exit(2)
f.write(d[2])
sys.exit(0)
self.stderr.write(self.style.ERROR('Export provider not found.'))
sys.exit(1)

View File

@@ -1,51 +0,0 @@
"""
Django, for theoretically very valid reasons, creates migrations for *every single thing*
we change on a model. Even the `help_text`! This makes sense, as we don't know if any
database backend unknown to us might actually use this information for its database schema.
However, pretix only supports PostgreSQL, MySQL, MariaDB and SQLite and we can be pretty
certain that some changes to models will never require a change to the database. In this case,
not creating a migration for certain changes will save us some performance while applying them
*and* allow for a cleaner git history. Win-win!
Only caveat is that we need to do some dirty monkeypatching to achieve it...
"""
from django.core.management.commands.makemigrations import Command as Parent
from django.db import models
from django.db.migrations.operations import models as modelops
from django_countries.fields import CountryField
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name_plural")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("ordering")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("get_latest_by")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_manager_name")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("permissions")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_permissions")
IGNORED_ATTRS = [
# (field type, attribute name, banlist of field sub-types)
(models.Field, 'verbose_name', []),
(models.Field, 'help_text', []),
(models.Field, 'validators', []),
(models.Field, 'editable', [models.DateField, models.DateTimeField, models.DateField, models.BinaryField]),
(models.Field, 'blank', [models.DateField, models.DateTimeField, models.AutoField, models.NullBooleanField,
models.TimeField]),
(models.CharField, 'choices', [CountryField])
]
original_deconstruct = models.Field.deconstruct
def new_deconstruct(self):
name, path, args, kwargs = original_deconstruct(self)
for ftype, attr, banlist in IGNORED_ATTRS:
if isinstance(self, ftype) and not any(isinstance(self, ft) for ft in banlist):
kwargs.pop(attr, None)
return name, path, args, kwargs
models.Field.deconstruct = new_deconstruct
class Command(Parent):
pass

View File

@@ -1,28 +0,0 @@
"""
Django tries to be helpful by suggesting to run "makemigrations" in red font on every "migrate"
run when there are things we have no migrations for. Usually, this is intended, and running
"makemigrations" can really screw up the environment of a user, so we want to prevent novice
users from doing that by going really dirty and filtering it from the output.
"""
import sys
from django.core.management.base import OutputWrapper
from django.core.management.commands.migrate import Command as Parent
class OutputFilter(OutputWrapper):
banlist = (
"Your models have changes that are not yet reflected",
"Run 'manage.py makemigrations' to make new "
)
def write(self, msg, style_func=None, ending=None):
if any(b in msg for b in self.banlist):
return
super().write(msg, style_func, ending)
class Command(Parent):
def __init__(self, stdout=None, stderr=None, no_color=False, force_color=False):
super().__init__(stdout, stderr, no_color, force_color)
self.stdout = OutputFilter(stdout or sys.stdout)

View File

@@ -1,4 +1,3 @@
from django.conf import settings
from django.core.management import call_command from django.core.management import call_command
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
@@ -9,12 +8,5 @@ class Command(BaseCommand):
help = "Run periodic tasks" help = "Run periodic tasks"
def handle(self, *args, **options): def handle(self, *args, **options):
for recv, resp in periodic_task.send_robust(self): periodic_task.send(self)
if isinstance(resp, Exception):
if settings.SENTRY_ENABLED:
from sentry_sdk import capture_exception
capture_exception(resp)
else:
raise resp
call_command('clearsessions') call_command('clearsessions')

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