Compare commits

..

2 Commits

Author SHA1 Message Date
Raphael Michel
05e9d09e1f Don't cache files globally 2019-03-07 18:14:43 +01:00
Raphael Michel
93947cace0 Disable locking (just for a test) 2019-03-07 17:55:04 +01:00
314 changed files with 33156 additions and 166249 deletions

View File

@@ -1,5 +1,4 @@
language: python
dist: xenial
sudo: false
install:
- pip install -U pip wheel setuptools
@@ -13,23 +12,23 @@ services:
- postgresql
matrix:
include:
- python: 3.7
- python: 3.6
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg
- python: 3.7
- python: 3.6
env: JOB=tests-cov PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.7
- python: 3.6
env: JOB=style
- python: 3.7
- python: 3.6
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
- python: 3.7
- python: 3.6
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.5
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.7
- python: 3.6
env: JOB=plugins
- python: 3.7
- python: 3.6
env: JOB=doc-spelling
- python: 3.7
- python: 3.6
env: JOB=translation-spelling
addons:
postgresql: "9.4"

View File

@@ -125,8 +125,6 @@ Example::
Indicates if the database backend is a MySQL/MariaDB Galera cluster and
turns on some optimizations/special case handlers. Default: ``False``
.. _`config-replica`:
Database replica settings
-------------------------
@@ -144,8 +142,6 @@ Example::
[replica]
host=192.168.0.2
.. _`config-urls`:
URLs
----

View File

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

View File

@@ -58,29 +58,16 @@ Database
--------
Next, we need a database and a database user. We can create these with any kind of database managing tool or directly on
our database's shell. For PostgreSQL, we would do::
our database's shell, e.g. for MySQL::
# sudo -u postgres createuser -P pretix
# sudo -u postgres createdb -O pretix pretix
$ mysql -u root -p
mysql> CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
mysql> GRANT ALL PRIVILEGES ON pretix.* TO pretix@'localhost' IDENTIFIED BY '*********';
mysql> FLUSH PRIVILEGES;
Make sure that your database listens on the network. If PostgreSQL on the same same host as docker, but not inside a docker container, we recommend that you just listen on the Docker interface by changing the following line in ``/etc/postgresql/<version>/main/postgresql.conf``::
listen_addresses = 'localhost,172.17.0.1'
You also need to add a new line to ``/etc/postgresql/<version>/main/pg_hba.conf`` to allow network connections to this user and database::
host pretix pretix 172.17.0.1/16 md5
Restart PostgreSQL after you changed these files::
# systemctl restart postgresql
If you have a firewall running, you should also make sure that port 5432 is reachable from the ``172.17.0.1/16`` subnet.
For MySQL, you can either also use network-based connections or mount the ``/var/run/mysqld/mysqld.sock`` socket into the docker container.
When using MySQL, make sure you set the character set of the database to ``utf8mb4``, e.g. like this::
mysql > CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
Replace the asterisks with a password of your own. For MySQL, we will use a unix domain socket to connect to the
database. For PostgreSQL, be sure to configure the interface binding and your firewall so that the docker container
can reach PostgreSQL.
Redis
-----
@@ -127,16 +114,13 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
datadir=/data
[database]
; Replace postgresql with mysql for MySQL
backend=postgresql
; Replace mysql with postgresql_psycopg2 for PostgreSQL
backend=mysql
name=pretix
user=pretix
; Replace with the password you chose above
password=*********
; In most docker setups, 172.17.0.1 is the address of the docker host. Adjuts
; this to wherever your database is running, e.g. the name of a linked container
; or of a mounted MySQL socket.
host=172.17.0.1
; Replace with host IP address for PostgreSQL
host=/var/run/mysqld/mysqld.sock
[mail]
; See config file documentation for more options
@@ -180,15 +164,14 @@ named ``/etc/systemd/system/pretix.service`` with the following content::
-v /var/pretix-data:/data \
-v /etc/pretix:/etc/pretix \
-v /var/run/redis:/var/run/redis \
-v /var/run/mysqld:/var/run/mysqld \
pretix/standalone:stable all
ExecStop=/usr/bin/docker stop %n
[Install]
WantedBy=multi-user.target
When using MySQL and socket mounting, you'll need the additional flag ``-v /var/run/mysqld:/var/run/mysqld`` in the command.
You can now run the following commands
You can leave the MySQL socket volume out if you're using PostgreSQL. You can now run the following commands
to enable and start the service::
# systemctl daemon-reload

View File

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

View File

@@ -50,23 +50,21 @@ Database
--------
Having the database server installed, we still need a database and a database user. We can create these with any kind
of database managing tool or directly on our database's shell. For PostgreSQL, we would do::
of database managing tool or directly on our database's shell, e.g. for MySQL::
# sudo -u postgres createuser pretix
# sudo -u postgres createdb -O pretix pretix
When using MySQL, make sure you set the character set of the database to ``utf8mb4``, e.g. like this::
mysql > CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
$ mysql -u root -p
mysql> CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
mysql> GRANT ALL PRIVILEGES ON pretix.* TO pretix@'localhost' IDENTIFIED BY '*********';
mysql> FLUSH PRIVILEGES;
Package dependencies
--------------------
To build and run pretix, you will need the following debian packages::
# apt-get install git build-essential python-dev python3-venv python3 python3-pip \
# apt-get install git build-essential python-dev python-virtualenv python3 python3-pip \
python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
gettext libpq-dev libmariadbclient-dev libjpeg-dev libopenjp2-7-dev
gettext libpq-dev libmysqlclient-dev libjpeg-dev libopenjp2-7-dev
Config file
-----------
@@ -87,18 +85,13 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
datadir=/var/pretix/data
[database]
; For MySQL, replace with "mysql"
backend=postgresql
; Replace mysql with postgresql_psycopg2 for PostgreSQL
backend=mysql
name=pretix
user=pretix
; For MySQL, enter the user password. For PostgreSQL on the same host,
; we don't need one because we can use peer authentification if our
; PostgreSQL user matches our unix user.
password=
; For MySQL, use local socket, e.g. /var/run/mysqld/mysqld.sock
; For a remote host, supply an IP address
; For local postgres authentication, you can leave it empty
host=
password=*********
; Replace with host IP address for PostgreSQL
host=/var/run/mysqld/mysqld.sock
[mail]
; See config file documentation for more options
@@ -122,14 +115,14 @@ Now we will install pretix itself. The following steps are to be executed as the
actually install pretix, we will create a virtual environment to isolate the python packages from your global
python installation::
$ python3 -m venv /var/pretix/venv
$ virtualenv -p python3 /var/pretix/venv
$ source /var/pretix/venv/bin/activate
(venv)$ pip3 install -U pip setuptools wheel
We now install pretix, its direct dependencies and gunicorn. Replace ``postgres`` with ``mysql`` in the following
command if you're running MySQL::
We now install pretix, its direct dependencies and gunicorn. Replace ``mysql`` with ``postgres`` in the following
command if you're running PostgreSQL::
(venv)$ pip3 install "pretix[postgres]" gunicorn
(venv)$ pip3 install "pretix[mysql]" gunicorn
Note that you need Python 3.5 or newer. You can find out your Python version using ``python -V``.
@@ -275,10 +268,10 @@ Updates
.. warning:: While we try hard not to break things, **please perform a backup before every upgrade**.
To upgrade to a new pretix release, pull the latest code changes and run the following commands (again, replace
``postgres`` with ``mysql`` if necessary)::
``mysql`` with ``postgres`` if necessary)::
$ source /var/pretix/venv/bin/activate
(venv)$ pip3 install -U pretix[postgres] gunicorn
(venv)$ pip3 install -U pretix[mysql] gunicorn
(venv)$ python -m pretix migrate
(venv)$ python -m pretix rebuild
(venv)$ python -m pretix updatestyles

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

@@ -181,37 +181,4 @@ as the string values ``true`` and ``false``.
If the ``ordering`` parameter is documented for a resource, you can use it to sort the result set by one of the allowed
fields. Prepend a ``-`` to the field name to reverse the sort order.
Idempotency
-----------
Our API supports an idempotency mechanism to make sure you can safely retry operations without accidentally performing
them twice. This is useful if an API call experiences interruptions in transit, e.g. due to a network failure, and you
do not know if it completed successfully.
To perform an idempotent request, add a ``X-Idempotency-Key`` header with a random string value (we recommend a version
4 UUID) to your request. If we see a second request with the same ``X-Idempotency-Key`` and the same ``Authorization``
and ``Cookie`` headers, we will not perform the action for a second time but return the exact same response instead.
Please note that this also goes for most error responses. For example, if we returned you a ``403 Permission Denied``
error and you retry with the same ``X-Idempotency-Key``, you will get the same error again, even if you were granted
permission in the meantime! This includes internal server errors on our side that might have been fixed in the meantime.
There are only three exceptions to the rule:
* Responses with status code ``409 Conflict`` are not cached. If you send the request again, it will be executed as a
new request, since these responses are intended to be retried.
* Rate-limited responses with status code ``429 Too Many Requests`` are not cached and you can safely retry them.
* Responses with status code ``503 Service Unavailable`` are not cached and you can safely retry them.
If you send a request with an ``X-Idempotency-Key`` header that we have seen before but that has not yet received a
response, you will receive a response with status code ``409 Conflict`` and are asked to retry after five seconds.
We store idempotency keys for 24 hours, so you should never retry a request after a longer time period.
All ``POST``, ``PUT``, ``PATCH``, or ``DELETE`` api calls support idempotency keys. Adding an idempotency key to a
``GET``, ``HEAD``, or ``OPTIONS`` request has no effect.
.. _CSRF policies: https://docs.djangoproject.com/en/1.11/ref/csrf/#ajax

View File

@@ -20,7 +20,7 @@ internal_name string An optional nam
description multi-lingual string A public description (might include markdown, can
be ``null``)
position integer An integer, used for sorting the categories
is_addon boolean If ``true``, items within this category are not on sale
is_addon boolean If ``True``, items within this category are not on sale
on their own but the category provides a source for
defining add-ons for other products.
===================================== ========================== =======================================================

View File

@@ -156,14 +156,14 @@ Endpoints
"checkin_count": 17,
"position_count": 42,
"event": {
"name": "Demo Conference"
"name": "Demo Converence",
},
"items": [
{
"name": "T-Shirt",
"id": 1,
"checkin_count": 1,
"admission": false,
"admission": False,
"position_count": 1,
"variations": [
{
@@ -184,7 +184,7 @@ Endpoints
"name": "Ticket",
"id": 2,
"checkin_count": 15,
"admission": true,
"admission": True,
"position_count": 22,
"variations": []
}
@@ -336,24 +336,11 @@ Order position endpoints
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.
.. 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
the :ref:`order-position-resource`, with the following differences:
* 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.
the :ref:`order-position-resource`, with one important difference: the ``checkins`` value will only include
check-ins for the selected list.
**Example request**:
@@ -420,8 +407,6 @@ Order position endpoints
}
: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``,
``order__datetime``, ``positionid``, ``attendee_name``, ``last_checked_in`` and ``order__email``. Default:
``attendee_name,positionid``
@@ -457,15 +442,8 @@ Order position endpoints
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/(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 ``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.
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.
**Instead of an ID, you can also use the ``secret`` field as the lookup parameter.**
@@ -550,8 +528,7 @@ Order position endpoints
:<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``.
:<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
list.
Defaults to ``false``.
:<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
create only one check-in object even when the previous request was successful as well. This

View File

@@ -25,7 +25,7 @@ is_public boolean If ``true``, th
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``)
location multi-lingual string The event 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.
meta_data dict Values set for organizer-specific meta data parameters.
plugins list A list of package names of the enabled plugins for this
@@ -379,7 +379,7 @@ Endpoints
:param organizer: The ``slug`` field of the organizer of the event to update
:param event: The ``slug`` field of the event to update
:statuscode 200: no error
:statuscode 201: no error
:statuscode 400: The event could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.

View File

@@ -11,7 +11,6 @@ Resources and endpoints
categories
items
item_variations
item_bundles
item_add-ons
questions
question_options

View File

@@ -13,7 +13,7 @@ Field Type Description
===================================== ========================== =======================================================
number string Invoice number (with prefix)
order string Order code of the order this invoice belongs to
is_cancellation boolean ``true``, if this invoice is the cancellation of a
is_cancellation boolean ``True``, if this invoice is the cancellation of a
different invoice.
invoice_from string Sender address
invoice_to string Receiver address

View File

@@ -189,7 +189,7 @@ Endpoints
{
"min_count": 0,
"max_count": 10
"max_count": 10,
}
**Example response**:

View File

@@ -1,242 +0,0 @@
Item bundles
============
Resource description
--------------------
With bundles, you can specify products that are included within other products. There are two premier use cases of this:
* Package discounts. For example, you could offer a discounted package that includes three tickets but can only be
bought as a whole. With a bundle including three times the usual product, the package will automatically pull three
sub-items into the cart, making sure of correct quota calculation and issuance of the correct number of tickets.
* Tax splitting. For example, if your conference ticket includes a part that is subject to different taxation and that
you need to put on the invoice separately. When you putting a "designated price" on a bundled sub-item, pretix will
use that price to show a split taxation.
The bundles resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the bundling configuration
bundled_item integer Internal ID of the item that is included.
bundled_variation integer Internal ID of the variation of the item (or ``null``).
count integer Number of items included
designated_price money (string) Designated price of the bundled product. This will be
used to split the price of the base item e.g. for mixed
taxation. This is not added to the price.
===================================== ========================== =======================================================
.. versionchanged:: 2.6
This resource has been added.
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/bundles/
Returns a list of all bundles for a given item.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/items/11/bundles/ 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": 2,
"next": null,
"previous": null,
"results": [
{
"id": 3,
"bundled_item": 3,
"bundled_variation": null,
"count": 1,
"designated_price": "0.00"
},
{
"id": 3,
"bundled_item": 3,
"bundled_variation": null,
"count": 2,
"designated_price": "1.50"
}
]
}
: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
:param event: The ``slug`` field of the event to fetch
:param item: The ``id`` field of the item to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event/item does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/bundles/(id)/
Returns information on one bundle configuration, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/items/1/bundles/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": 3,
"bundled_item": 3,
"bundled_variation": null,
"count": 2,
"designated_price": "1.50"
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param item: The ``id`` field of the item to fetch
:param id: The ``id`` field of the bundle 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:post:: /api/v1/organizers/bigevents/events/sampleconf/items/1/bundles/
Creates a new bundle configuration
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/(organizer)/events/(event)/items/(item)/bundles/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content: application/json
{
"bundled_item": 3,
"bundled_variation": null,
"count": 2,
"designated_price": "1.50"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 3,
"bundled_item": 3,
"bundled_variation": null,
"count": 2,
"designated_price": "1.50"
}
:param organizer: The ``slug`` field of the organizer of the event/item to create a bundle-configuration for
:param event: The ``slug`` field of the event to create a bundle configuration for
:param item: The ``id`` field of the item to create a bundle configuration for
:statuscode 201: no error
:statuscode 400: The bundle could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/bundles/(id)/
Update a bundle configuration. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all
fields of the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields
that you want to change.
You can change all fields of the resource except the ``id`` field.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/items/1/bundles/3/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"count": 2
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 3,
"bundled_item": 3,
"bundled_variation": null,
"count": 2,
"designated_price": "1.50"
}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param item: The ``id`` field of the item to modify
:param id: The ``id`` field of the bundle to modify
:statuscode 200: no error
:statuscode 400: The bundle configuration could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/bundles/(id)/
Delete a bundle configuration.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/items/1/bundles/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the item to modify
:param id: The ``id`` field of the bundle to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.

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
same as ``default_price`` if that value is set or equal
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
Markdown syntax or can be ``null``.
position integer An integer, used for sorting
===================================== ========================== =======================================================
.. versionchanged:: 2.7
The attribute ``original_price`` has been added.
.. versionchanged:: 1.12
This resource has been added.
@@ -73,8 +67,7 @@ Endpoints
},
"position": 0,
"default_price": "223.00",
"price": 223.0,
"original_price": null,
"price": 223.0
},
{
"id": 3,
@@ -127,7 +120,6 @@ Endpoints
},
"default_price": "10.00",
"price": "10.00",
"original_price": null,
"active": true,
"description": null,
"position": 0
@@ -175,7 +167,6 @@ Endpoints
"value": {"en": "Student"},
"default_price": "10.00",
"price": "10.00",
"original_price": null,
"active": true,
"description": null,
"position": 0
@@ -225,7 +216,6 @@ Endpoints
"value": {"en": "Student"},
"default_price": "10.00",
"price": "10.00",
"original_price": null,
"active": false,
"description": null,
"position": 1

View File

@@ -21,35 +21,34 @@ default_price money (string) The item price
overwritten by variations or other options.
category integer The ID of the category this item belongs to
(or ``null``).
active boolean If ``false``, the item is hidden from all public lists
active boolean If ``False``, the item is hidden from all public lists
and will not be sold.
description multi-lingual string A public description of the item. May contain Markdown
syntax or can be ``null``.
free_price boolean If ``true``, customers can change the price at which
free_price boolean If ``True``, customers can change the price at which
they buy the product (however, the price can't be set
lower than the price defined by ``default_price`` or
otherwise).
tax_rate decimal (string) The VAT rate to be applied for this item (read-only,
set through ``tax_rule``).
tax_rate decimal (string) The VAT rate to be applied for this item.
tax_rule integer The internal ID of the applied tax rule (or ``null``).
admission boolean ``true`` for items that grant admission to the event
(such as primary tickets) and ``false`` for others
admission boolean ``True`` for items that grant admission to the event
(such as primary tickets) and ``False`` for others
(such as add-ons or merchandise).
position integer An integer, used for sorting
picture string A product picture to be displayed in the shop
(read-only, can be ``null``).
(read-only).
sales_channels list of strings Sales channels this product is available on, such as
``"web"`` or ``"resellers"``. Defaults to ``["web"]``.
available_from datetime The first date time at which this item can be bought
(or ``null``).
available_until datetime The last date time at which this item can be bought
(or ``null``).
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.
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
redemption process, but not in the normal shop
frontend.
allow_cancel boolean If ``false``, customers cannot cancel orders containing
allow_cancel boolean If ``False``, customers cannot cancel orders containing
this item.
min_per_order integer This product can only be bought if it is included at
least this many times in the order (or ``null`` for no
@@ -57,17 +56,16 @@ min_per_order integer This product ca
max_per_order integer This product can only be bought if it is included at
most this many times in the order (or ``null`` for no
limitation).
checkin_attention boolean If ``true``, the check-in app should show a warning
checkin_attention boolean If ``True``, the check-in app should show a warning
that this ticket requires special attention if such
a product is being scanned.
original_price money (string) An original price, shown for comparison, not used
for price calculations (or ``null``).
require_approval boolean If ``true``, orders with this product will need to be
for price calculations.
require_approval boolean If ``True``, orders with this product will need to be
approved by the event organizer before they can be
paid.
require_bundling boolean If ``true``, this item is only available as part of bundles.
generate_tickets boolean If ``false``, tickets are never generated for this
product, regardless of other settings. If ``true``,
generate_tickets boolean If ``False``, tickets are never generated for this
product, regardless of other settings. If ``True``,
tickets are generated even if this is a
non-admission or add-on product, regardless of event
settings. If this is ``null``, regular ticketing
@@ -82,9 +80,7 @@ variations list of objects A list with one
├ price money (string) The price used for this variation. This is either the
same as ``default_price`` if that value is set or equal
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
Markdown syntax or can be ``null``.
└ position integer An integer, used for sorting
@@ -95,23 +91,10 @@ addons list of objects Definition of a
chosen from.
├ min_count integer The minimal number of add-ons that need to be chosen.
├ max_count integer The maximal number of add-ons that can be chosen.
position integer An integer, used for sorting
position integer An integer, used for sorting
└ price_included boolean Adding this add-on to the item is free
bundles list of objects Definition of bundles that are included in this item.
Only writable during creation,
use separate endpoint to modify this later.
├ bundled_item integer Internal ID of the item that is included.
├ bundled_variation integer Internal ID of the variation of the item (or ``null``).
├ count integer Number of items included
└ designated_price money (string) Designated price of the bundled product. This will be
used to split the price of the base item e.g. for mixed
taxation. This is not added to the price.
===================================== ========================== =======================================================
.. versionchanged:: 2.7
The attribute ``original_price`` has been added for ``variations``.
.. versionchanged:: 1.7
The attribute ``tax_rule`` has been added. ``tax_rate`` is kept for compatibility. The attribute
@@ -138,20 +121,15 @@ bundles list of objects Definition of b
The ``generate_tickets`` attribute has been added.
.. versionchanged:: 2.6
The ``bundles`` and ``require_bundling`` attributes have been added.
Notes
-----
Please note that an item either always has variations or never has. Once created with variations the item can never
change to an item without and vice versa. To create an item with variations ensure that you POST an item with at least
one variation.
Also note that ``variations``, ``bundles``, and ``addons`` are only supported on ``POST``. To update/delete variations,
bundles, and add-ons please use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT``
with nested ``variations``, ``bundles`` and/or ``addons``.
Also note that ``variations`` and ``addons`` are only supported on ``POST``. To update/delete variations and add-ons please
use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT`` with nested
``variations`` and/or ``addons``.
Endpoints
---------
@@ -208,13 +186,11 @@ Endpoints
"has_variations": false,
"generate_tickets": null,
"require_approval": false,
"require_bundling": false,
"variations": [
{
"value": {"en": "Student"},
"default_price": "10.00",
"price": "10.00",
"original_price": null,
"active": true,
"description": null,
"position": 0
@@ -223,14 +199,12 @@ Endpoints
"value": {"en": "Regular"},
"default_price": null,
"price": "23.00",
"original_price": null,
"active": true,
"description": null,
"position": 1
}
],
"addons": [],
"bundles": []
"addons": []
}
]
}
@@ -299,13 +273,11 @@ Endpoints
"checkin_attention": false,
"has_variations": false,
"require_approval": false,
"require_bundling": false,
"variations": [
{
"value": {"en": "Student"},
"default_price": "10.00",
"price": "10.00",
"original_price": null,
"active": true,
"description": null,
"position": 0
@@ -314,14 +286,12 @@ Endpoints
"value": {"en": "Regular"},
"default_price": null,
"price": "23.00",
"original_price": null,
"active": true,
"description": null,
"position": 1
}
],
"addons": [],
"bundles": []
"addons": []
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -370,13 +340,11 @@ Endpoints
"max_per_order": null,
"checkin_attention": false,
"require_approval": false,
"require_bundling": false,
"variations": [
{
"value": {"en": "Student"},
"default_price": "10.00",
"price": "10.00",
"original_price": null,
"active": true,
"description": null,
"position": 0
@@ -385,14 +353,12 @@ Endpoints
"value": {"en": "Regular"},
"default_price": null,
"price": "23.00",
"original_price": null,
"active": true,
"description": null,
"position": 1
}
],
"addons": [],
"bundles": []
"addons": []
}
**Example response**:
@@ -430,13 +396,11 @@ Endpoints
"checkin_attention": false,
"has_variations": true,
"require_approval": false,
"require_bundling": false,
"variations": [
{
"value": {"en": "Student"},
"default_price": "10.00",
"price": "10.00",
"original_price": null,
"active": true,
"description": null,
"position": 0
@@ -445,14 +409,12 @@ Endpoints
"value": {"en": "Regular"},
"default_price": null,
"price": "23.00",
"original_price": null,
"active": true,
"description": null,
"position": 1
}
],
"addons": [],
"bundles": []
"addons": []
}
:param organizer: The ``slug`` field of the organizer of the event to create an item for
@@ -521,13 +483,11 @@ Endpoints
"checkin_attention": false,
"has_variations": true,
"require_approval": false,
"require_bundling": false,
"variations": [
{
"value": {"en": "Student"},
"default_price": "10.00",
"price": "10.00",
"original_price": null,
"active": true,
"description": null,
"position": 0
@@ -536,14 +496,12 @@ Endpoints
"value": {"en": "Regular"},
"default_price": null,
"price": "23.00",
"original_price": null,
"active": true,
"description": null,
"position": 1
}
],
"addons": [],
"bundles": []
"addons": []
}
:param organizer: The ``slug`` field of the organizer to modify

View File

@@ -26,7 +26,7 @@ status string Order status, o
* ``p`` paid
* ``e`` expired
* ``c`` canceled
testmode boolean If ``true``, this order was created when the event was in
testmode boolean If ``True``, this order was created when the event was in
test mode. Only orders in test mode can be deleted.
secret string The secret contained in the link sent to the customer
email string The customer email address
@@ -39,13 +39,13 @@ payment_date date **DEPRECATED AN
payment_provider string **DEPRECATED AND INACCURATE** Payment provider used for this order
total money (string) Total value of this order
comment string Internal comment on this order
checkin_attention boolean If ``true``, the check-in app should show a warning
checkin_attention boolean If ``True``, the check-in app should show a warning
that this ticket requires special attention if a ticket
of this order is scanned.
invoice_address object Invoice address information (can be ``null``)
├ last_modified datetime Last modification date of the address
├ company string Customer company name
├ is_business boolean Business or individual customers (always ``false``
├ is_business boolean Business or individual customers (always ``False``
for orders created before pretix 1.7, do not rely on
it).
├ name string Customer name
@@ -56,7 +56,7 @@ invoice_address object Invoice address
├ country string Customer country
├ internal_reference string Customer's internal reference to be printed on the invoice
├ 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
EU VAT service and validation was successful. This only
happens in rare cases.
positions list of objects List of non-canceled order positions (see below)
@@ -78,9 +78,9 @@ downloads list of objects List of ticket
download options.
├ output string Ticket output provider (e.g. ``pdf``, ``passbook``)
└ url string Download URL
require_approval boolean If ``true`` and the order is pending, this order
require_approval boolean If ``True`` and the order is pending, this order
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.
payments list of objects List of payment processes (see below)
refunds list of objects List of refund processes (see below)
@@ -295,7 +295,7 @@ List of all orders
"require_approval": false,
"invoice_address": {
"last_modified": "2017-12-01T10:00:00Z",
"is_business": true,
"is_business": True,
"company": "Sample company",
"name": "John Doe",
"name_parts": {"full_name": "John Doe"},
@@ -305,7 +305,7 @@ List of all orders
"country": "Testikistan",
"internal_reference": "",
"vat_id": "EU123456789",
"vat_id_validated": false
"vat_id_validated": False
},
"positions": [
{
@@ -373,8 +373,8 @@ List of all orders
}
: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 ``datetime``, ``code``,
``last_modified``, and ``status``. Default: ``datetime``
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``datetime``, ``code`` and
``status``. Default: ``datetime``
:query string code: Only return orders that match the given order code
:query string status: Only return orders in the given order status (see above)
:query boolean testmode: Only return orders with ``testmode`` set to ``true`` or ``false``
@@ -385,7 +385,6 @@ List of all orders
:query datetime modified_since: Only return orders that have changed since the given date. Be careful: We only
recommend using this in combination with ``testmode=false``, since test mode orders can vanish at any time and
you will not notice it using this method.
:query datetime created_since: Only return orders that have been created since the given date.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:resheader X-Page-Generated: The server time at the beginning of the operation. If you're using this API to fetch
@@ -438,7 +437,7 @@ Fetching individual orders
"invoice_address": {
"last_modified": "2017-12-01T10:00:00Z",
"company": "Sample company",
"is_business": true,
"is_business": True,
"name": "John Doe",
"name_parts": {"full_name": "John Doe"},
"street": "Test street 12",
@@ -447,7 +446,7 @@ Fetching individual orders
"country": "Testikistan",
"internal_reference": "",
"vat_id": "EU123456789",
"vat_id_validated": false
"vat_id_validated": False
},
"positions": [
{
@@ -564,92 +563,6 @@ Order ticket download
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
seconds.
Updating order fields
---------------------
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/
Updates specific fields on an order. Currently, only the following fields are supported:
* ``email``
* ``checkin_attention``
* ``locale``
* ``comment``
* ``invoice_address`` (you always need to supply the full object, or ``null`` to delete the current address)
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"email": "other@example.org",
"locale": "de",
"comment": "Foo",
"checkin_attention": true
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
(Full order resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
:param code: The ``code`` field of the order to update
:statuscode 200: no error
:statuscode 400: The order could not be updated due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
Generating new secrets
----------------------
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/regenerate_secrets/
Triggers generation of new ``secret`` attributes for both the order and all order positions.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/regenerate_secrets/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
(Full order resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
:param code: The ``code`` field of the order to update
:statuscode 200: no error
:statuscode 400: The order could not be updated due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order.
Deleting orders
---------------
@@ -750,7 +663,6 @@ Creating orders
should only use this if you know the specific payment provider in detail. Please keep in mind that the payment
provider will not be called to do anything about this (i.e. if you pass a bank account to a debit provider, *no*
charge will be created), this is just informative in case you *handled the payment already*.
* ``payment_date`` (optional) Date and time of the completion of the payment.
* ``comment`` (optional)
* ``checkin_attention`` (optional)
* ``invoice_address`` (optional)
@@ -790,8 +702,6 @@ Creating orders
* ``internal_type``
* ``tax_rule``
* ``force`` (optional). If set to ``true``, quotas will be ignored.
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
IDs in the ``addon_to`` field of another position. Note that all add_ons for a specific position need to come
@@ -804,7 +714,7 @@ Creating orders
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content: application/json
{
"email": "dummy@example.org",
@@ -821,7 +731,7 @@ Creating orders
],
"payment_provider": "banktransfer",
"invoice_address": {
"is_business": false,
"is_business": False,
"company": "Sample company",
"name_parts": {"full_name": "John Doe"},
"street": "Sesam Street 12",
@@ -864,10 +774,10 @@ Creating orders
(Full order resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event to create an order for
:param event: The ``slug`` field of the event to create an order for
:param organizer: The ``slug`` field of the organizer of the event to create an item for
:param event: The ``slug`` field of the event to create an item for
:statuscode 201: no error
:statuscode 400: The order could not be created due to invalid submitted data or lack of quota.
:statuscode 400: The item could not be created due to invalid submitted data or lack of quota.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this
order.
@@ -992,7 +902,7 @@ Order state operations
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_expired/
Marks an unpaid order as expired.
Marks a unpaid order as expired.
**Example request**:
@@ -1151,82 +1061,9 @@ Order state operations
:statuscode 200: no error
:statuscode 400: The order cannot be marked as denied since the current order status does not allow it.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this resource.
:statuscode 404: The requested order does not exist.
Generating invoices
-------------------
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/create_invoice/
Creates an invoice for an order which currently does not have an invoice. Returns the
invoice object.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/create_invoice/ 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
{
"order": "FOO",
"number": "DUMMY-00001",
"is_cancellation": false,
...
}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param code: The ``code`` field of the order to create an invoice for
:statuscode 200: no error
:statuscode 400: The invoice can not be created (invoicing disabled, the order already has an invoice, …)
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order does not exist.
Sending e-mails
---------------
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/resend_link/
Sends an email to the buyer with the link to the order page.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/resend_link/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param code: The ``code`` field of the order to send an email for
:statuscode 200: no error
:statuscode 400: The order does not have an email address associated
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order does not exist.
:statuscode 503: The email could not be sent.
List of all order positions
---------------------------

View File

@@ -30,17 +30,14 @@ type string The expected ty
* ``D`` date
* ``H`` time
* ``W`` date and time
* ``CC`` country code (ISO 3666-1 alpha-2)
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
items list of integers List of item IDs this question is assigned to.
identifier string An arbitrary string that can be used for matching with
other sources.
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
the ticket instead.
hidden boolean If ``true``, the question will only be shown in the
backend.
options list of objects In case of question type ``C`` or ``M``, this lists the
available objects. Only writable during creation,
use separate endpoint to modify this later.
@@ -49,16 +46,6 @@ options list of objects In case of ques
├ identifier string An arbitrary string that can be used for matching with
other sources.
└ answer multi-lingual string The displayed value of this option
dependency_question integer Internal ID of a different question. The current
question will only be shown if the question given in
this attribute is set to the value given in
``dependency_value``. This cannot be combined with
``ask_during_checkin``.
dependency_value string The value ``dependency_question`` needs to be set to.
If ``dependency_question`` is set to a boolean
question, this should be ``"true"`` or ``"false"``.
Otherwise, it should be the ``identifier`` of a
question option.
===================================== ========================== =======================================================
.. versionchanged:: 1.12
@@ -71,10 +58,6 @@ dependency_value string The value ``dep
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.
.. versionchanged:: 2.7
The attribute ``hidden`` and the question type ``CC`` have been added.
Endpoints
---------
@@ -117,9 +100,6 @@ Endpoints
"position": 1,
"identifier": "WY3TP9SL",
"ask_during_checkin": false,
"hidden": false,
"dependency_question": null,
"dependency_value": null,
"options": [
{
"id": 1,
@@ -185,9 +165,6 @@ Endpoints
"position": 1,
"identifier": "WY3TP9SL",
"ask_during_checkin": false,
"hidden": false,
"dependency_question": null,
"dependency_value": null,
"options": [
{
"id": 1,
@@ -237,9 +214,6 @@ Endpoints
"items": [1, 2],
"position": 1,
"ask_during_checkin": false,
"hidden": false,
"dependency_question": null,
"dependency_value": null,
"options": [
{
"answer": {"en": "S"}
@@ -271,9 +245,6 @@ Endpoints
"position": 1,
"identifier": "WY3TP9SL",
"ask_during_checkin": false,
"hidden": false,
"dependency_question": null,
"dependency_value": null,
"options": [
{
"id": 1,
@@ -343,9 +314,6 @@ Endpoints
"position": 2,
"identifier": "WY3TP9SL",
"ask_during_checkin": false,
"hidden": false,
"dependency_question": null,
"dependency_value": null,
"options": [
{
"id": 1,

View File

@@ -20,8 +20,6 @@ name multi-lingual string The sub-event's
event string The slug of the parent event
active boolean If ``true``, the sub-event ticket shop is publicly
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_to datetime The sub-event's end date (or ``null``)
date_admission datetime The sub-event's admission date (or ``null``)
@@ -47,12 +45,6 @@ meta_data dict Values set for
The ``event`` field has been added, together with filters on the list of dates and an organizer-level list.
.. versionchanged:: 2.6
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
.. versionchanged:: 2.7
The attribute ``is_public`` has been added.
Endpoints
---------
@@ -87,7 +79,6 @@ Endpoints
"name": {"en": "First Sample Conference"},
"event": "sampleconf",
"active": false,
"is_public": true,
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
@@ -112,85 +103,11 @@ Endpoints
:query is_past: If set to ``true`` (``false``), only events that are over are (not) 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 event: The ``slug`` field of the main event
:param event: The ``slug`` field of the event 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 it.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/subevents/
Creates a new subevent.
Permission required: "Can create events"
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/subevents/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content: application/json
{
"name": {"en": "First Sample Conference"},
"active": false,
"is_public": true,
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
"presale_start": null,
"presale_end": null,
"location": null,
"item_price_overrides": [
{
"item": 2,
"price": "12.00"
}
],
"variation_price_overrides": [],
"meta_data": {}
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": {"en": "First Sample Conference"},
"active": false,
"is_public": true,
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
"presale_start": null,
"presale_end": null,
"location": null,
"item_price_overrides": [
{
"item": 2,
"price": "12.00"
}
],
"variation_price_overrides": [],
"meta_data": {}
}
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the main event
:statuscode 201: no error
:statuscode 400: The sub-event 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:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/
Returns information on one sub-event, identified by its ID.
@@ -216,7 +133,6 @@ Endpoints
"name": {"en": "First Sample Conference"},
"event": "sampleconf",
"active": false,
"is_public": true,
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
@@ -233,107 +149,13 @@ Endpoints
"meta_data": {}
}
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the main event
:param id: The ``id`` field of the sub-event to fetch
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param id: The ``slug`` field of the sub-event 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 it.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/
Updates a sub-event, identified by its ID. 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.
Permission required: "Can change event settings"
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/subevents/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content: application/json
{
"name": {"en": "New Subevent Name"},
"item_price_overrides": [
{
"item": 2,
"price": "23.42"
}
],
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": {"en": "New Subevent Name"},
"event": "sampleconf",
"active": false,
"is_public": true,
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
"presale_start": null,
"presale_end": null,
"location": null,
"item_price_overrides": [
{
"item": 2,
"price": "23.42"
}
],
"variation_price_overrides": [],
"meta_data": {}
}
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the main event
:param id: The ``id`` field of the sub-event to update
:statuscode 200: no error
:statuscode 400: The sub-event could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/sub-event does not exist **or** you have no permission to update this resource.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/
Delete a sub-event. Note that events with orders cannot be deleted to ensure data integrity.
Permission required: "Can change event settings"
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/subevents/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 a valid organizer
:param event: The ``slug`` field of the main event
:param id: The ``id`` field of the sub-event to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/sub-event does not exist **or** you have no permission to delete this resource.
.. http:get:: /api/v1/organizers/(organizer)/subevents/
Returns a list of all sub-events of any event series you have access to within an organizer account.
@@ -364,7 +186,6 @@ Endpoints
"name": {"en": "First Sample Conference"},
"event": "sampleconf",
"active": false,
"is_public": true,
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,

View File

@@ -18,8 +18,8 @@ max_usages integer The maximum num
redeemed integer The number of times this voucher already has been
redeemed.
valid_until datetime The voucher expiration date (or ``null``).
block_quota boolean If ``true``, quota is blocked for this voucher.
allow_ignore_quota boolean If ``true``, this voucher can be redeemed even if a
block_quota boolean If ``True``, quota is blocked for this voucher.
allow_ignore_quota boolean If ``True``, this voucher can be redeemed even if a
product is sold out and even if quota is not blocked
for this voucher.
price_mode string Determines how this voucher affects product prices.

View File

@@ -17,11 +17,11 @@ The webhook resource contains the following public fields:
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the webhook
enabled boolean If ``false``, this webhook will not receive any notifications
enabled boolean If ``False``, this webhook will not receive any notifications
target_url string The URL to call
all_events boolean If ``true``, this webhook will receive notifications
all_events boolean If ``True``, this webhook will receive notifications
on all events of this organizer
limit_events list of strings If ``all_events`` is ``false``, this is a list of
limit_events list of strings If ``all_events`` is ``False``, this is a list of
event slugs this webhook is active for
action_types list of strings A list of action type filters that limit the
notifications sent to this webhook. See below for

View File

@@ -12,7 +12,7 @@ Core
.. automodule:: pretix.base.signals
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types,
item_copy_data, register_sales_channels, register_global_settings
item_copy_data, register_sales_channels
Order events
""""""""""""
@@ -20,13 +20,17 @@ Order events
There are multiple signals that will be sent out in the ordering cycle:
.. automodule:: pretix.base.signals
: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
:members: validate_cart, order_fee_calculation, order_paid, order_placed, order_fee_type_name, allow_ticket_download
Frontend
--------
.. 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, checkout_flow_steps, order_info, order_meta_from_request
: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
.. automodule:: pretix.presale.signals
:members: order_info, order_meta_from_request
Request flow
""""""""""""

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

View File

@@ -23,7 +23,7 @@ Organizers and events
:members:
.. 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
: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

@@ -108,43 +108,3 @@ Endpoints
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/badgeitems/
Returns a list of all assignments of items to layouts
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/democon/badgeitems/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"layout": 2,
"item": 3,
}
]
}
:query page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of a valid event
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.

View File

@@ -316,7 +316,7 @@ uses to communicate with the pretix server.
"total": 42,
"version": 3,
"event": {
"name": "Demo Conference",
"name": "Demo Converence",
"slug": "democon",
"date_from": "2016-12-27T17:00:00Z",
"date_to": "2016-12-30T18:00:00Z",

View File

@@ -114,44 +114,3 @@ Endpoints
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/ticketlayoutitems/
Returns a list of all assignments of items to layouts
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/democon/ticketlayoutitems/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"layout": 2,
"item": 3,
"sales_channel": web
}
]
}
:query page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of a valid event
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.

View File

@@ -10,12 +10,10 @@ availabilities
backend
backends
banktransfer
Bcc
boolean
booleans
cancelled
casted
Ceph
checkbox
checksum
config
@@ -54,7 +52,6 @@ linters
memcached
metadata
middleware
Minio
mixin
mixins
multi
@@ -96,10 +93,7 @@ renderer
renderers
reportlab
SaaS
scalability
screenshot
scss
searchable
selectable
serializers
serializers
@@ -114,7 +108,6 @@ subdomains
subevent
subevents
submodule
subnet
subpath
Symfony
systemd
@@ -127,7 +120,6 @@ unconfigured
unix
unprefixed
untrusted
uptime
username
url
versa

View File

@@ -21,18 +21,11 @@ Frontpage text
your product types, give more information on the event or for other general notices.
You can use :ref:`Markdown syntax <markdown-guide>` in this field.
Voucher explanation
This text will be shown above the voucher input box. You can use it to explain how to obtain a voucher and use it.
Show variations of a product expanded by default
If this is not checked, a product with variations will be shown as one row in the show by default and will expand
into multiple rows once it is clicked on. With this box checked, the variations will be shown as multiple rows
right from the beginning.
Ask search engines not to index the ticket shop
If this is checked, we will set a HTML meta attribute asking search engines by Google not to put this ticket shop
into their searchable index.
The lower part of the page contains settings that you can **either** set on organizer-level for all your events **or**
override for this single event individually. Those are:
@@ -42,12 +35,6 @@ Primary color
customers. We suggest not choosing something to light, since text in that color should be readable on a white
background and white text should be readable on a background of this color.
Accent color for success
This color will be used for success messages. We suggest to choose a dark shade of green.
Accent color for errors
This color will be used for error messages. We suggest to choose a dark shade of red.
Font
Choose one of multiple fonts to use for your web shop.

View File

@@ -8,8 +8,8 @@ event.
:align: center
:class: screenshot
The page is separated into four parts: "E-mail settings", "E-mail design", "E-mail content" and "SMTP settings".
We will explain all of them in detail on this page.
The page is separated into three parts: "E-mail settings", "E-mail content" and "SMTP settings". We will explain all
of them in detail on this page.
E-mail settings
---------------
@@ -30,18 +30,10 @@ Signature
This text will be appended to all e-mails in form of a signature. This might be useful e.g. to add your contact
details or any legal information that needs to be included with the e-mails.
Bcc address
This email address will receive a copy of every event-related email.
E-mail design
-------------
In this part, you can choose and preview the layout of your emails. More layouts can be added by pretix plugins.
E-mail content
--------------
The next part of the page allows you to customize the exact texts of all e-mails sent by the system automatically.
The middle part of the page allows you to customize the exact texts of all e-mails sent by the system automatically.
You can click on the different boxes to expand them and see the texts.
Within the texts, you can use placeholders that will later by replaced by values depending on the event or order. Below
@@ -53,7 +45,6 @@ Placeholder Description
============================== ===============================================================================
event The event name
total The order's total value
total_with_currency The order's total value with a localized currency sign
currency The currency used for the event (three-letter code)
payment_info Information text specific to the payment method (e.g. banking details)
url An URL pointing to the download/status page of the order
@@ -121,22 +112,6 @@ Reminder to download tickets
attendees to download their tickets. The e-mail should include a link to the ticket download. This e-mail will only
ever be sent if you specify a number of days.
Order approval process
If you configure one of your products to "require approval", orders of that product will not immediately be confirmed
but only after you approved them manually. In this case, the following e-mail templates will be sent out.
Received order
After an order has been received, this e-mail will be sent automatically instead of the "order placed" e-mail from
above.
Approved order
This e-mail will be sent after you manually approved an order. This should include instructions to pay for the order,
which is why this will only be used for a paid order. For a free order, the "free order" e-mail from above will
be sent.
Denied order
This e-mail will be sent out to customers when their order has been denied.
SMTP settings
-------------

View File

@@ -11,6 +11,18 @@ The settings at "Settings" → "Invoice" allow you to specify if and how pretix
In particular, you can configure the following things:
Ask for invoice address
If this checkbox is enabled, customers will be able to enter an invoice address during checkout. If you only enable
this box, the invoice address will be optional to fill in.
Require invoice address
If this checkbox is enabled, entering an invoice address will be obligatory for all customers and it will not be
able to create an order without entering an address.
Require customer name
If this checkbox is enabled, the street, city, and country fields of the invoice address will still be optional but
the name field will be obligatory.
Generate invoices
This field controls whether pretix should generate an invoice for an order. You have the following options:
@@ -39,51 +51,6 @@ Attach invoices to emails
"Automatically for all created orders" or to the payment confirmation e-mails if it is set to "Automatically on
payment".
Invoice number prefix
This is the prefix that will be prepended to all your invoice numbers. For example, if you set this to "Inv", your
invoices will be numbered Inv00001, Inv00002, etc. If you leave this field empty, your event slug will be used,
followed by a dash, e.g. DEMOCON-00001.
Within one organizer account, events with the same number prefix will share their number range. For example, if you
set this to "Inv" for all of your events, there will be only one invoice numbered Inv00007 across all your events
and the numbers will have gaps within one event.
Generate invoices with consecutive numbers
If enabled, invoices will be created with numerical invoice numbers in the order of their creation, i.e.
PREFIX-00001, PREFIX-00002, and so on. If disabled, invoice numbers will instead be generated from the order code,
i.e. PREFIX-YHASD-1. When in doubt, keep this option enabled since it might be legally required in your country,
but disabling it has the advantage that your customers can not estimate the number of tickets sold by looking at
the invoice numbers.
Invoice language
This setting allows you to configure the language of all invoices. You can either set it to one of your event
language or dynamically to the language used by the customer.
Show free products on invoices
If enabled, products that do not cost anything will still show up on invoices. Note that the order needs to contain
at least one non-free product in order to generate an invoice.
Show attendee names on invoices
If enabled, the attendee name will be printed on the invoice for admission tickets.
Ask for invoice address
If this checkbox is enabled, customers will be able to enter an invoice address during checkout. If you only enable
this box, the invoice address will be optional to fill in.
Require invoice address
If this checkbox is enabled, entering an invoice address will be obligatory for all customers and it will not be
able to create an order without entering an address.
Require customer name
If this checkbox is enabled, the street, city, and country fields of the invoice address will still be optional but
the name field will be obligatory.
Require a business address
If enabled, the invoice address form will require a company name and do not allow personal addresses.
Ask for beneficiary
If enabled, the invoice address form will contain an additional field to input the beneficiary of the transaction.
Ask for VAT ID
If enabled, the invoice address form will not only ask for a postal address, but also for a VAT ID. The VAT ID will
always be an optional field.
@@ -95,12 +62,25 @@ Generate invoices with consecutive numbers
but disabling it has the advantage that your customers can not estimate the number of tickets sold by looking at
the invoice numbers.
Your invoice details
These fields should be set to the address of the entity issuing the invoice (read: you) and will be printed inside
the header of the invoice.
Invoice number prefix
This is the prefix that will be prepended to all your invoice numbers. For example, if you set this to "Inv", your
invoices will be numbered Inv00001, Inv00002, etc. If you leave this field empty, your event slug will be used,
followed by a dash, e.g. DEMOCON-00001.
Invoice style
This setting allows you to choose the design of your invoice. Additional designs can be added by pretix plugins.
Within one organizer account, events with the same number prefix will share their number range. For example, if you
set this to "Inv" for all of your events, there will be only one invoice numbered Inv00007 across all your events
and the numbers will have gaps within one event.
Show free products on invoices
If enabled, products that do not cost anything will still show up on invoices. Note that the order needs to contain
at least one non-free product in order to generate an invoice.
Show attendee names on invoices
If enabled, the attendee name will be printed on the invoice for admission tickets.
Your address
This should be set to the address of the entity issuing the invoice (read: you) and will be printed inside
the header of the invoice.
Introductory text
A free custom text that will be printed above the list of products on the invoice.

View File

@@ -1,260 +0,0 @@
Product structure guide
=======================
Between products, categories, variations, add-ons, bundles, and quotas, pretix provides a wide range of features that allow you to set up your event in the way you want it.
However, it is easy to get lost in the process or to get started with building your event right.
Often times, there are multiple ways to do something that come with different advantages and disadvantages.
This guide will walk you through a number of typical examples of pretix event structures and will explain how to set them up feel free to just skip ahead to a section relevant for you.
Terminology
-----------
Products
A product is a basic entity that can be bought. You can think of it as a ticket type, but it can be more things than just a ticket, it can also be a piece of merchandise, a parking slot, etc.
You might find some places where they are called "items" instead, but we're trying to get rid of that.
Product categories
Products can be sorted into categories. Each product can only be in one category. Category are mostly used for grouping related products together to make your event page easier to read for buyers. However, we'll need categories as well to set up some of the structures outlined below.
Product variations
During creation of a product, you can decide that your product should have multiple variations. Variations of a product can differ in price, description, and availability, but are otherwise the same. You could use this e.g. for differentiating between a regular ticket and a discounted ticket for students, or when selling merchandise to differentiate the different sizes of a t-shirt.
Product add-ons
Add-ons are products that are sold together with another product (which we will call the base product in this case). For example, you could have a base product "Conference ticket" and then define multiple workshops that can be chosen as an add-on.
Product bundles
Bundles work very similarly to add-ons, but are different in the way that they are always automatically included with the base product and cannot be optional. In contrast to add-on products, the same product can be included multiple times in a bundle.
Quotas
Quotas define the availability of products. A quota has a size (i.e. the number of products in the inventory) and is mapped to one or multiple products or variations.
Questions
Questions are user-defined form fields that buyers will need to fill out when purchasing a product.
Use case: Multiple price levels
-------------------------------
Imagine you're running a concert with general admission that sells a total of 200 tickets for two prices:
* Regular: € 25.00
* Students: € 19.00
You can either set up two different products called e.g. "Regular ticket" and "Student ticket" with the respective prices, or two variations within the same product. In this simple case, it really doesn't matter.
In addition, you will need quotas. If you do not care how many of your tickets are sold to students, you should set up just **one quota** of 200 called e.g. "General admission" that you link to **both products**.
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
--------------------------
Let's say you run a conference that has the following pricing scheme:
* 12 to 6 months before the event: € 450
* 6 to 3 months before the event: € 550
* closer than 3 months to the event: € 650
Of course, you could just set up one product and change its price at the given dates manually, but if you want to set this up automatically, here's how:
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.
.. note::
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.
Use case: Up-selling of ticket extras
-------------------------------------
Let's assume you're putting up a great music festival, and to save trouble with handling payments on-site, you want to sell parking spaces together with your ticket. By using our add-on feature, you can prompt all users to book the parking space (to make sure they see it) and ensure that only people with a ticket can book a parking space. You can set it up like this:
* Create a base product "Festival admission"
* Create a quota for the base product
* Create a category "Ticket extras" and check "Products in this category are add-on products"
* Create a product "Parking space" within that category
* Create a quota for the parking space product
* Go to the base product and select the tab "Add-Ons" at the top. Click "Add a new add-on" and choose the "Ticket extras" category. You can keep the numbers at 0 and 1.
During checkout, all buyers of the base product will now be prompted if they want to add the parking space.
.. tip::
You can also use add-on products for free things, just to keep tabs on capacity.
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.
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.
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:
==================== =================================== ===================================
Time Room A Room B
==================== =================================== ===================================
Wednesday morning Lecture
Wednesday afternoon Workshop A Workshop B
Thursday morning Workshop C Workshop D (20 € extra charge)
==================== =================================== ===================================
Assuming you already created one or more products for your general conference admission, we suggest that you additionally create:
* A category called "Workshops" with the checkbox "Products in this category are add-on products" activated
* A free product called "Wednesday afternoon" within the category "Workshops" and with two variations:
* Workshop A
* Workshop B
* A free product called "Thursday morning" within the category "Workshops" and with two variations:
* Workshop C
* Workshop D with a price of 20 €
* Four quotas for each of the workshops
* One add-on configuration on your base product that allows users to choose between 0 and 2 products from the category "Workshops"
Use case: Discounted packages
-----------------------------
Imagine you run a trade show that opens on three consecutive days and you want to have the following pricing:
* Single day: € 10
* Any two days: € 17
* All three days: € 25
In this case, there are multiple different ways you could set this up with pretix.
Option A: Combination products
""""""""""""""""""""""""""""""
With this option, you just set up all the different combinations someone could by as a separate product. In this case, you would need 7 products:
* Day 1 pass
* Day 2 pass
* Day 3 pass
* Day 1+2 pass
* Day 2+3 pass
* Day 1+3 pass
* All-day pass
Then, you create three quotas, each one with the maximum capacity of your venue on any given day:
* Day 1 quota, linked to "Day 1 pass", "Day 1+2 pass", "Day 1+3 pass", and "All-day pass"
* Day 2 quota, linked to "Day 2 pass", "Day 1+2 pass", "Day 2+3 pass", and "All-day pass"
* Day 3 quota, linked to "Day 3 pass", "Day 2+3 pass", "Day 1+3 pass", and "All-day pass"
This way, every person gets exactly one ticket that they can use for all days that they attend. You can later set up check-in lists appropriately to make sure only tickets valid for a certain day can be scanned on that day.
The benefit of this option is that your product structure and order structure stays very simple. However, the two-day packages scale badly when you need many products.
We recommend this setup for most setups in which the number of possible combinations does not exceed the number of parts (here: number of days) by much.
Option B: Add-ons and bundles
"""""""""""""""""""""""""""""
We can combine the two features "product add-ons" and "product bundles" to set this up in a different way. Here, you would create the following five products:
* Day 1 pass in a category called "Day passes"
* Day 2 pass in a category called "Day passes"
* Day 3 pass in a category called "Day passes"
* Two-day pass
* All-day pass
This time, you will need five quotas:
* Day 1 quota, linked to "Day 1 pass"
* Day 2 quota, linked to "Day 2 pass"
* Day 3 quota, linked to "Day 3 pass"
* Two-day pass quota, linked to "Two-day pass" (can be unlimited)
* All-day pass quota, linked to "All-day pass" (can be unlimited)
Then, you open the "Add-On" tab in the settings of the **Two-day pass** product and create a new add-on configuration specifying the following options:
* Category: "Day passes"
* Minimum number: 2
* Maximum number: 2
* Add-Ons are included in the price: Yes
This way, when buying a two-day pass, the user will be able to select *exactly* two days for free, which will then be added to the cart. Depending on your specific configuration, the user will now receive *two separate* tickets, one for each day.
For the all-day pass, you open the "Bundled products" tab in the settings of the **All-day pass** product and add **three** new bundled items with the following options:
* Bundled product: "Day 1/2/3"
* Bundled variation: None
* Count: 1
* Designated price: 0
This way, when buying an all-day pass, three free day passes will *automatically* be added to the cart. Depending on your specific configuration, the user will now receive *three separate* tickets, one for each day.
This approach makes your order data more complicated, since e.g. someone who buys an all-day pass now technically bought **four products**. However, this option allows for more flexibility when you have lots of options to choose from.
.. tip::
Depending on the packages you offer, you **might not need both the add-on and the bundle feature**, i.e. you only need the add-on feature for the two-day pass and only the bundle feature for the all-day pass. You could also set up the two-day pass like we showed here, but the all-day pass like in option A!
Use case: Group discounts
-------------------------
Often times, you want to give discounts for whole groups attending your event. pretix can't automatically discount based on volume, but there's still some ways you can set up group tickets.
Flexible group sizes
""""""""""""""""""""
If you want to give out discounted tickets to groups starting at a given size, but still billed per person, you can do so by creating a special **Group ticket** at the per-person price and set the **Minimum amount per order** option of the ticket to the minimal group size.
This way, your ticket can be bought an arbitrary number of times but no less than the given minimal amount per order.
Fixed group sizes
"""""""""""""""""
If you want to sell group tickets in fixed sizes, e.g. a table of eight at your gala dinner, you can use product bundles. Assuming you already set up a ticket for admission of single persons, you then set up a second product **Table (8 persons)** with a discounted full price. Then, head to the **Bundled products** tab of that product and add one bundle configuration to include the single admission product **eight times**. Next, create an unlimited quota mapped to the new product.
This way, the purchase of a table will automatically create eight tickets, leading to a correct calculation of your total quota and, as expected, eight persons on your check-in list. You can even ask for the individual names of the persons during checkout.
Use case: Restricted audience
-----------------------------
Not all events are for everyone. Sometimes, there is a good reason to restrict access to your event or parts of your event only to a specific, invited group. There's two ways to implement this with pretix:
Option A: Required voucher codes
""""""""""""""""""""""""""""""""
If you check the option "**This product can only be bought using a voucher**" of one or multiple products, only people holding an applicable voucher code will be able to buy the product.
You can then generate voucher codes for the respective product and send them out to the group of possible attendees. If the recipients should still be able to choose between different products, you can create an additional quota and map the voucher to that quota instead of the products themselves.
There's also the second option "**This product will only be shown if a voucher matching the product is redeemed**". In this case, the existence of the product won't even be shown before a voucher code is entered useful for a VIP option in a shop where you also sell other products to the general public. Please note that this option does **not** work with vouchers assigned to a quota, only with vouchers assigned directly to the product.
This option is appropriate if you know the group of people beforehand, e.g. members of a club, and you can mail them their access codes.
Option B: Order approvals
"""""""""""""""""""""""""
If you do not know your audience already, but still want to restrict it to a certain group, e.g. people with a given profession, you can check the "**Buying this product requires approval**" in the settings of your product. If a customer tries to buy such a product, they will be able to place their order but can not proceed to payment. Instead, you will be asked to approve or deny the order and only if you approve it, we will send a payment link to the customer.
This requires the customer to interact with the ticket shop twice (once for the order, once for the payment) which adds a little more friction, but gives you full control over who attends the event.
Use case: Mixed taxation
------------------------
Let's say you are a charitable organization in Germany and are allowed to charge a reduced tax rate of 7% for your educational event. However, your event includes a significant amount of food, you might need to charge a 19% tax rate on that portion. For example, your desired tax structure might then look like this:
* Conference ticket price: € 450 (incl. € 150 for food)
* incl. € 19.63 VAT at 7%
* incl. € 23.95 VAT at 19%
You can implement this in pretix using product bundles. In order to do so, you should create the following two products:
* Conference ticket at € 450 with a 7% tax rule
* Conference food at € 150 with a 19% tax rule and the option "**Only sell this product as part of a bundle**" set
In addition to your normal conference quota, you need to create an unlimited quota for the food product.
Then, head to the **Bundled products** tab of the "conference ticket" and add the "conference food" as a bundled product with a **designated price** of € 150.
Once a customer tries to buy the € 450 conference ticket, a sub-product will be added and the price will automatically be split into the two components, leading to a correct computation of taxes.

View File

@@ -25,10 +25,6 @@ Generate tickets for non-admission products
By default, tickets will only be generated for products that are marked as admission products. Enable this option to
generate tickets for all products instead.
Offer to download tickets even before an order is paid
By default, ticket download is only possible for paid orders. If you run an event where people usually pay only after
the event, you can check this box to enable ticket download even before.
Below these settings, the detail settings for the various ticket file formats are offered. They differ from format to
format and only share the common "Enable" setting that can be used to turn them on. By default, pretix ships with
a PDF output plugin that you can configure through a visual design editor.

View File

@@ -114,35 +114,6 @@ If you want to disable voucher input in the widget, you can pass the ``disable-v
<pretix-widget event="https://pretix.eu/demo/democon/" disable-vouchers></pretix-widget>
Multi-event selection
---------------------
If you want to embed multiple events in a single widget, you can do so. If it's multiple dates of an event series, just leave off the ``series`` attribute::
<pretix-widget event="https://pretix.eu/demo/series/"></pretix-widget>
If you want to include all your public events, you can just reference your organizer::
<pretix-widget event="https://pretix.eu/demo/"></pretix-widget>
There is an optional ``style`` parameter that let's you choose between a calendar view and a list view. If you do not set it, the choice will be taken from your organizer settings::
<pretix-widget event="https://pretix.eu/demo/series/" style="list"></pretix-widget>
<pretix-widget event="https://pretix.eu/demo/series/" style="calendar"></pretix-widget>
You can see an example here:
.. raw:: html
<pretix-widget event="https://pretix.eu/demo/series/" style="calendar"></pretix-widget>
<noscript>
<div class="pretix-widget">
<div class="pretix-widget-info-message">
JavaScript is disabled in your browser. To access our ticket shop without javascript, please <a target="_blank" href="https://pretix.eu/demo/series/">click here</a>.
</div>
</div>
</noscript>
pretix Button
-------------

View File

@@ -10,7 +10,6 @@ wanting to use pretix to sell tickets.
organizers/index
events/create
events/settings
events/structureguide
events/widget
faq
markdown

View File

@@ -3,13 +3,6 @@
PayPal
======
.. note::
If you use pretix Hosted, you do not longer need to go through this tedious process! You can
just open the PayPal settings in the payment section of your event, click "Connect to PayPal"
and log in to your PayPal account. The following guide is only required for self-hosted
versions of pretix.
To integrate PayPal with pretix, you first need to have an active PayPal merchant account. If you do not already have a
PayPal account, you can create one on `paypal.com`_.
If you look into pretix' settings, you are required to fill in two keys:

View File

@@ -1 +1 @@
__version__ = "2.7.2"
__version__ = "2.5.0.dev0"

View File

@@ -19,7 +19,7 @@ class DeviceTokenAuthentication(TokenAuthentication):
if not device.initialized:
raise exceptions.AuthenticationFailed('Device has not been initialized.')
if device.revoked:
if not device.api_token:
raise exceptions.AuthenticationFailed('Device access has been revoked.')
return AnonymousUser(), device

View File

@@ -1,8 +1,7 @@
from rest_framework.permissions import SAFE_METHODS, BasePermission
from pretix.api.models import OAuthAccessToken
from pretix.base.models import Device, Event, User
from pretix.base.models.auth import SuperuserPermissionSet
from pretix.base.models import Device, Event
from pretix.base.models.organizer import Organizer, TeamAPIToken
from pretix.helpers.security import (
SessionInvalid, SessionReauthRequired, assert_session_valid,
@@ -38,13 +37,10 @@ class EventPermission(BasePermission):
slug=request.resolver_match.kwargs['event'],
organizer__slug=request.resolver_match.kwargs['organizer'],
).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
request.organizer = request.event.organizer
if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key):
request.eventpermset = SuperuserPermissionSet()
else:
request.eventpermset = perm_holder.get_event_permission_set(request.organizer, request.event)
request.eventpermset = perm_holder.get_event_permission_set(request.organizer, request.event)
if required_permission and required_permission not in request.eventpermset:
return False
@@ -53,12 +49,9 @@ class EventPermission(BasePermission):
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, request=request):
if not request.organizer or not perm_holder.has_organizer_permission(request.organizer):
return False
if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key):
request.orgapermset = SuperuserPermissionSet()
else:
request.orgapermset = perm_holder.get_organizer_permission_set(request.organizer)
request.orgapermset = perm_holder.get_organizer_permission_set(request.organizer)
if required_permission and required_permission not in request.orgapermset:
return False

View File

@@ -1,91 +0,0 @@
import json
from hashlib import sha1
from django.conf import settings
from django.db import transaction
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.utils.timezone import now
from rest_framework import status
from pretix.api.models import ApiCall
class IdempotencyMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request: HttpRequest):
if request.method in ('GET', 'HEAD', 'OPTIONS'):
return self.get_response(request)
if not request.path.startswith('/api/'):
return self.get_response(request)
if not request.headers.get('X-Idempotency-Key'):
return self.get_response(request)
auth_hash_parts = '{}:{}'.format(
request.headers.get('Authorization', ''),
request.COOKIES.get(settings.SESSION_COOKIE_NAME, '')
)
auth_hash = sha1(auth_hash_parts.encode()).hexdigest()
idempotency_key = request.headers.get('X-Idempotency-Key', '')
with transaction.atomic():
call, created = ApiCall.objects.select_for_update().get_or_create(
auth_hash=auth_hash,
idempotency_key=idempotency_key,
defaults={
'locked': now(),
'request_method': request.method,
'request_path': request.path,
'response_code': 0,
'response_headers': '{}',
'response_body': b''
}
)
if created:
resp = self.get_response(request)
with transaction.atomic():
if resp.status_code in (409, 429, 503):
# This is the exception: These calls are *meant* to be retried!
call.delete()
else:
call.response_code = resp.status_code
if isinstance(resp.content, str):
call.response_body = resp.content.encode()
elif isinstance(resp.content, memoryview):
call.response_body = resp.content.tobytes()
elif isinstance(resp.content, bytes):
call.response_body = resp.content
elif hasattr(resp.content, 'read'):
call.response_body = resp.read()
elif hasattr(resp, 'data'):
call.response_body = json.dumps(resp.data)
else:
call.response_body = repr(resp).encode()
call.response_headers = json.dumps(resp._headers)
call.locked = None
call.save(update_fields=['locked', 'response_code', 'response_headers',
'response_body'])
return resp
else:
if call.locked:
r = JsonResponse(
{'detail': 'Concurrent request with idempotency key.'},
status=status.HTTP_409_CONFLICT,
)
r['Retry-After'] = 5
return r
content = call.response_body
if isinstance(content, memoryview):
content = content.tobytes()
r = HttpResponse(
content=content,
status=call.response_code,
)
for k, v in json.loads(call.response_headers).values():
r[k] = v
return r

View File

@@ -1,44 +0,0 @@
# Generated by Django 2.1.5 on 2019-04-05 10:48
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('pretixbase', '0116_auto_20190402_0722'),
('pretixapi', '0003_webhook_webhookcall_webhookeventlistener'),
]
operations = [
migrations.CreateModel(
name='ApiCall',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('idempotency_key', models.CharField(db_index=True, max_length=190)),
('auth_hash', models.CharField(db_index=True, max_length=190)),
('created', models.DateTimeField(auto_now_add=True)),
('locked', models.DateTimeField(null=True)),
('request_method', models.CharField(max_length=20)),
('request_path', models.CharField(max_length=255)),
('response_code', models.PositiveIntegerField()),
('response_headers', models.TextField()),
('response_body', models.BinaryField()),
],
),
migrations.AlterModelOptions(
name='webhookcall',
options={'ordering': ('-datetime',)},
),
migrations.AlterModelOptions(
name='webhookeventlistener',
options={'ordering': ('action_type',)},
),
migrations.AlterUniqueTogether(
name='apicall',
unique_together={('idempotency_key', 'auth_hash')},
),
]

View File

@@ -77,9 +77,6 @@ class WebHook(models.Model):
all_events = models.BooleanField(default=True, verbose_name=_("All events (including newly created ones)"))
limit_events = models.ManyToManyField('pretixbase.Event', verbose_name=_("Limit to events"), blank=True)
class Meta:
ordering = ('id',)
@property
def action_types(self):
return [
@@ -109,20 +106,3 @@ class WebHookCall(models.Model):
class Meta:
ordering = ("-datetime",)
class ApiCall(models.Model):
idempotency_key = models.CharField(max_length=190, db_index=True)
auth_hash = models.CharField(max_length=190, db_index=True)
created = models.DateTimeField(auto_now_add=True)
locked = models.DateTimeField(null=True)
request_method = models.CharField(max_length=20)
request_path = models.CharField(max_length=255)
response_code = models.PositiveIntegerField()
response_headers = models.TextField()
response_body = models.BinaryField()
class Meta:
unique_together = (('idempotency_key', 'auth_hash'),)

View File

@@ -31,10 +31,10 @@ class PluginsField(Field):
def to_representation(self, obj):
from pretix.base.plugins import get_all_plugins
return sorted([
return {
p.module for p in get_all_plugins()
if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in obj.get_plugins()
])
}
def to_internal_value(self, data):
return {
@@ -191,114 +191,17 @@ class SubEventItemVariationSerializer(I18nAwareModelSerializer):
class SubEventSerializer(I18nAwareModelSerializer):
item_price_overrides = SubEventItemSerializer(source='subeventitem_set', many=True, required=False)
variation_price_overrides = SubEventItemVariationSerializer(source='subeventitemvariation_set', many=True, required=False)
item_price_overrides = SubEventItemSerializer(source='subeventitem_set', many=True)
variation_price_overrides = SubEventItemVariationSerializer(source='subeventitemvariation_set', many=True)
event = SlugRelatedField(slug_field='slug', read_only=True)
meta_data = MetaDataField(source='*')
class Meta:
model = SubEvent
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
'presale_start', 'presale_end', 'location', 'event', 'is_public',
'presale_start', 'presale_end', 'location', 'event',
'item_price_overrides', 'variation_price_overrides', 'meta_data')
def validate(self, data):
data = super().validate(data)
event = self.context['request'].event
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
Event.clean_dates(data.get('date_from'), data.get('date_to'))
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_variations(event, [item['variation'] for item in full_data.get('subeventitemvariation_set', [])])
return data
def validate_item_price_overrides(self, data):
return list(filter(lambda i: 'item' in i, data))
def validate_variation_price_overrides(self, data):
return list(filter(lambda i: 'variation' in i, data))
@cached_property
def meta_properties(self):
return {
p.name: p for p in self.context['request'].organizer.meta_properties.all()
}
def validate_meta_data(self, value):
for key in value['meta_data'].keys():
if key not in self.meta_properties:
raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key))
return value
@transaction.atomic
def create(self, validated_data):
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 {}
meta_data = validated_data.pop('meta_data', None)
subevent = super().create(validated_data)
for item_price_override_data in item_price_overrides_data:
SubEventItem.objects.create(subevent=subevent, **item_price_override_data)
for variation_price_override_data in variation_price_overrides_data:
SubEventItemVariation.objects.create(subevent=subevent, **variation_price_override_data)
# Meta data
if meta_data is not None:
for key, value in meta_data.items():
subevent.meta_values.create(
property=self.meta_properties.get(key),
value=value
)
return subevent
@transaction.atomic
def update(self, instance, validated_data):
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 {}
meta_data = validated_data.pop('meta_data', None)
subevent = super().update(instance, validated_data)
existing_item_overrides = {item.item: item.id for item in SubEventItem.objects.filter(subevent=subevent)}
for item_price_override_data in item_price_overrides_data:
id = existing_item_overrides.pop(item_price_override_data['item'], None)
SubEventItem(id=id, subevent=subevent, **item_price_override_data).save()
SubEventItem.objects.filter(id__in=existing_item_overrides.values()).delete()
existing_variation_overrides = {item.variation: item.id for item in SubEventItemVariation.objects.filter(subevent=subevent)}
for variation_price_override_data in variation_price_overrides_data:
id = existing_variation_overrides.pop(variation_price_override_data['variation'], None)
SubEventItemVariation(id=id, subevent=subevent, **variation_price_override_data).save()
SubEventItemVariation.objects.filter(id__in=existing_variation_overrides.values()).delete()
# Meta data
if meta_data is not None:
current = {mv.property: mv for mv in subevent.meta_values.select_related('property')}
for key, value in meta_data.items():
prop = self.meta_properties.get(key)
if prop in current:
current[prop].value = value
current[prop].save()
else:
subevent.meta_values.create(
property=self.meta_properties.get(key),
value=value
)
for prop, current_object in current.items():
if prop.name not in meta_data:
current_object.delete()
return subevent
class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
class Meta:

View File

@@ -7,36 +7,23 @@ from rest_framework import serializers
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Question,
QuestionOption, Quota,
Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption,
Quota,
)
class InlineItemVariationSerializer(I18nAwareModelSerializer):
price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=10,
coerce_to_string=True)
class Meta:
model = ItemVariation
fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price', 'original_price')
'position', 'default_price', 'price')
class ItemVariationSerializer(I18nAwareModelSerializer):
price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=10,
coerce_to_string=True)
class Meta:
model = ItemVariation
fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price', 'original_price')
class InlineItemBundleSerializer(serializers.ModelSerializer):
class Meta:
model = ItemBundle
fields = ('bundled_item', 'bundled_variation', 'count',
'designated_price')
'position', 'default_price', 'price')
class InlineItemAddOnSerializer(serializers.ModelSerializer):
@@ -46,31 +33,6 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer):
'position', 'price_included')
class ItemBundleSerializer(serializers.ModelSerializer):
class Meta:
model = ItemBundle
fields = ('id', 'bundled_item', 'bundled_variation', 'count',
'designated_price')
def validate(self, data):
data = super().validate(data)
event = self.context['event']
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
ItemBundle.clean_itemvar(event, full_data.get('bundled_item'), full_data.get('bundled_variation'))
item = self.context['item']
if item == full_data.get('bundled_item'):
raise ValidationError(_("The bundled item must not be the same item as the bundling one."))
if full_data.get('bundled_item'):
if full_data['bundled_item'].bundles.exists():
raise ValidationError(_("The bundled item must not have bundles on its own."))
return data
class ItemAddOnSerializer(serializers.ModelSerializer):
class Meta:
model = ItemAddOn
@@ -107,7 +69,6 @@ class ItemTaxRateField(serializers.Field):
class ItemSerializer(I18nAwareModelSerializer):
addons = InlineItemAddOnSerializer(many=True, required=False)
bundles = InlineItemBundleSerializer(many=True, required=False)
variations = InlineItemVariationSerializer(many=True, required=False)
tax_rate = ItemTaxRateField(source='*', read_only=True)
@@ -116,9 +77,9 @@ class ItemSerializer(I18nAwareModelSerializer):
fields = ('id', 'category', 'name', 'internal_name', 'active', 'sales_channels', 'description',
'default_price', 'free_price', 'tax_rate', 'tax_rule', 'admission',
'position', 'picture', 'available_from', 'available_until',
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets')
'require_voucher', 'hide_without_voucher', 'allow_cancel',
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations',
'variations', 'addons', 'original_price', 'require_approval', 'generate_tickets')
read_only_fields = ('has_variations', 'picture')
def get_serializer_context(self):
@@ -126,8 +87,8 @@ class ItemSerializer(I18nAwareModelSerializer):
def validate(self, data):
data = super().validate(data)
if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data):
raise ValidationError(_('Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use the '
if self.instance and ('addons' in data or 'variations' in data):
raise ValidationError(_('Updating add-ons or variations via PATCH/PUT is not supported. Please use the '
'dedicated nested endpoint.'))
Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order'))
@@ -143,12 +104,6 @@ class ItemSerializer(I18nAwareModelSerializer):
Item.clean_tax_rule(value, self.context['event'])
return value
def validate_bundles(self, value):
if not self.instance:
for b_data in value:
ItemBundle.clean_itemvar(self.context['event'], b_data['bundled_item'], b_data['bundled_variation'])
return value
def validate_addons(self, value):
if not self.instance:
for addon_data in value:
@@ -162,14 +117,11 @@ class ItemSerializer(I18nAwareModelSerializer):
def create(self, validated_data):
variations_data = validated_data.pop('variations') if 'variations' in validated_data else {}
addons_data = validated_data.pop('addons') if 'addons' in validated_data else {}
bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {}
item = Item.objects.create(**validated_data)
for variation_data in variations_data:
ItemVariation.objects.create(item=item, **variation_data)
for addon_data in addons_data:
ItemAddOn.objects.create(base_item=item, **addon_data)
for bundle_data in bundles_data:
ItemBundle.objects.create(base_item=item, **bundle_data)
return item
@@ -207,21 +159,12 @@ class QuestionSerializer(I18nAwareModelSerializer):
class Meta:
model = Question
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_value',
'hidden')
'ask_during_checkin', 'identifier')
def validate_identifier(self, value):
Question._clean_identifier(self.context['event'], value, self.instance)
return value
def validate_dependency_question(self, value):
if value:
if value.type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE):
raise ValidationError('Question dependencies can only be set to boolean or choice questions.')
if value == self.instance:
raise ValidationError('A question cannot depend on itself.')
return value
def validate(self, data):
data = super().validate(data)
if self.instance and 'options' in data:
@@ -233,18 +176,6 @@ class QuestionSerializer(I18nAwareModelSerializer):
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
if full_data.get('ask_during_checkin') and full_data.get('dependency_question'):
raise ValidationError('Dependencies are not supported during check-in.')
dep = full_data.get('dependency_question')
if dep:
seen_ids = {self.instance.pk} if self.instance else set()
while dep:
if dep.pk in seen_ids:
raise ValidationError(_('Circular dependency between questions detected.'))
seen_ids.add(dep.pk)
dep = dep.dependency_question
Question.clean_items(event, full_data.get('items'))
return data

View File

@@ -14,8 +14,8 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.channels import get_all_sales_channels
from pretix.base.i18n import language
from pretix.base.models import (
Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order,
OrderPosition, Question, QuestionAnswer, SubEvent,
Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition,
Question, QuestionAnswer,
)
from pretix.base.models.orders import (
CartPosition, OrderFee, OrderPayment, OrderRefund,
@@ -179,55 +179,6 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
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):
# TODO: Remove after pretix 2.2
def to_representation(self, instance: Order):
@@ -269,90 +220,26 @@ class OrderRefundSerializer(I18nAwareModelSerializer):
class OrderSerializer(I18nAwareModelSerializer):
invoice_address = InvoiceAddressSerializer(allow_null=True)
positions = OrderPositionSerializer(many=True, read_only=True)
fees = OrderFeeSerializer(many=True, read_only=True)
downloads = OrderDownloadsField(source='*', read_only=True)
payments = OrderPaymentSerializer(many=True, read_only=True)
refunds = OrderRefundSerializer(many=True, read_only=True)
payment_date = OrderPaymentDateField(source='*', read_only=True)
payment_provider = OrderPaymentTypeField(source='*', read_only=True)
invoice_address = InvoiceAddressSerializer()
positions = OrderPositionSerializer(many=True)
fees = OrderFeeSerializer(many=True)
downloads = OrderDownloadsField(source='*')
payments = OrderPaymentSerializer(many=True)
refunds = OrderRefundSerializer(many=True)
payment_date = OrderPaymentDateField(source='*')
payment_provider = OrderPaymentTypeField(source='*')
class Meta:
model = Order
fields = (
'code', 'status', 'testmode', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel'
)
read_only_fields = (
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'positions', 'downloads',
'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel'
)
fields = ('code', 'status', 'testmode', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.context['request'].query_params.get('pdf_data', 'false') == 'true':
self.fields['positions'].child.fields.pop('pdf_data')
def validate_locale(self, l):
if l not in set(k for k in self.instance.event.settings.locales):
raise ValidationError('"{}" is not a supported locale for this event.'.format(l))
return l
def update(self, instance, validated_data):
# Even though all fields that shouldn't be edited are marked as read_only in the serializer
# (hopefully), we'll be extra careful here and be explicit about the model fields we update.
update_fields = ['comment', 'checkin_attention', 'email', 'locale']
print(validated_data)
if 'invoice_address' in validated_data:
iadata = validated_data.pop('invoice_address')
if not iadata:
try:
instance.invoice_address.delete()
except InvoiceAddress.DoesNotExist:
pass
else:
name = iadata.pop('name', '')
if name and not iadata.get('name_parts'):
iadata['name_parts'] = {
'_legacy': name
}
try:
ia = instance.invoice_address
if iadata.get('vat_id') != ia.vat_id:
ia.vat_id_validated = False
self.fields['invoice_address'].update(ia, iadata)
except InvoiceAddress.DoesNotExist:
InvoiceAddress.objects.create(order=instance, **iadata)
for attr, value in validated_data.items():
if attr in update_fields:
setattr(instance, attr, value)
instance.save(update_fields=update_fields)
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):
@@ -523,13 +410,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
payment_provider = serializers.CharField(required=True)
payment_info = CompatibleJSONField(required=False)
consume_carts = serializers.ListField(child=serializers.CharField(), required=False)
force = serializers.BooleanField(default=False, required=False)
payment_date = serializers.DateTimeField(required=False, allow_null=True)
class Meta:
model = Order
fields = ('code', 'status', 'testmode', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts', 'force')
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'consume_carts')
def validate_payment_provider(self, pp):
if pp not in self.context['event'].get_payment_providers():
@@ -600,8 +485,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
positions_data = validated_data.pop('positions') if 'positions' in validated_data else []
payment_provider = validated_data.pop('payment_provider')
payment_info = validated_data.pop('payment_info', '{}')
payment_date = validated_data.pop('payment_date', now())
force = validated_data.pop('force', False)
if 'invoice_address' in validated_data:
iadata = validated_data.pop('invoice_address')
@@ -635,30 +518,29 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
errs = [{} for p in positions_data]
if not force:
for i, pos_data in enumerate(positions_data):
new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
if pos_data.get('variation')
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')))
if len(new_quotas) == 0:
errs[i]['item'] = [ugettext_lazy('The product "{}" is not assigned to a quota.').format(
str(pos_data.get('item'))
)]
else:
for quota in new_quotas:
if quota not in quota_avail_cache:
quota_avail_cache[quota] = list(quota.availability())
for i, pos_data in enumerate(positions_data):
new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
if pos_data.get('variation')
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')))
if len(new_quotas) == 0:
errs[i]['item'] = [ugettext_lazy('The product "{}" is not assigned to a quota.').format(
str(pos_data.get('item'))
)]
else:
for quota in new_quotas:
if quota not in quota_avail_cache:
quota_avail_cache[quota] = list(quota.availability())
if quota_avail_cache[quota][1] is not None:
quota_avail_cache[quota][1] -= 1
if quota_avail_cache[quota][1] < 0:
errs[i]['item'] = [
ugettext_lazy('There is not enough quota available on quota "{}" to perform the operation.').format(
quota.name
)
]
if quota_avail_cache[quota][1] is not None:
quota_avail_cache[quota][1] -= 1
if quota_avail_cache[quota][1] < 0:
errs[i]['item'] = [
ugettext_lazy('There is not enough quota available on quota "{}" to perform the operation.').format(
quota.name
)
]
quotadiff.update(new_quotas)
quotadiff.update(new_quotas)
if any(errs):
raise ValidationError({'positions': errs})
@@ -685,7 +567,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
amount=order.total,
provider=payment_provider,
info=payment_info,
payment_date=payment_date,
payment_date=now(),
state=OrderPayment.PAYMENT_STATE_CONFIRMED
)
elif payment_provider:

View File

@@ -3,7 +3,7 @@ from datetime import timedelta
from django.dispatch import Signal, receiver
from django.utils.timezone import now
from pretix.api.models import ApiCall, WebHookCall
from pretix.api.models import WebHookCall
from pretix.base.signals import periodic_task
register_webhook_events = Signal(
@@ -19,8 +19,3 @@ instances.
@receiver(periodic_task)
def cleanup_webhook_logs(sender, **kwargs):
WebHookCall.objects.filter(datetime__lte=now() - timedelta(days=30)).delete()
@receiver(periodic_task)
def cleanup_api_logs(sender, **kwargs):
ApiCall.objects.filter(created__lte=now() - timedelta(hours=24)).delete()

View File

@@ -44,7 +44,6 @@ question_router.register(r'options', item.QuestionOptionViewSet)
item_router = routers.DefaultRouter()
item_router.register(r'variations', item.ItemVariationViewSet)
item_router.register(r'addons', item.ItemAddOnViewSet)
item_router.register(r'bundles', item.ItemBundleViewSet)
order_router = routers.DefaultRouter()
order_router.register(r'payments', order.PaymentViewSet)

View File

@@ -31,10 +31,10 @@ class RichOrderingFilter(OrderingFilter):
class ConditionalListView:
def list(self, request, **kwargs):
if_modified_since = request.headers.get('If-Modified-Since')
if_modified_since = request.META.get('HTTP_IF_MODIFIED_SINCE')
if if_modified_since:
if_modified_since = parse_http_date_safe(if_modified_since)
if_unmodified_since = request.headers.get('If-Unmodified-Since')
if_unmodified_since = request.META.get('HTTP_IF_UNMODIFIED_SINCE')
if if_unmodified_since:
if_unmodified_since = parse_http_date_safe(if_unmodified_since)
if not hasattr(request, 'event'):

View File

@@ -7,13 +7,13 @@ from django.utils.functional import cached_property
from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.decorators import detail_route
from rest_framework.fields import DateTimeField
from rest_framework.response import Response
from pretix.api.serializers.checkin import CheckinListSerializer
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.order import OrderPositionFilter
from pretix.base.models import (
@@ -77,7 +77,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
)
super().perform_destroy(instance)
@action(detail=True, methods=['GET'])
@detail_route(methods=['GET'])
def status(self, *args, **kwargs):
clist = self.get_object()
cqs = Checkin.objects.filter(
@@ -153,7 +153,7 @@ class CheckinOrderPositionFilter(OrderPositionFilter):
class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = CheckinListOrderPositionSerializer
serializer_class = OrderPositionSerializer
queryset = OrderPosition.objects.none()
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
ordering = ('attendee_name_cached', 'positionid')
@@ -189,7 +189,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
except ValueError:
raise Http404()
def get_queryset(self, ignore_status=False):
def get_queryset(self):
cqs = Checkin.objects.filter(
position_id=OuterRef('pk'),
list_id=self.checkinlist.pk
@@ -199,15 +199,11 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
qs = OrderPosition.objects.filter(
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
).annotate(
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':
qs = qs.prefetch_related(
Prefetch(
@@ -229,7 +225,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
)
))
).select_related(
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address'
'item', 'variation', 'item__category', 'addon_to'
)
else:
qs = qs.prefetch_related(
@@ -239,19 +235,19 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
),
'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order')
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address')
if not self.checkinlist.all_products:
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
return qs
@action(detail=True, methods=['POST'])
@detail_route(methods=['POST'])
def redeem(self, *args, **kwargs):
force = bool(self.request.data.get('force', False))
ignore_unpaid = bool(self.request.data.get('ignore_unpaid', False))
nonce = self.request.data.get('nonce')
op = self.get_object(ignore_status=True)
op = self.get_object()
if 'datetime' in self.request.data:
dt = DateTimeField().to_internal_value(self.request.data.get('datetime'))
@@ -285,7 +281,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
return Response({
'status': 'incomplete',
'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': [
QuestionSerializer(q).data for q in e.questions
]
@@ -295,17 +291,17 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
'status': 'error',
'reason': e.code,
'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)
else:
return Response({
'status': 'ok',
'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)
def get_object(self, ignore_status=False):
queryset = self.filter_queryset(self.get_queryset(ignore_status=ignore_status))
def get_object(self):
queryset = self.filter_queryset(self.get_queryset())
if self.kwargs['pk'].isnumeric():
obj = get_object_or_404(queryset, Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
else:

View File

@@ -105,7 +105,7 @@ class RevokeKeyView(APIView):
def post(self, request, format=None):
device = request.auth
device.revoked = True
device.api_token = None
device.save()
device.log_action('pretix.device.revoked', auth=device)

View File

@@ -13,7 +13,7 @@ from pretix.api.serializers.event import (
)
from pretix.api.views import ConditionalListView
from pretix.base.models import (
CartPosition, Device, Event, ItemCategory, TaxRule, TeamAPIToken,
Device, Event, ItemCategory, TaxRule, TeamAPIToken,
)
from pretix.base.models.event import SubEvent
from pretix.helpers.dicts import merge_dicts
@@ -217,10 +217,9 @@ class SubEventFilter(FilterSet):
return queryset.exclude(expr)
class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
class SubEventViewSet(ConditionalListView, viewsets.ReadOnlyModelViewSet):
serializer_class = SubEventSerializer
queryset = ItemCategory.objects.none()
write_permission = 'can_change_event_settings'
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
filterset_class = SubEventFilter
@@ -241,44 +240,6 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
'subeventitem_set', 'subeventitemvariation_set'
)
def perform_update(self, serializer):
super().perform_update(serializer)
serializer.instance.log_action(
'pretix.subevent.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
'pretix.subevent.added',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
def perform_destroy(self, instance):
if not instance.allow_delete():
raise PermissionDenied('The sub-event can not be deleted as it has already been used in orders. Please set'
' \'active\' to false instead to hide it from users.')
try:
with transaction.atomic():
instance.log_action(
'pretix.subevent.deleted',
user=self.request.user,
auth=self.request.auth,
data=self.request.data
)
CartPosition.objects.filter(addon_to__subevent=instance).delete()
instance.cartposition_set.all().delete()
super().perform_destroy(instance)
except ProtectedError:
raise PermissionDenied('The sub-event could not be deleted as some constraints (e.g. data created by '
'plug-ins) do not allow it.')
class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = TaxRuleSerializer

View File

@@ -1,23 +1,22 @@
import django_filters
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.decorators import detail_route
from rest_framework.exceptions import PermissionDenied
from rest_framework.filters import OrderingFilter
from rest_framework.response import Response
from pretix.api.serializers.item import (
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
ItemSerializer, ItemVariationSerializer, QuestionOptionSerializer,
QuestionSerializer, QuotaSerializer,
ItemAddOnSerializer, ItemCategorySerializer, ItemSerializer,
ItemVariationSerializer, QuestionOptionSerializer, QuestionSerializer,
QuotaSerializer,
)
from pretix.api.views import ConditionalListView
from pretix.base.models import (
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation,
Question, QuestionOption, Quota,
Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption,
Quota,
)
from pretix.helpers.dicts import merge_dicts
@@ -47,7 +46,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
write_permission = 'can_change_items'
def get_queryset(self):
return self.request.event.items.select_related('tax_rule').prefetch_related('variations', 'addons', 'bundles').all()
return self.request.event.items.select_related('tax_rule').prefetch_related('variations', 'addons').all()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
@@ -84,8 +83,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
user=self.request.user,
auth=self.request.auth,
)
CartPosition.objects.filter(addon_to__item=instance).delete()
instance.cartposition_set.all().delete()
self.get_object().cartposition_set.all().delete()
super().perform_destroy(instance)
@@ -98,20 +96,17 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
permission = None
write_permission = 'can_change_items'
@cached_property
def item(self):
return get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
def get_queryset(self):
return self.item.variations.all()
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
return item.variations.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['item'] = self.item
ctx['item'] = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
return ctx
def perform_create(self, serializer):
item = self.item
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
if not item.has_variations:
raise PermissionDenied('This variation cannot be created because the item does not have variations. '
'Changing a product without variations to a product with variations is not allowed.')
@@ -154,58 +149,6 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
)
class ItemBundleViewSet(viewsets.ModelViewSet):
serializer_class = ItemBundleSerializer
queryset = ItemBundle.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter,)
ordering_fields = ('id',)
ordering = ('id',)
permission = None
write_permission = 'can_change_items'
@cached_property
def item(self):
return get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
def get_queryset(self):
return self.item.bundles.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['item'] = self.item
return ctx
def perform_create(self, serializer):
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
serializer.save(base_item=item)
item.log_action(
'pretix.event.item.bundles.added',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
)
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.base_item.log_action(
'pretix.event.item.bundles.changed',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
)
def perform_destroy(self, instance):
super().perform_destroy(instance)
instance.base_item.log_action(
'pretix.event.item.bundles.removed',
user=self.request.user,
auth=self.request.auth,
data={'bundled_item': instance.bundled_item.pk, 'bundled_variation': instance.bundled_variation.pk if instance.bundled_variation else None,
'count': instance.count, 'designated_price': instance.designated_price}
)
class ItemAddOnViewSet(viewsets.ModelViewSet):
serializer_class = ItemAddOnSerializer
queryset = ItemAddOn.objects.none()
@@ -215,21 +158,18 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
permission = None
write_permission = 'can_change_items'
@cached_property
def item(self):
return get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
def get_queryset(self):
return self.item.addons.all()
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
return item.addons.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['item'] = self.item
ctx['item'] = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
return ctx
def perform_create(self, serializer):
item = self.item
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
category = get_object_or_404(ItemCategory, pk=self.request.data['addon_category'])
serializer.save(base_item=item, addon_category=category)
item.log_action(
@@ -499,7 +439,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
)
super().perform_destroy(instance)
@action(detail=True, methods=['get'])
@detail_route(methods=['get'])
def availability(self, request, *args, **kwargs):
quota = self.get_object()

View File

@@ -9,31 +9,27 @@ from django.db.models.functions import Coalesce, Concat
from django.http import FileResponse
from django.shortcuts import get_object_or_404
from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext as _
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import mixins, serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.decorators import detail_route
from rest_framework.exceptions import (
APIException, NotFound, PermissionDenied, ValidationError,
)
from rest_framework.filters import OrderingFilter
from rest_framework.mixins import CreateModelMixin
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
from rest_framework.response import Response
from pretix.api.models import OAuthAccessToken
from pretix.api.serializers.order import (
InvoiceSerializer, OrderCreateSerializer, OrderPaymentSerializer,
OrderPositionSerializer, OrderRefundCreateSerializer,
OrderRefundSerializer, OrderSerializer, PriceCalcSerializer,
OrderRefundSerializer, OrderSerializer,
)
from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, InvoiceAddress,
Order, OrderPayment, OrderPosition, OrderRefund, Quota, TeamAPIToken,
generate_position_secret, generate_secret,
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, Order,
OrderPayment, OrderPosition, OrderRefund, Quota, TeamAPIToken,
)
from pretix.base.payment import PaymentException
from pretix.base.services import tickets
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
regenerate_invoice,
@@ -43,12 +39,8 @@ from pretix.base.services.orders import (
OrderChangeManager, OrderError, approve_order, cancel_order, deny_order,
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.signals import (
order_modified, order_placed, register_ticket_outputs,
)
from pretix.base.templatetags.money import money_filter
from pretix.base.signals import order_placed, register_ticket_outputs
class OrderFilter(FilterSet):
@@ -56,14 +48,13 @@ class OrderFilter(FilterSet):
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(DestroyModelMixin, CreateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderSerializer
queryset = Order.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
@@ -131,7 +122,7 @@ class OrderViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data, headers={'X-Page-Generated': date})
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
@detail_route(url_name='download', url_path='download/(?P<output>[^/]+)')
def download(self, request, output, **kwargs):
provider = self._get_output_provider(output)
order = self.get_object()
@@ -153,7 +144,7 @@ class OrderViewSet(viewsets.ModelViewSet):
)
return resp
@action(detail=True, methods=['POST'])
@detail_route(methods=['POST'])
def mark_paid(self, request, **kwargs):
order = self.get_object()
@@ -194,7 +185,7 @@ class OrderViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST
)
@action(detail=True, methods=['POST'])
@detail_route(methods=['POST'])
def mark_canceled(self, request, **kwargs):
send_mail = request.data.get('send_email', True)
cancellation_fee = request.data.get('cancellation_fee', None)
@@ -228,7 +219,7 @@ class OrderViewSet(viewsets.ModelViewSet):
)
return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST'])
@detail_route(methods=['POST'])
def approve(self, request, **kwargs):
send_mail = request.data.get('send_email', True)
@@ -246,7 +237,7 @@ class OrderViewSet(viewsets.ModelViewSet):
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST'])
@detail_route(methods=['POST'])
def deny(self, request, **kwargs):
send_mail = request.data.get('send_email', True)
comment = request.data.get('comment', '')
@@ -264,7 +255,7 @@ class OrderViewSet(viewsets.ModelViewSet):
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST'])
@detail_route(methods=['POST'])
def mark_pending(self, request, **kwargs):
order = self.get_object()
@@ -283,7 +274,7 @@ class OrderViewSet(viewsets.ModelViewSet):
)
return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST'])
@detail_route(methods=['POST'])
def mark_expired(self, request, **kwargs):
order = self.get_object()
@@ -300,7 +291,7 @@ class OrderViewSet(viewsets.ModelViewSet):
)
return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST'])
@detail_route(methods=['POST'])
def mark_refunded(self, request, **kwargs):
order = self.get_object()
@@ -317,73 +308,7 @@ class OrderViewSet(viewsets.ModelViewSet):
)
return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST'])
def create_invoice(self, request, **kwargs):
order = self.get_object()
has_inv = order.invoices.exists() and not (
order.status in (Order.STATUS_PAID, Order.STATUS_PENDING)
and order.invoices.filter(is_cancellation=True).count() >= order.invoices.filter(is_cancellation=False).count()
)
if self.request.event.settings.get('invoice_generate') not in ('admin', 'user', 'paid', 'True') or not invoice_qualified(order):
return Response(
{'detail': _('You cannot generate an invoice for this order.')},
status=status.HTTP_400_BAD_REQUEST
)
elif has_inv:
return Response(
{'detail': _('An invoice for this order already exists.')},
status=status.HTTP_400_BAD_REQUEST
)
inv = generate_invoice(order)
order.log_action(
'pretix.event.order.invoice.generated',
user=self.request.user,
auth=self.request.auth,
data={
'invoice': inv.pk
}
)
return Response(
InvoiceSerializer(inv).data,
status=status.HTTP_201_CREATED
)
@action(detail=True, methods=['POST'])
def resend_link(self, request, **kwargs):
order = self.get_object()
if not order.email:
return Response({'detail': 'There is no email address associated with this order.'}, status=status.HTTP_400_BAD_REQUEST)
try:
order.resend_link(user=self.request.user, auth=self.request.auth)
except SendMailException:
return Response({'detail': _('There was an error sending the mail. Please try again later.')}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
return Response(
status=status.HTTP_204_NO_CONTENT
)
@action(detail=True, methods=['POST'])
@transaction.atomic
def regenerate_secrets(self, request, **kwargs):
order = self.get_object()
order.secret = generate_secret()
for op in order.all_positions.all():
op.secret = generate_position_secret()
op.save()
order.save(update_fields=['secret'])
CachedTicket.objects.filter(order_position__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(
'pretix.event.order.secret.changed',
user=self.request.user,
auth=self.request.auth,
)
return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST'])
@detail_route(methods=['POST'])
def extend(self, request, **kwargs):
new_date = request.data.get('expires', None)
force = request.data.get('force', False)
@@ -450,75 +375,6 @@ class OrderViewSet(viewsets.ModelViewSet):
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def update(self, request, *args, **kwargs):
partial = kwargs.get('partial', False)
if not partial:
return Response(
{"detail": "Method \"PUT\" not allowed."},
status=status.HTTP_405_METHOD_NOT_ALLOWED,
)
return super().update(request, *args, **kwargs)
def perform_update(self, serializer):
with transaction.atomic():
if 'comment' in self.request.data and serializer.instance.comment != self.request.data.get('comment'):
serializer.instance.log_action(
'pretix.event.order.comment',
user=self.request.user,
auth=self.request.auth,
data={
'new_comment': self.request.data.get('comment')
}
)
if 'checkin_attention' in self.request.data and serializer.instance.checkin_attention != self.request.data.get('checkin_attention'):
serializer.instance.log_action(
'pretix.event.order.checkin_attention',
user=self.request.user,
auth=self.request.auth,
data={
'new_value': self.request.data.get('checkin_attention')
}
)
if 'email' in self.request.data and serializer.instance.email != self.request.data.get('email'):
serializer.instance.log_action(
'pretix.event.order.contact.changed',
user=self.request.user,
auth=self.request.auth,
data={
'old_email': serializer.instance.email,
'new_email': self.request.data.get('email'),
}
)
if 'locale' in self.request.data and serializer.instance.locale != self.request.data.get('locale'):
serializer.instance.log_action(
'pretix.event.order.locale.changed',
user=self.request.user,
auth=self.request.auth,
data={
'old_locale': serializer.instance.locale,
'new_locale': self.request.data.get('locale'),
}
)
if 'invoice_address' in self.request.data:
serializer.instance.log_action(
'pretix.event.order.modified',
user=self.request.user,
auth=self.request.auth,
data={
'invoice_data': self.request.data.get('invoice_address'),
}
)
serializer.save()
tickets.invalidate_cache.apply_async(kwargs={'event': serializer.instance.event.pk, 'order': serializer.instance.pk})
if 'invoice_address' in self.request.data:
order_modified.send(sender=serializer.instance.event, order=serializer.instance)
def perform_create(self, serializer):
serializer.save()
@@ -541,8 +397,6 @@ class OrderPositionFilter(FilterSet):
Q(secret__istartswith=value)
| Q(attendee_name_cached__icontains=value)
| Q(addon_to__attendee_name_cached__icontains=value)
| Q(attendee_email__icontains=value)
| Q(addon_to__attendee_email__icontains=value)
| Q(order__code__istartswith=value)
| Q(order__invoice_address__name_cached__icontains=value)
| Q(order__email__icontains=value)
@@ -626,84 +480,7 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
return prov
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>[^/]+)')
@detail_route(url_name='download', url_path='download/(?P<output>[^/]+)')
def download(self, request, output, **kwargs):
provider = self._get_output_provider(output)
pos = self.get_object()
@@ -754,7 +531,7 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
order = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
return order.payments.all()
@action(detail=True, methods=['POST'])
@detail_route(methods=['POST'])
def confirm(self, request, **kwargs):
payment = self.get_object()
force = request.data.get('force', False)
@@ -775,7 +552,7 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
pass
return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST'])
@detail_route(methods=['POST'])
def refund(self, request, **kwargs):
payment = self.get_object()
amount = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
@@ -840,7 +617,7 @@ class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
payment.order.save(update_fields=['status', 'expires'])
return Response(OrderRefundSerializer(r).data, status=status.HTTP_200_OK)
@action(detail=True, methods=['POST'])
@detail_route(methods=['POST'])
def cancel(self, request, **kwargs):
payment = self.get_object()
@@ -868,7 +645,7 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
order = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
return order.refunds.all()
@action(detail=True, methods=['POST'])
@detail_route(methods=['POST'])
def cancel(self, request, **kwargs):
refund = self.get_object()
@@ -885,7 +662,7 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST'])
@detail_route(methods=['POST'])
def process(self, request, **kwargs):
refund = self.get_object()
@@ -910,7 +687,7 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
refund.order.save(update_fields=['status', 'expires'])
return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST'])
@detail_route(methods=['POST'])
def done(self, request, **kwargs):
refund = self.get_object()
@@ -1000,7 +777,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
nr=Concat('prefix', 'invoice_no')
)
@action(detail=True, )
@detail_route()
def download(self, request, **kwargs):
invoice = self.get_object()
@@ -1018,7 +795,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number)
return resp
@action(detail=True, methods=['POST'])
@detail_route(methods=['POST'])
def regenerate(self, request, **kwarts):
inv = self.get_object()
if inv.canceled:
@@ -1037,7 +814,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
)
return Response(status=204)
@action(detail=True, methods=['POST'])
@detail_route(methods=['POST'])
def reissue(self, request, **kwarts):
inv = self.get_object()
if inv.canceled:

View File

@@ -7,7 +7,7 @@ from django_filters.rest_framework import (
BooleanFilter, DjangoFilterBackend, FilterSet,
)
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.decorators import list_route
from rest_framework.exceptions import PermissionDenied
from rest_framework.filters import OrderingFilter
from rest_framework.response import Response
@@ -111,12 +111,9 @@ class VoucherViewSet(viewsets.ModelViewSet):
user=self.request.user,
auth=self.request.auth,
)
with transaction.atomic():
instance.cartposition_set.filter(addon_to__isnull=False).delete()
instance.cartposition_set.all().delete()
super().perform_destroy(instance)
super().perform_destroy(instance)
@action(detail=False, methods=['POST'])
@list_route(methods=['POST'])
def batch_create(self, request, *args, **kwargs):
if any(self._predict_quota_check(d, None) for d in request.data):
lockfn = request.event.lock

View File

@@ -1,7 +1,7 @@
import django_filters
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.decorators import detail_route
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.filters import OrderingFilter
from rest_framework.response import Response
@@ -69,7 +69,7 @@ class WaitingListViewSet(viewsets.ModelViewSet):
)
super().perform_destroy(instance)
@action(detail=True, methods=['POST'])
@detail_route(methods=['POST'])
def send_voucher(self, *args, **kwargs):
try:
self.get_object().send_voucher(

View File

@@ -1,6 +1,8 @@
import logging
from smtplib import SMTPResponseException
import bleach
import markdown
from django.conf import settings
from django.core.mail.backends.smtp import EmailBackend
from django.dispatch import receiver
@@ -10,7 +12,7 @@ from inlinestyler.utils import inline_css
from pretix.base.models import Event, Order
from pretix.base.signals import register_html_mail_renderers
from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.base.templatetags.rich_text import markdown_compile
logger = logging.getLogger('pretix.base.email')
@@ -96,7 +98,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
raise NotImplementedError()
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order) -> str:
body_md = markdown_compile_email(plain_body)
body_md = bleach.linkify(markdown_compile(plain_body))
htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME,
'site_url': settings.SITE_URL,
@@ -110,7 +112,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
if plain_signature:
signature_md = plain_signature.replace('\n', '<br>\n')
signature_md = markdown_compile_email(signature_md)
signature_md = bleach.linkify(bleach.clean(markdown.markdown(signature_md), tags=bleach.ALLOWED_TAGS + ['p', 'br']))
htmlctx['signature'] = signature_md
if order:

View File

@@ -92,7 +92,7 @@ class ListExporter(BaseExporter):
choices=(
('xlsx', _('Excel (.xlsx)')),
('default', _('CSV (with commas)')),
('csv-excel', _('CSV (Excel-style)')),
('excel', _('CSV (Excel-style)')),
('semicolon', _('CSV (with semicolons)')),
),
)),

View File

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

View File

@@ -1,219 +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", "{}")
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],
'FN': invoice.invoice_to_name.rsplit(' ', 1)[0] if ' ' 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

@@ -6,7 +6,7 @@ from django import forms
from django.db.models import DateTimeField, F, Max, OuterRef, Subquery, Sum
from django.dispatch import receiver
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 (
InvoiceAddress, InvoiceLine, Order, OrderPosition,
@@ -108,7 +108,6 @@ class OrderListExporter(MultiSheetListExporter):
]
headers.append(_('Invoice numbers'))
headers.append(_('Sales channel'))
yield headers
@@ -177,7 +176,6 @@ class OrderListExporter(MultiSheetListExporter):
]
row.append(', '.join([i.number for i in order.invoices.all()]))
row.append(order.sales_channel)
yield row
def iterate_fees(self, form_data: dict):
@@ -269,10 +267,6 @@ class OrderListExporter(MultiSheetListExporter):
_('Status'),
_('Email'),
_('Order date'),
]
if self.event.has_subevents:
headers.append(pgettext('subevent', 'Date'))
headers += [
_('Product'),
_('Variation'),
_('Price'),
@@ -303,7 +297,6 @@ class OrderListExporter(MultiSheetListExporter):
headers += [
_('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
]
headers.append(_('Sales channel'))
yield headers
@@ -315,10 +308,6 @@ class OrderListExporter(MultiSheetListExporter):
order.get_status_display(),
order.email,
order.datetime.astimezone(tz).strftime('%Y-%m-%d'),
]
if self.event.has_subevents:
row.append(op.subevent)
row += [
str(op.item),
str(op.variation) if op.variation else '',
op.price,
@@ -362,7 +351,6 @@ class OrderListExporter(MultiSheetListExporter):
]
except InvoiceAddress.DoesNotExist:
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
row.append(order.sales_channel)
yield row
def get_filename(self):

View File

@@ -9,16 +9,14 @@ import vat_moss.id
from django import forms
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from django_countries.fields import CountryField
from pretix.base.forms.widgets import (
BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget,
TimePickerWidget, UploadedFileWidget,
)
from pretix.base.models import InvoiceAddress, Question, QuestionOption
from pretix.base.models import InvoiceAddress, Question
from pretix.base.models.tax import EU_COUNTRIES
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.templatetags.rich_text import rich_text
@@ -146,7 +144,6 @@ class BaseQuestionsForm(forms.Form):
item = pos.item
questions = pos.item.questions_to_ask
event = kwargs.pop('event')
self.all_optional = kwargs.pop('all_optional', False)
super().__init__(*args, **kwargs)
@@ -174,8 +171,6 @@ class BaseQuestionsForm(forms.Form):
initial = None
tz = pytz.timezone(event.settings.timezone)
help_text = rich_text(q.help_text)
label = escape(q.question) # django-bootstrap3 calls mark_safe
required = q.required and not self.all_optional
if q.type == Question.TYPE_BOOLEAN:
if q.required:
# For some reason, django-bootstrap3 does not set the required attribute
@@ -190,81 +185,71 @@ class BaseQuestionsForm(forms.Form):
initialbool = False
field = forms.BooleanField(
label=label, required=required,
label=q.question, required=q.required,
help_text=help_text,
initial=initialbool, widget=widget,
)
elif q.type == Question.TYPE_NUMBER:
field = forms.DecimalField(
label=label, required=required,
label=q.question, required=q.required,
help_text=q.help_text,
initial=initial.answer if initial else None,
min_value=Decimal('0.00'),
)
elif q.type == Question.TYPE_STRING:
field = forms.CharField(
label=label, required=required,
label=q.question, required=q.required,
help_text=help_text,
initial=initial.answer if initial else None,
)
elif q.type == Question.TYPE_TEXT:
field = forms.CharField(
label=label, required=required,
label=q.question, required=q.required,
help_text=help_text,
widget=forms.Textarea,
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:
field = forms.ModelChoiceField(
queryset=q.options,
label=label, required=required,
label=q.question, required=q.required,
help_text=help_text,
widget=forms.Select,
to_field_name='identifier',
empty_label='',
initial=initial.options.first() if initial else None,
)
elif q.type == Question.TYPE_CHOICE_MULTIPLE:
field = forms.ModelMultipleChoiceField(
queryset=q.options,
label=label, required=required,
label=q.question, required=q.required,
help_text=help_text,
to_field_name='identifier',
widget=forms.CheckboxSelectMultiple,
initial=initial.options.all() if initial else None,
)
elif q.type == Question.TYPE_FILE:
field = forms.FileField(
label=label, required=required,
label=q.question, required=q.required,
help_text=help_text,
initial=initial.file if initial else None,
widget=UploadedFileWidget(position=pos, event=event, answer=initial),
)
elif q.type == Question.TYPE_DATE:
field = forms.DateField(
label=label, required=required,
label=q.question, required=q.required,
help_text=help_text,
initial=dateutil.parser.parse(initial.answer).date() if initial and initial.answer else None,
widget=DatePickerWidget(),
)
elif q.type == Question.TYPE_TIME:
field = forms.TimeField(
label=label, required=required,
label=q.question, required=q.required,
help_text=help_text,
initial=dateutil.parser.parse(initial.answer).time() if initial and initial.answer else None,
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
)
elif q.type == Question.TYPE_DATETIME:
field = SplitDateTimeField(
label=label, required=required,
label=q.question, required=q.required,
help_text=help_text,
initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None,
widget=SplitDateTimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
@@ -273,15 +258,6 @@ class BaseQuestionsForm(forms.Form):
if answers:
# Cache the answer object for later use
field.answer = answers[0]
if q.dependency_question_id:
field.widget.attrs['data-question-dependency'] = q.dependency_question_id
field.widget.attrs['data-question-dependency-value'] = q.dependency_value
if q.type != 'M':
field.widget.attrs['required'] = q.required and not self.all_optional
field._required = q.required and not self.all_optional
field.required = False
self.fields['question_%s' % q.id] = field
responses = question_form_fields.send(sender=event, position=pos)
@@ -292,40 +268,6 @@ class BaseQuestionsForm(forms.Form):
self.fields[key] = value
value.initial = data.get('question_form_data', {}).get(key)
def clean(self):
d = super().clean()
question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)}
def question_is_visible(parentid, qval):
parentq = question_cache[parentid]
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_value):
return False
if 'question_%d' % parentid not in d:
return False
dval = d.get('question_%d' % parentid)
if qval == 'True':
return dval
elif qval == 'False':
return not dval
elif isinstance(dval, QuestionOption):
return dval.identifier == qval
else:
return qval in [o.identifier for o in dval]
def question_is_required(q):
return (
q.required and
(not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_value))
)
if not self.all_optional:
for q in question_cache.values():
if question_is_required(q) and not d.get('question_%d' % q.pk):
raise ValidationError({'question_%d' % q.pk: [_('This field is required')]})
return d
class BaseInvoiceAddressForm(forms.ModelForm):
vat_warning = False

View File

@@ -9,17 +9,14 @@ import vat_moss.exchange_rates
from django.contrib.staticfiles import finders
from django.dispatch import receiver
from django.utils.formats import date_format, localize
from django.utils.translation import (
get_language, pgettext, ugettext, ugettext_lazy,
)
from django.utils.translation import pgettext, ugettext
from PIL.Image import BICUBIC
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.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import (
@@ -125,7 +122,6 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
"""
stylesheet = StyleSheet1()
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='FineprintHeading', fontName=self.font_bold, fontSize=8, leading=12))
stylesheet.add(ParagraphStyle(name='Fineprint', fontName=self.font_regular, fontSize=8, leading=10))
@@ -142,12 +138,6 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd',
italic='OpenSansIt', boldItalic='OpenSansBI')
def _upper(self, val):
# We uppercase labels, but not in every language
if get_language() == 'el':
return val
return val.upper()
def _on_other_page(self, canvas: Canvas, doc):
"""
Called when a new page is rendered that is *not* the first page.
@@ -258,67 +248,52 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
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):
p = Paragraph(self.invoice.address_invoice_to.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height)
p_size = p.wrap(self.invoice_to_width, self.invoice_to_height)
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top)
invoice_from_width = 70 * mm
invoice_from_height = 50 * mm
invoice_from_left = 25 * mm
invoice_from_top = 17 * mm
p = Paragraph(self.invoice.invoice_to.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p.wrapOn(canvas, 85 * mm, 50 * mm)
p_size = p.wrap(85 * mm, 50 * mm)
p.drawOn(canvas, 25 * mm, (297 - 52) * mm - p_size[1])
def _draw_invoice_from(self, canvas):
p = Paragraph(self.invoice.full_invoice_from.strip().replace('\n', '<br />\n'), style=self.stylesheet[
'InvoiceFrom'])
p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height)
p_size = p.wrap(self.invoice_from_width, self.invoice_from_height)
p.drawOn(canvas, self.invoice_from_left, self.pagesize[1] - p_size[1] - self.invoice_from_top)
p = Paragraph(self.invoice.full_invoice_from.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p.wrapOn(canvas, 70 * mm, 50 * mm)
p_size = p.wrap(70 * mm, 50 * mm)
p.drawOn(canvas, 25 * mm, (297 - 17) * mm - p_size[1])
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.setFont(self.font_bold, 8)
textobject.textLine(self._upper(pgettext('invoice', 'Invoice from')))
textobject.textLine(pgettext('invoice', 'Invoice from').upper())
canvas.drawText(textobject)
def _draw_invoice_to_label(self, canvas):
self._draw_invoice_from(canvas)
textobject = canvas.beginText(25 * mm, (297 - 50) * mm)
textobject.setFont(self.font_bold, 8)
textobject.textLine(self._upper(pgettext('invoice', 'Invoice to')))
textobject.textLine(pgettext('invoice', 'Invoice to').upper())
canvas.drawText(textobject)
logo_width = 25 * mm
logo_height = 25 * mm
logo_left = 95 * mm
logo_top = 13 * mm
logo_anchor = 'n'
self._draw_invoice_to(canvas)
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.setFont(self.font_bold, 8)
textobject.textLine(self._upper(pgettext('invoice', 'Order code')))
textobject.textLine(pgettext('invoice', 'Order code').upper())
textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10)
textobject.textLine(self.invoice.order.full_code)
@@ -327,18 +302,18 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
textobject = canvas.beginText(125 * mm, (297 - 50) * mm)
textobject.setFont(self.font_bold, 8)
if self.invoice.is_cancellation:
textobject.textLine(self._upper(pgettext('invoice', 'Cancellation number')))
textobject.textLine(pgettext('invoice', 'Cancellation number').upper())
textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10)
textobject.textLine(self.invoice.number)
textobject.moveCursor(0, 5)
textobject.setFont(self.font_bold, 8)
textobject.textLine(self._upper(pgettext('invoice', 'Original invoice')))
textobject.textLine(pgettext('invoice', 'Original invoice').upper())
textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10)
textobject.textLine(self.invoice.refers.number)
else:
textobject.textLine(self._upper(pgettext('invoice', 'Invoice number')))
textobject.textLine(pgettext('invoice', 'Invoice number').upper())
textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10)
textobject.textLine(self.invoice.number)
@@ -346,20 +321,20 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.is_cancellation:
textobject.setFont(self.font_bold, 8)
textobject.textLine(self._upper(pgettext('invoice', 'Cancellation date')))
textobject.textLine(pgettext('invoice', 'Cancellation date').upper())
textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10)
textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT"))
textobject.moveCursor(0, 5)
textobject.setFont(self.font_bold, 8)
textobject.textLine(self._upper(pgettext('invoice', 'Original invoice date')))
textobject.textLine(pgettext('invoice', 'Original invoice date').upper())
textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10)
textobject.textLine(date_format(self.invoice.refers.date, "DATE_FORMAT"))
textobject.moveCursor(0, 5)
else:
textobject.setFont(self.font_bold, 8)
textobject.textLine(self._upper(pgettext('invoice', 'Invoice date')))
textobject.textLine(pgettext('invoice', 'Invoice date').upper())
textobject.moveCursor(0, 5)
textobject.setFont(self.font_regular, 10)
textobject.textLine(date_format(self.invoice.date, "DATE_FORMAT"))
@@ -367,37 +342,37 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
canvas.drawText(textobject)
event_left = 125 * mm
event_top = 17 * mm
event_width = 65 * mm
event_height = 50 * mm
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(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):
txt = str(txt)
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:
txt = ' '.join(txt.replace('', '').split()[:-1]) + ''
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
if not self.invoice.event.has_subevents:
if self.invoice.event.settings.show_date_to and self.invoice.event.date_to:
p_str = (
shorten(self.invoice.event.name) + '\n' +
pgettext('invoice', '{from_date}\nuntil {to_date}').format(
shorten(self.invoice.event.name) + '\n' + pgettext('invoice', '{from_date}\nuntil {to_date}').format(
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:
p_str = (
@@ -407,38 +382,15 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
p_str = shorten(self.invoice.event.name)
p = Paragraph(p_str.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.event_width, self.event_height)
p_size = p.wrap(self.event_width, self.event_height)
p.drawOn(canvas, self.event_left, self.pagesize[1] - self.event_top - p_size[1])
self._draw_event_label(canvas)
p.wrapOn(canvas, 65 * mm, 50 * mm)
p_size = p.wrap(65 * mm, 50 * mm)
p.drawOn(canvas, 125 * mm, (297 - 17) * mm - p_size[1])
def _draw_footer(self, canvas):
canvas.setFont(self.font_regular, 8)
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())
textobject = canvas.beginText(125 * mm, (297 - 15) * mm)
textobject.setFont(self.font_bold, 8)
textobject.textLine(pgettext('invoice', 'Event').upper())
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()
def _get_first_page_frames(self, doc):
@@ -476,13 +428,6 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
self.stylesheet['Normal']
))
if self.invoice.invoice_to_vat_id:
story.append(Paragraph(
pgettext('invoice', 'Customer VAT ID') + ':<br />' +
bleach.clean(self.invoice.invoice_to_vat_id, tags=[]).replace("\n", "<br />\n"),
self.stylesheet['Normal']
))
if self.invoice.invoice_to_beneficiary:
story.append(Paragraph(
pgettext('invoice', 'Beneficiary') + ':<br />' +
@@ -603,7 +548,6 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
colwidths = [a * doc.width for a in (.25, .15, .15, .15, .3)]
table = Table(tdata, colWidths=colwidths, repeatRows=2, hAlign=TA_LEFT)
table.setStyle(TableStyle(tstyledata))
story.append(Spacer(5 * mm, 5 * mm))
story.append(KeepTogether([
Paragraph(pgettext('invoice', 'Included taxes'), self.stylesheet['FineprintHeading']),
table
@@ -657,114 +601,6 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
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")
def recv_classic(sender, **kwargs):
return [ClassicInvoiceRenderer, Modern1Renderer]
return ClassicInvoiceRenderer

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, blacklist 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, blacklist in IGNORED_ATTRS:
if isinstance(self, ftype) and not any(isinstance(self, ft) for ft in blacklist):
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 fitlering 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):
blacklist = (
"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.blacklist):
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

@@ -97,7 +97,7 @@ def get_language_from_event(request: HttpRequest) -> str:
def get_language_from_browser(request: HttpRequest) -> str:
accept = request.headers.get('Accept-Language', '')
accept = request.META.get('HTTP_ACCEPT_LANGUAGE', '')
for accept_lang, unused in parse_accept_lang_header(accept):
if accept_lang == '*':
break

View File

@@ -1,27 +0,0 @@
# Generated by Django 2.1.5 on 2019-03-04 17:26
from django.db import migrations
from django.db.models import Count
def make_checkins_unique(apps, se):
Checkin = apps.get_model('pretixbase', 'Checkin')
for d in Checkin.objects.order_by().values('list_id', 'position_id').annotate(c=Count('id')).filter(c__gt=1):
for c in Checkin.objects.filter(list_id=d['list_id'], position_id=d['position_id'])[:d['c'] - 1]:
c.delete()
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0111_auto_20190219_0949'),
]
operations = [
migrations.RunPython(
make_checkins_unique, migrations.RunPython.noop,
),
migrations.AlterUniqueTogether(
name='checkin',
unique_together={('list', 'position')},
),
]

View File

@@ -1,27 +0,0 @@
# Generated by Django 2.1.5 on 2019-03-12 09:42
import django.db.models.deletion
import jsonfallback.fields
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0112_auto_20190304_1726'),
]
operations = [
migrations.AddField(
model_name='question',
name='dependency_question',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dependent_questions', to='pretixbase.Question'),
),
migrations.AddField(
model_name='question',
name='dependency_value',
field=models.TextField(blank=True, null=True),
),
]

View File

@@ -1,60 +0,0 @@
# Generated by Django 2.1.7 on 2019-03-16 10:14
import django.db.models.deletion
import jsonfallback.fields
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0113_auto_20190312_0942'),
]
operations = [
migrations.CreateModel(
name='ItemBundle',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('count', models.PositiveIntegerField(default=1, verbose_name='Number')),
('designated_price', models.DecimalField(blank=True, decimal_places=2, help_text="If set, it will be shown that this bundled item is responsible for the given value of the total price. This might be important in cases of mixed taxation, but can be kept blank otherwise. This value will NOT be added to the base item's price.", max_digits=10, null=True, verbose_name='Designated price part')),
],
),
migrations.AddField(
model_name='cartposition',
name='is_bundled',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='cartposition',
name='addon_to',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='addons', to='pretixbase.CartPosition'),
),
migrations.AlterField(
model_name='orderposition',
name='addon_to',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='addons', to='pretixbase.OrderPosition'),
),
migrations.AddField(
model_name='itembundle',
name='base_item',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bundles', to='pretixbase.Item'),
),
migrations.AddField(
model_name='itembundle',
name='bundled_item',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bundled_with', to='pretixbase.Item', verbose_name='Bundled item'),
),
migrations.AddField(
model_name='itembundle',
name='bundled_variation',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='bundled_with', to='pretixbase.ItemVariation', verbose_name='Bundled variation'),
),
migrations.AddField(
model_name='item',
name='require_bundling',
field=models.BooleanField(default=False, help_text='If this option is set, the product will only be sold as part of bundle products.', verbose_name='Only sell this product as part of a bundle'),
),
]

View File

@@ -1,24 +0,0 @@
# Generated by Django 2.1.7 on 2019-03-23 22:38
from decimal import Decimal
import django.db.models.deletion
import jsonfallback.fields
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0114_auto_20190316_1014'),
]
operations = [
migrations.AlterField(
model_name='itembundle',
name='designated_price',
field=models.DecimalField(blank=True, decimal_places=2, default=Decimal('0.00'), help_text="If set, it will be shown that this bundled item is responsible for the given value of the total gross price. This might be important in cases of mixed taxation, but can be kept blank otherwise. This value will NOT be added to the base item's price.", max_digits=10, verbose_name='Designated price part'),
),
]

View File

@@ -1,22 +0,0 @@
# Generated by Django 2.1.5 on 2019-04-02 07:22
import django.db.models.deletion
import jsonfallback.fields
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0115_auto_20190323_2238'),
]
operations = [
migrations.AddField(
model_name='device',
name='revoked',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 2.2 on 2019-04-18 11:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0116_auto_20190402_0722'),
]
operations = [
migrations.AddField(
model_name='itemvariation',
name='original_price',
field=models.DecimalField(blank=True, decimal_places=2, help_text='If set, this will be displayed next to '
'the current price to show that the '
'current price is a discounted one. '
'This is just a cosmetic setting and '
'will not actually impact pricing.',
max_digits=7, null=True, verbose_name='Original price'),
),
]

View File

@@ -1,22 +0,0 @@
# Generated by Django 2.2 on 2019-04-23 08:39
import django.db.models.deletion
import jsonfallback.fields
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0117_auto_20190418_1149'),
]
operations = [
migrations.AddField(
model_name='subevent',
name='is_public',
field=models.BooleanField(default=True, help_text='If selected, this event will show up publicly on the list of dates for your event.', verbose_name='Show in lists'),
),
]

View File

@@ -1,22 +0,0 @@
# Generated by Django 2.2 on 2019-05-09 06:54
import django.db.models.deletion
import jsonfallback.fields
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0118_auto_20190423_0839'),
]
operations = [
migrations.AddField(
model_name='question',
name='hidden',
field=models.BooleanField(default=False, help_text='This question will only show up in the backend.', verbose_name='Hidden question'),
),
]

View File

@@ -1,77 +0,0 @@
# Generated by Django 2.2 on 2019-05-09 07:36
import django.db.models.deletion
import jsonfallback.fields
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0119_auto_20190509_0654'),
]
operations = [
migrations.AlterField(
model_name='cartposition',
name='attendee_name_parts',
field=jsonfallback.fields.FallbackJSONField(default=dict),
),
migrations.AlterField(
model_name='cartposition',
name='subevent',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.SubEvent'),
),
migrations.AlterField(
model_name='cartposition',
name='voucher',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Voucher'),
),
migrations.AlterField(
model_name='event',
name='is_public',
field=models.BooleanField(default=True),
),
migrations.AlterField(
model_name='invoiceaddress',
name='name_parts',
field=jsonfallback.fields.FallbackJSONField(default=dict),
),
migrations.AlterField(
model_name='item',
name='sales_channels',
field=pretix.base.models.fields.MultiStringField(default=['web']),
),
migrations.AlterField(
model_name='order',
name='sales_channel',
field=models.CharField(default='web', max_length=190),
),
migrations.AlterField(
model_name='orderposition',
name='attendee_name_parts',
field=jsonfallback.fields.FallbackJSONField(default=dict),
),
migrations.AlterField(
model_name='orderposition',
name='subevent',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.SubEvent'),
),
migrations.AlterField(
model_name='orderposition',
name='voucher',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Voucher'),
),
migrations.AlterField(
model_name='staffsessionauditlog',
name='method',
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(db_index=True, max_length=190, null=True, unique=True),
),
]

View File

@@ -9,9 +9,8 @@ from .event import (
)
from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Question,
QuestionOption, Quota, SubEventItem, SubEventItemVariation,
itempicture_upload_to,
Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption,
Quota, SubEventItem, SubEventItemVariation, itempicture_upload_to,
)
from .log import LogEntry
from .notifications import NotificationSetting

View File

@@ -111,7 +111,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
class Meta:
verbose_name = _("User")
verbose_name_plural = _("Users")
ordering = ('email',)
def save(self, *args, **kwargs):
self.email = self.email.lower()

View File

@@ -167,9 +167,6 @@ class Checkin(models.Model):
'pretixbase.CheckinList', related_name='checkins', on_delete=models.PROTECT,
)
class Meta:
unique_together = (('list', 'position'),)
def __repr__(self):
return "<Checkin: pos {} on list '{}' at {}>".format(
self.position, self.list, self.datetime

View File

@@ -41,7 +41,6 @@ class Device(LoggedModel):
api_token = models.CharField(max_length=190, unique=True, null=True)
all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)"))
limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True)
revoked = models.BooleanField(default=False)
name = models.CharField(
max_length=190,
verbose_name=_('Name')

View File

@@ -1,7 +1,7 @@
import string
import uuid
from collections import OrderedDict
from datetime import datetime, time, timedelta
from datetime import datetime, time
from operator import attrgetter
import pytz
@@ -164,7 +164,7 @@ class EventMixin:
def annotated(cls, qs, channel='web'):
from pretix.base.models import Item, ItemVariation, Quota
sq_active_item = Item.objects.using(settings.DATABASE_REPLICA).filter_available(channel=channel).filter(
sq_active_item = Item.objects.filter_available(channel=channel).filter(
Q(variations__isnull=True)
& Q(quotas__pk=OuterRef('pk'))
).order_by().values_list('quotas__pk').annotate(
@@ -186,7 +186,7 @@ class EventMixin:
Prefetch(
'quotas',
to_attr='active_quotas',
queryset=Quota.objects.using(settings.DATABASE_REPLICA).annotate(
queryset=Quota.objects.annotate(
active_items=Subquery(sq_active_item, output_field=models.TextField()),
active_variations=Subquery(sq_active_variation, output_field=models.TextField()),
).exclude(
@@ -515,10 +515,6 @@ class Event(EventMixin, LoggedModel):
o.question = q
o.save()
for q in self.questions.filter(dependency_question__isnull=False):
q.dependency_question = question_map[q.dependency_question_id]
q.save(update_fields=['dependency_question'])
for cl in other.checkin_lists.filter(subevent__isnull=True).prefetch_related('limit_products'):
items = list(cl.limit_products.all())
cl.pk = None
@@ -639,6 +635,22 @@ class Event(EventMixin, LoggedModel):
irs = self.get_invoice_renderers()
return irs[self.settings.invoice_renderer]
@property
def active_subevents(self):
"""
Returns a queryset of active subevents.
"""
return self.subevents.filter(active=True).order_by('-date_from', 'name')
@property
def active_future_subevents(self):
return self.subevents.filter(
Q(active=True) & (
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
| Q(date_to__gte=now())
)
).order_by('date_from', 'name')
def subevents_annotated(self, channel):
return SubEvent.annotated(self.subevents, channel)
@@ -651,9 +663,9 @@ class Event(EventMixin, LoggedModel):
'name_descending': ('-name', 'date_from'),
}[ordering]
subevs = queryset.filter(
Q(active=True) & Q(is_public=True) & (
Q(Q(date_to__isnull=True) & Q(date_from__gte=now() - timedelta(hours=24)))
| Q(date_to__gte=now() - timedelta(hours=24))
Q(active=True) & (
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
| Q(date_to__gte=now())
)
) # order_by doesn't make sense with I18nField
for f in reversed(orderfields):
@@ -743,7 +755,6 @@ class Event(EventMixin, LoggedModel):
return not self.orders.exists() and not self.invoices.exists()
def delete_sub_objects(self):
self.cartposition_set.filter(addon_to__isnull=False).delete()
self.cartposition_set.all().delete()
self.items.all().delete()
self.subevents.all().delete()
@@ -817,8 +828,6 @@ class SubEvent(EventMixin, LoggedModel):
:type event: Event
:param active: Whether to show the subevent
:type active: bool
:param is_public: Whether to show the subevent in lists
:type is_public: bool
:param name: This event's full title
:type name: str
:param date_from: The datetime this event starts
@@ -837,10 +846,6 @@ class SubEvent(EventMixin, LoggedModel):
active = models.BooleanField(default=False, verbose_name=_("Active"),
help_text=_("Only with this checkbox enabled, this date is visible in the "
"frontend to users."))
is_public = models.BooleanField(default=True,
verbose_name=_("Show in lists"),
help_text=_("If selected, this event will show up publicly on the list of dates "
"for your event."))
name = I18nCharField(
max_length=200,
verbose_name=_("Name"),
@@ -927,18 +932,6 @@ class SubEvent(EventMixin, LoggedModel):
if self.event:
self.event.cache.clear()
@staticmethod
def clean_items(event, items):
for item in items:
if event != item.event:
raise ValidationError(_('One or more items do not belong to this event.'))
@staticmethod
def clean_variations(event, variations):
for variation in variations:
if event != variation.item.event:
raise ValidationError(_('One or more variations do not belong to this event.'))
def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)

View File

@@ -37,7 +37,7 @@ class MultiStringField(TextField):
def get_prep_lookup(self, lookup_type, value): # NOQA
raise TypeError('Lookups on multi strings are currently not supported.')
def from_db_value(self, value, expression, connection):
def from_db_value(self, value, expression, connection, context):
if value:
return [v for v in value.split(DELIMITER) if v]
else:

View File

@@ -117,35 +117,12 @@ class Invoice(models.Model):
self.invoice_from_name,
self.invoice_from,
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
self.invoice_from_country.name if self.invoice_from_country else "",
str(self.invoice_from_country),
pgettext("invoice", "VAT-ID: %s") % self.invoice_from_vat_id if self.invoice_from_vat_id else "",
pgettext("invoice", "Tax ID: %s") % self.invoice_from_tax_id if self.invoice_from_tax_id else "",
]
return '\n'.join([p.strip() for p in parts if p and p.strip()])
@property
def address_invoice_from(self):
parts = [
self.invoice_from_name,
self.invoice_from,
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
self.invoice_from_country.name if self.invoice_from_country else "",
]
return '\n'.join([p.strip() for p in parts if p and p.strip()])
@property
def address_invoice_to(self):
if self.invoice_to and not self.invoice_to_company and not self.invoice_to_name:
return self.invoice_to
parts = [
self.invoice_to_company,
self.invoice_to_name,
self.invoice_to_street,
(self.invoice_to_zipcode or "") + " " + (self.invoice_to_city or ""),
self.invoice_to_country.name if self.invoice_to_country else "",
]
return '\n'.join([p.strip() for p in parts if p and p.strip()])
def _get_numeric_invoice_number(self):
numeric_invoices = Invoice.objects.filter(
event__organizer=self.event.organizer,

View File

@@ -1,6 +1,5 @@
import sys
import uuid
from collections import Counter
from datetime import date, datetime, time
from decimal import Decimal, DecimalException
from typing import Tuple
@@ -16,7 +15,6 @@ from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.timezone import is_naive, make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_countries.fields import Country
from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.models import fields
@@ -163,7 +161,7 @@ class ItemQuerySet(models.QuerySet):
Q(active=True)
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
& Q(sales_channels__contains=channel) & Q(require_bundling=False)
& Q(sales_channels__contains=channel)
)
if not allow_addons:
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
@@ -330,13 +328,6 @@ class Item(LoggedModel):
help_text=_('This product will be hidden from the event page until the user enters a voucher '
'code that is specifically tied to this product (and not via a quota).')
)
require_bundling = models.BooleanField(
verbose_name=_('Only sell this product as part of a bundle'),
default=False,
help_text=_('If this option is set, the product will only be sold as part of bundle products. Do '
'<strong>not</strong> check this option if you want to use this product as an add-on product, '
'but only for fixed bundles!')
)
allow_cancel = models.BooleanField(
verbose_name=_('Allow product to be canceled'),
default=True,
@@ -395,28 +386,12 @@ class Item(LoggedModel):
if self.event:
self.event.cache.clear()
def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False):
def tax(self, price=None, base_price_is='auto'):
price = price if price is not None else self.default_price
if not self.tax_rule:
t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
rate=Decimal('0.00'), name='')
else:
t = self.tax_rule.tax(price, base_price_is=base_price_is, currency=currency)
if include_bundled:
for b in self.bundles.all():
if b.designated_price and b.bundled_item.tax_rule_id != self.tax_rule_id:
if b.bundled_variation:
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
else:
bprice = b.bundled_item.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
compare_price = self.tax_rule.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
t.net += bprice.net - compare_price.net
t.tax += bprice.tax - compare_price.tax
t.name = "MIXED!"
return t
return TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
rate=Decimal('0.00'), name='')
return self.tax_rule.tax(price, base_price_is=base_price_is)
def is_available_by_time(self, now_dt: datetime=None) -> bool:
now_dt = now_dt or now()
@@ -436,18 +411,7 @@ class Item(LoggedModel):
return False
return True
def _get_quotas(self, ignored_quotas=None, subevent=None):
check_quotas = set(getattr(
self, '_subevent_quotas', # Utilize cache in product list
self.quotas.filter(subevent=subevent).select_related('subevent')
if subevent else self.quotas.all()
))
if ignored_quotas:
check_quotas -= set(ignored_quotas)
return check_quotas
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None,
include_bundled=False, trust_parameters=False):
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None):
"""
This method is used to determine whether this Item is currently available
for sale.
@@ -456,60 +420,33 @@ class Item(LoggedModel):
quotas will be ignored in the calculation. If this leads
to no quotas being checked at all, this method will return
unlimited availability.
:param include_bundled: Also take availability of bundled items into consideration.
:param trust_parameters: Disable checking of the subevent parameter and disable checking if
any variations exist (performance optimization).
:returns: any of the return codes of :py:meth:`Quota.availability()`.
:raises ValueError: if you call this on an item which has variations associated with it.
Please use the method on the ItemVariation object you are interested in.
"""
if not trust_parameters and not subevent and self.event.has_subevents:
check_quotas = set(getattr(
self, '_subevent_quotas', # Utilize cache in product list
self.quotas.select_related('subevent').filter(subevent=subevent)
if subevent else self.quotas.all()
))
if not subevent and self.event.has_subevents:
raise TypeError('You need to supply a subevent.')
check_quotas = self._get_quotas(ignored_quotas=ignored_quotas, subevent=subevent)
quotacounter = Counter()
res = Quota.AVAILABILITY_OK, None
for q in check_quotas:
quotacounter[q] += 1
if include_bundled:
for b in self.bundles.all():
bundled_check_quotas = (b.bundled_variation or b.bundled_item)._get_quotas(ignored_quotas=ignored_quotas, subevent=subevent)
if not bundled_check_quotas:
return Quota.AVAILABILITY_GONE, 0
for q in bundled_check_quotas:
quotacounter[q] += b.count
for q, n in quotacounter.items():
a = q.availability(count_waitinglist=count_waitinglist, _cache=_cache)
if a[1] is None:
continue
num_avail = a[1] // n
code_avail = Quota.AVAILABILITY_GONE if a[1] >= 1 and num_avail < 1 else a[0]
# this is not entirely accurate, as it shows "sold out" even if it is actually just "reserved",
# since we do not know that distinction here if at least one item is available. However, this
# is only relevant in connection with bundles.
if code_avail < res[0] or res[1] is None or num_avail < res[1]:
res = (code_avail, num_avail)
if len(quotacounter) == 0:
return Quota.AVAILABILITY_OK, sys.maxsize # backwards compatibility
return res
if ignored_quotas:
check_quotas -= set(ignored_quotas)
if not check_quotas:
return Quota.AVAILABILITY_OK, sys.maxsize
if self.has_variations: # NOQA
raise ValueError('Do not call this directly on items which have variations '
'but call this on their ItemVariation objects')
return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas],
key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize))
def allow_delete(self):
from pretix.base.models.orders import OrderPosition
return not OrderPosition.all.filter(item=self).exists()
@property
def includes_mixed_tax_rate(self):
for b in self.bundles.all():
if b.designated_price and b.bundled_item.tax_rule_id != self.tax_rule_id:
return True
return False
@cached_property
def has_variations(self):
return self.variations.exists()
@@ -553,8 +490,6 @@ class ItemVariation(models.Model):
:type active: bool
:param default_price: This variation's default price
:type default_price: decimal.Decimal
:param original_price: The item's "original" price. Will not be used for any calculations, will just be shown.
:type original_price: decimal.Decimal
"""
item = models.ForeignKey(
Item,
@@ -583,13 +518,6 @@ class ItemVariation(models.Model):
null=True, blank=True,
verbose_name=_("Default price"),
)
original_price = models.DecimalField(
verbose_name=_('Original price'),
blank=True, null=True,
max_digits=7, decimal_places=2,
help_text=_('If set, this will be displayed next to the current price to show that the current price is a '
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
)
class Meta:
verbose_name = _("Product variation")
@@ -603,28 +531,11 @@ class ItemVariation(models.Model):
def price(self):
return self.default_price if self.default_price is not None else self.item.default_price
def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False):
def tax(self, price=None):
price = price if price is not None else self.price
if not self.item.tax_rule:
t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
rate=Decimal('0.00'), name='')
else:
t = self.item.tax_rule.tax(price, base_price_is=base_price_is, currency=currency)
if include_bundled:
for b in self.item.bundles.all():
if b.designated_price and b.bundled_item.tax_rule_id != self.item.tax_rule_id:
if b.bundled_variation:
bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
else:
bprice = b.bundled_item.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
compare_price = self.item.tax_rule.tax(b.designated_price * b.count, base_price_is='gross', currency=currency)
t.net += bprice.net - compare_price.net
t.tax += bprice.tax - compare_price.tax
t.name = "MIXED!"
return t
return TaxedPrice(gross=price, net=price, tax=Decimal('0.00'), rate=Decimal('0.00'), name='')
return self.item.tax_rule.tax(price)
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
@@ -636,18 +547,7 @@ class ItemVariation(models.Model):
if self.item:
self.item.event.cache.clear()
def _get_quotas(self, ignored_quotas=None, subevent=None):
check_quotas = set(getattr(
self, '_subevent_quotas', # Utilize cache in product list
self.quotas.filter(subevent=subevent).select_related('subevent')
if subevent else self.quotas.all()
))
if ignored_quotas:
check_quotas -= set(ignored_quotas)
return check_quotas
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None,
include_bundled=False, trust_parameters=False) -> Tuple[int, int]:
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None) -> Tuple[int, int]:
"""
This method is used to determine whether this ItemVariation is currently
available for sale in terms of quotas.
@@ -659,38 +559,19 @@ class ItemVariation(models.Model):
:param count_waitinglist: If ``False``, waiting list entries will be ignored for quota calculation.
:returns: any of the return codes of :py:meth:`Quota.availability()`.
"""
if not trust_parameters and not subevent and self.item.event.has_subevents: # NOQA
check_quotas = set(getattr(
self, '_subevent_quotas', # Utilize cache in product list
self.quotas.filter(subevent=subevent).select_related('subevent')
if subevent else self.quotas.all()
))
if ignored_quotas:
check_quotas -= set(ignored_quotas)
if not subevent and self.item.event.has_subevents: # NOQA
raise TypeError('You need to supply a subevent.')
check_quotas = self._get_quotas(ignored_quotas=ignored_quotas, subevent=subevent)
quotacounter = Counter()
res = Quota.AVAILABILITY_OK, None
for q in check_quotas:
quotacounter[q] += 1
if include_bundled:
for b in self.item.bundles.all():
bundled_check_quotas = (b.bundled_variation or b.bundled_item)._get_quotas(ignored_quotas=ignored_quotas, subevent=subevent)
if not bundled_check_quotas:
return Quota.AVAILABILITY_GONE, 0
for q in bundled_check_quotas:
quotacounter[q] += b.count
for q, n in quotacounter.items():
a = q.availability(count_waitinglist=count_waitinglist, _cache=_cache)
if a[1] is None:
continue
num_avail = a[1] // n
code_avail = Quota.AVAILABILITY_GONE if a[1] >= 1 and num_avail < 1 else a[0]
# this is not entirely accurate, as it shows "sold out" even if it is actually just "reserved",
# since we do not know that distinction here if at least one item is available. However, this
# is only relevant in connection with bundles.
if code_avail < res[0] or res[1] is None or num_avail < res[1]:
res = (code_avail, num_avail)
if len(quotacounter) == 0:
return Quota.AVAILABILITY_OK, sys.maxsize # backwards compatibility
return res
if not check_quotas:
return Quota.AVAILABILITY_OK, sys.maxsize
return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas],
key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize))
def __lt__(self, other):
if self.position == other.position:
@@ -791,83 +672,6 @@ class ItemAddOn(models.Model):
raise ValidationError(_('The maximum count needs to be greater than the minimum count.'))
class ItemBundle(models.Model):
"""
An instance of this model indicates that buying a ticket of the type ``base_item``
automatically also buys ``count`` items of type ``bundled_item``.
:param base_item: The base item the bundle is attached to
:type base_item: Item
:param bundled_item: The bundled item
:type bundled_item: Item
:param bundled_variation: The variation, if the bundled item has variations
:type bundled_variation: ItemVariation
:param count: The number of items to bundle
:type count: int
:param designated_price: The designated part price (optional)
:type designated_price: bool
"""
base_item = models.ForeignKey(
Item,
related_name='bundles',
on_delete=models.CASCADE
)
bundled_item = models.ForeignKey(
Item,
related_name='bundled_with',
verbose_name=_('Bundled item'),
on_delete=models.CASCADE
)
bundled_variation = models.ForeignKey(
ItemVariation,
related_name='bundled_with',
verbose_name=_('Bundled variation'),
null=True, blank=True,
on_delete=models.CASCADE
)
count = models.PositiveIntegerField(
default=1,
verbose_name=_('Number')
)
designated_price = models.DecimalField(
default=Decimal('0.00'), blank=True,
decimal_places=2, max_digits=10,
verbose_name=_('Designated price part'),
help_text=_('If set, it will be shown that this bundled item is responsible for the given value of the total '
'gross price. This might be important in cases of mixed taxation, but can be kept blank otherwise. This '
'value will NOT be added to the base item\'s price.')
)
def clean(self):
self.clean_count(self.count)
def describe(self):
if self.count == 1:
if self.bundled_variation_id:
return "{} {}".format(self.bundled_item.name, self.bundled_variation.value)
else:
return self.bundled_item.name
else:
if self.bundled_variation_id:
return "{}× {} {}".format(self.count, self.bundled_item.name, self.bundled_variation.value)
else:
return "{}x {}".format(self.count, self.bundled_item.name)
@staticmethod
def clean_itemvar(event, bundled_item, bundled_variation):
if event != bundled_item.event:
raise ValidationError(_('The bundled item must belong to the same event as the item.'))
if bundled_item.has_variations and not bundled_variation:
raise ValidationError(_('A variation needs to be set for this item.'))
if bundled_variation and bundled_variation.item != bundled_item:
raise ValidationError(_('The chosen variation does not belong to this item.'))
@staticmethod
def clean_count(count):
if count < 0:
raise ValidationError(_('The count needs to be equal to or greater than zero.'))
class Question(LoggedModel):
"""
A question is an input field that can be used to extend a ticket by custom information,
@@ -896,14 +700,8 @@ class Question(LoggedModel):
:param items: A set of ``Items`` objects that this question should be applied to
:param ask_during_checkin: Whether to ask this question during check-in instead of during check-out.
:type ask_during_checkin: bool
:param hidden: Whether to only show the question in the backend
:type hidden: bool
:param identifier: An arbitrary, internal identifier
:type identifier: str
:param dependency_question: This question will only show up if the referenced question is set to `dependency_value`.
:type dependency_question: Question
:param dependency_value: The value that `dependency_question` needs to be set to for this question to be applicable.
:type dependency_value: str
"""
TYPE_NUMBER = "N"
TYPE_STRING = "S"
@@ -915,7 +713,6 @@ class Question(LoggedModel):
TYPE_DATE = "D"
TYPE_TIME = "H"
TYPE_DATETIME = "W"
TYPE_COUNTRYCODE = "CC"
TYPE_CHOICES = (
(TYPE_NUMBER, _("Number")),
(TYPE_STRING, _("Text (one line)")),
@@ -927,7 +724,6 @@ class Question(LoggedModel):
(TYPE_DATE, _("Date")),
(TYPE_TIME, _("Time")),
(TYPE_DATETIME, _("Date and time")),
(TYPE_COUNTRYCODE, _("Country code (ISO 3166-1 alpha-2)")),
)
event = models.ForeignKey(
@@ -975,15 +771,6 @@ class Question(LoggedModel):
'pretixdesk 0.2 or newer.'),
default=False
)
hidden = models.BooleanField(
verbose_name=_('Hidden question'),
help_text=_('This question will only show up in the backend.'),
default=False
)
dependency_question = models.ForeignKey(
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
)
dependency_value = models.TextField(null=True, blank=True)
class Meta:
verbose_name = _("Question")
@@ -1083,12 +870,6 @@ class Question(LoggedModel):
return dt
except:
raise ValidationError(_('Invalid datetime input.'))
elif self.type == Question.TYPE_COUNTRYCODE and answer:
c = Country(answer.upper())
if c.name:
return answer
else:
raise ValidationError(_('Unknown country code.'))
return answer
@@ -1308,8 +1089,7 @@ class Quota(LoggedModel):
'cached_availability_state', 'cached_availability_number', 'cached_availability_time',
'cached_availability_paid_orders'
],
clear_cache=False,
using='default'
clear_cache=False
)
if _cache is not None:
@@ -1419,7 +1199,7 @@ class Quota(LoggedModel):
@staticmethod
def clean_variations(items, variations):
for variation in (variations or []):
for variation in variations:
if variation.item not in items:
raise ValidationError(_('All variations must belong to an item contained in the items list.'))
break

View File

@@ -24,7 +24,7 @@ from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_countries.fields import Country, CountryField
from django_countries.fields import CountryField
from i18nfield.strings import LazyI18nString
from jsonfallback.fields import FallbackJSONField
@@ -32,7 +32,6 @@ from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
from pretix.base.models import User
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.locking import NoLockManager
from pretix.base.settings import PERSON_NAME_SCHEMES
from .base import LockModel, LoggedModel
@@ -230,8 +229,6 @@ class Order(LockModel, LoggedModel):
@cached_property
def meta_info_data(self):
if not self.meta_info:
return {}
try:
return json.loads(self.meta_info)
except TypeError:
@@ -607,7 +604,7 @@ class Order(LockModel, LoggedModel):
), tz)
return term_last
def _can_be_paid(self, count_waitinglist=True, ignore_date=False) -> Union[bool, str]:
def _can_be_paid(self, count_waitinglist=True) -> Union[bool, str]:
error_messages = {
'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the "
"payment settings is over."),
@@ -618,13 +615,13 @@ class Order(LockModel, LoggedModel):
if self.require_approval:
return error_messages['require_approval']
term_last = self.payment_term_last
if term_last and not ignore_date:
if term_last:
if now() > term_last:
return error_messages['late_lastdate']
if self.status == self.STATUS_PENDING:
return True
if not self.event.settings.get('payment_term_accept_late') and not ignore_date:
if not self.event.settings.get('payment_term_accept_late'):
return error_messages['late']
return self._is_still_available(count_waitinglist=count_waitinglist)
@@ -716,33 +713,6 @@ class Order(LockModel, LoggedModel):
}
)
def resend_link(self, user=None, auth=None):
from pretix.multidomain.urlreverse import build_absolute_uri
with language(self.locale):
try:
invoice_name = self.invoice_address.name
invoice_company = self.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
email_template = self.event.settings.mail_text_resend_link
email_context = {
'event': self.event.name,
'url': build_absolute_uri(self.event, 'presale:event.order', kwargs={
'order': self.code,
'secret': self.secret
}),
'invoice_name': invoice_name,
'invoice_company': invoice_company,
}
email_subject = _('Your order: %(code)s') % {'code': self.code}
self.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.resend', user=user, auth=auth,
attach_tickets=True
)
@property
def positions_with_tickets(self):
for op in self.positions.all():
@@ -860,8 +830,6 @@ class QuestionAnswer(models.Model):
return date_format(d, "TIME_FORMAT")
except ValueError:
return self.answer
elif self.question.type == Question.TYPE_COUNTRYCODE and self.answer:
return Country(self.answer).name or self.answer
else:
return self.answer
@@ -976,39 +944,16 @@ class AbstractPosition(models.Model):
# selected via prefetch_related
if not all:
if hasattr(self.item, 'questions_to_ask'):
questions = list(copy.copy(q) for q in self.item.questions_to_ask)
self.questions = list(copy.copy(q) for q in self.item.questions_to_ask)
else:
questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False,
hidden=False))
self.questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False))
else:
questions = list(copy.copy(q) for q in self.item.questions.all())
question_cache = {
q.pk: q for q in questions
}
def question_is_visible(parentid, qval):
parentq = question_cache[parentid]
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_value):
return False
if parentid not in self.answ:
return False
if qval == 'True':
return self.answ[parentid].answer == 'True'
elif qval == 'False':
return self.answ[parentid].answer == 'False'
else:
return qval in [o.identifier for o in self.answ[parentid].options.all()]
self.questions = []
for q in questions:
self.questions = list(copy.copy(q) for q in self.item.questions.all())
for q in self.questions:
if q.id in self.answ:
q.answer = self.answ[q.id]
q.answer.question = q # cache object
else:
q.answer = ""
if not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_value):
self.questions.append(q)
@property
def net_price(self):
@@ -1141,9 +1086,9 @@ class OrderPayment(models.Model):
"""
return self.order.event.get_payment_providers().get(self.provider)
def _mark_paid(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False):
def _mark_paid(self, force, count_waitinglist, user, auth):
from pretix.base.signals import order_paid
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date)
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist)
if not force and can_be_paid is not True:
self.order.log_action('pretix.event.order.quotaexceeded', {
'message': can_be_paid
@@ -1158,12 +1103,9 @@ class OrderPayment(models.Model):
'date': self.payment_date,
'force': force
}, user=user, auth=auth)
if overpaid:
self.order.log_action('pretix.event.order.overpaid', {}, user=user, auth=auth)
order_paid.send(self.order.event, order=self.order)
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='', ignore_date=False, lock=True):
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text=''):
"""
Marks the payment as complete. If possible, this also marks the order as paid if no further
payment is required
@@ -1173,7 +1115,6 @@ class OrderPayment(models.Model):
:type count_waitinglist: boolean
:param force: Whether this payment should be marked as paid even if no remaining
quota is available (default: ``False``).
:param ignore_date: Whether this order should be marked as paid even when the last date of payments is over.
:type force: boolean
:param send_mail: Whether an email should be sent to the user about this event (default: ``True``).
:type send_mail: boolean
@@ -1223,16 +1164,14 @@ class OrderPayment(models.Model):
if payment_sum - refund_sum < self.order.total:
return
if (self.order.status == Order.STATUS_PENDING and self.order.expires > now() + timedelta(hours=12)) or not lock:
if self.order.status == Order.STATUS_PENDING and self.order.expires > now() + timedelta(hours=12):
# Performance optimization. In this case, there's really no reason to lock everything and an atomic
# database transaction is more than enough.
lockfn = NoLockManager
with transaction.atomic():
self._mark_paid(force, count_waitinglist, user, auth)
else:
lockfn = self.order.event.lock
with lockfn():
self._mark_paid(force, count_waitinglist, user, auth, overpaid=payment_sum - refund_sum > self.order.total,
ignore_date=ignore_date)
with self.order.event.lock():
self._mark_paid(force, count_waitinglist, user, auth)
invoice = None
if invoice_qualified(self.order):
@@ -1725,9 +1664,8 @@ class OrderPosition(AbstractPosition):
# Delete afterwards. Deleting in between might cause deletion of things related to add-ons
# due to the deletion cascade.
for cartpos in cp:
if cartpos.pk:
cartpos.addons.all().delete()
cartpos.delete()
cartpos.addons.all().delete()
cartpos.delete()
return ops
def __str__(self):
@@ -1824,7 +1762,6 @@ class CartPosition(AbstractPosition):
includes_tax = models.BooleanField(
default=True
)
is_bundled = models.BooleanField(default=False)
class Meta:
verbose_name = _("Cart position")

View File

@@ -33,32 +33,6 @@ class TaxedPrice:
money_filter(self.gross, currency)
)
def __sub__(self, other):
newgross = self.gross - other.gross
newnet = round_decimal(newgross - (newgross * (1 - 100 / (100 + self.rate)))).quantize(
Decimal('10') ** self.gross.as_tuple().exponent
)
return TaxedPrice(
gross=newgross,
net=newnet,
tax=newgross - newnet,
rate=self.rate,
name=self.name,
)
def __mul__(self, other):
newgross = self.gross * other
newnet = round_decimal(newgross - (newgross * (1 - 100 / (100 + self.rate)))).quantize(
Decimal('10') ** self.gross.as_tuple().exponent
)
return TaxedPrice(
gross=newgross,
net=newnet,
tax=newgross - newnet,
rate=self.rate,
name=self.name,
)
TAXED_ZERO = TaxedPrice(
gross=Decimal('0.00'),
@@ -118,9 +92,6 @@ class TaxRule(LoggedModel):
)
custom_rules = models.TextField(blank=True, null=True)
class Meta:
ordering = ('event', 'rate', 'id')
def allow_delete(self):
from pretix.base.models.orders import OrderFee, OrderPosition
@@ -158,12 +129,7 @@ class TaxRule(LoggedModel):
def has_custom_rules(self):
return self.custom_rules and self.custom_rules != '[]'
def tax(self, base_price, base_price_is='auto', currency=None):
from .event import Event
try:
currency = currency or self.event.currency
except Event.DoesNotExist:
pass
def tax(self, base_price, base_price_is='auto'):
if self.rate == Decimal('0.00'):
return TaxedPrice(
net=base_price, gross=base_price, tax=Decimal('0.00'),
@@ -179,11 +145,11 @@ class TaxRule(LoggedModel):
if base_price_is == 'gross':
gross = base_price
net = round_decimal(gross - (base_price * (1 - 100 / (100 + self.rate))),
currency)
self.event.currency if self.event else None)
elif base_price_is == 'net':
net = base_price
gross = round_decimal((net * (1 + self.rate / 100)),
currency)
self.event.currency if self.event else None)
else:
raise ValueError('Unknown base price type: {}'.format(base_price_is))

View File

@@ -344,8 +344,6 @@ class Voucher(LoggedModel):
a variation).
"""
if self.quota_id:
if variation:
return variation.quotas.filter(pk=self.quota_id).exists()
return item.quotas.filter(pk=self.quota_id).exists()
if self.item_id and not self.variation_id:
return self.item_id == item.pk

View File

@@ -236,12 +236,6 @@ def register_default_notification_types(sender, **kwargs):
_('Order changed'),
_('Order {order.code} has been changed.')
),
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.overpaid',
_('Order has been overpaid'),
_('Order {order.code} has been overpaid.')
),
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.refund.created.externally',

View File

@@ -311,7 +311,7 @@ class BasePaymentProvider:
@property
def payment_form_fields(self) -> dict:
"""
This is used by the default implementation of :py:meth:`payment_form`.
This is used by the default implementation of :py:meth:`checkout_form`.
It should return an object similar to :py:attr:`settings_form_fields`.
The default implementation returns an empty dictionary.
@@ -320,10 +320,10 @@ class BasePaymentProvider:
def payment_form(self, request: HttpRequest) -> Form:
"""
This is called by the default implementation of :py:meth:`payment_form_render`
This is called by the default implementation of :py:meth:`checkout_form_render`
to obtain the form that is displayed to the user during the checkout
process. The default implementation constructs the form using
:py:attr:`payment_form_fields` and sets appropriate prefixes for the form
:py:attr:`checkout_form_fields` and sets appropriate prefixes for the form
and all fields and fills the form with data form the user's session.
If you overwrite this, we strongly suggest that you inherit from
@@ -437,7 +437,7 @@ class BasePaymentProvider:
When the user selects this provider as their preferred payment method,
they will be shown the HTML you return from this method.
The default implementation will call :py:meth:`payment_form`
The default implementation will call :py:meth:`checkout_form`
and render the returned form. If your payment method doesn't require
the user to fill out form fields, you should just return a paragraph
of explanatory text.
@@ -720,22 +720,6 @@ class BoxOfficeProvider(BasePaymentProvider):
def order_change_allowed(self, order: Order) -> bool:
return False
def payment_control_render(self, request, payment) -> str:
if not payment.info:
return
payment_info = json.loads(payment.info)
template = get_template('pretixcontrol/boxoffice/payment.html')
ctx = {
'request': request,
'event': self.event,
'settings': self.settings,
'payment_info': payment_info,
'payment': payment,
'provider': self,
}
return template.render(ctx)
class ManualPayment(BasePaymentProvider):
identifier = 'manual'

View File

@@ -87,15 +87,6 @@ DEFAULT_VARIABLES = OrderedDict((
"editor_sample": _("123.45 EUR"),
"evaluate": lambda op, order, event: money_filter(op.price, event.currency)
}),
("price_with_addons", {
"label": _("Price including add-ons"),
"editor_sample": _("123.45 EUR"),
"evaluate": lambda op, order, event: money_filter(op.price + sum(
p.price
for p in op.addons.all()
if not p.canceled
), event.currency)
}),
("attendee_name", {
"label": _("Attendee name"),
"editor_sample": _("John Doe"),
@@ -119,18 +110,7 @@ DEFAULT_VARIABLES = OrderedDict((
("event_begin", {
"label": _("Event begin date and time"),
"editor_sample": _("2017-05-31 20:00"),
"evaluate": lambda op, order, ev: date_format(
ev.date_from.astimezone(timezone(ev.settings.timezone)),
"SHORT_DATETIME_FORMAT"
) if ev.date_from else ""
}),
("event_begin_date", {
"label": _("Event begin date"),
"editor_sample": _("2017-05-31"),
"evaluate": lambda op, order, ev: date_format(
ev.date_from.astimezone(timezone(ev.settings.timezone)),
"SHORT_DATE_FORMAT"
) if ev.date_from else ""
"evaluate": lambda op, order, ev: ev.get_date_from_display(show_times=True)
}),
("event_begin_time", {
"label": _("Event begin time"),
@@ -201,7 +181,6 @@ DEFAULT_VARIABLES = OrderedDict((
op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {})
else op.addons.select_related('item', 'variation')
)
if not p.canceled
])
}),
("organizer", {

View File

@@ -1,10 +1,8 @@
import sys
from enum import Enum
from typing import List
from django.apps import AppConfig, apps
from django.apps import apps
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
class PluginType(Enum):
@@ -41,22 +39,3 @@ def get_all_plugins(event=None) -> List[type]:
plugins,
key=lambda m: (0 if m.module.startswith('pretix.') else 1, str(m.name).lower().replace('pretix ', ''))
)
class PluginConfig(AppConfig):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not hasattr(self, 'PretixPluginMeta'):
raise ImproperlyConfigured("A pretix plugin config should have a PretixPluginMeta inner class.")
if hasattr(self.PretixPluginMeta, 'compatibility'):
import pkg_resources
try:
pkg_resources.require(self.PretixPluginMeta.compatibility)
except pkg_resources.VersionConflict as e:
print("Incompatible plugins found!")
print("Plugin {} requires you to have {}, but you installed {}.".format(
self.name, e.req, e.dist
))
sys.exit(1)

View File

@@ -1,27 +1,25 @@
from collections import Counter, defaultdict, namedtuple
from datetime import datetime, time, timedelta
from datetime import timedelta
from decimal import Decimal
from typing import List, Optional
from celery.exceptions import MaxRetriesExceededError
from django.core.exceptions import ValidationError
from django.db import DatabaseError, transaction
from django.db import transaction
from django.db.models import Q
from django.dispatch import receiver
from django.utils.timezone import make_aware, now
from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext as _
from pretix.base.i18n import language
from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation,
Voucher,
CartPosition, Event, InvoiceAddress, Item, ItemVariation, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import OrderFee
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.checkin import _save_answers
from pretix.base.services.locking import LockTimeoutException, NoLockManager
from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.pricing import get_price
from pretix.base.services.tasks import ProfiledTask
from pretix.base.settings import PERSON_NAME_SCHEMES
@@ -89,13 +87,12 @@ error_messages = {
'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the '
'product %(base)s.'),
'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'),
'bundled_only': _('One of the products you selected can only be bought part of a bundle.'),
}
class CartManager:
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
'addon_to', 'subevent', 'includes_tax', 'bundled'))
'addon_to', 'subevent', 'includes_tax'))
RemoveOperation = namedtuple('RemoveOperation', ('position',))
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
'quotas', 'subevent'))
@@ -135,15 +132,6 @@ class CartManager:
raise CartError(error_messages['not_started'])
if self.event.presale_has_ended:
raise CartError(error_messages['ended'])
if not self.event.has_subevents:
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if tlv:
term_last = make_aware(datetime.combine(
tlv.datetime(self.event).date(),
time(hour=23, minute=59, second=59)
), self.event.timezone)
if term_last < self.now_dt:
raise CartError(error_messages['ended'])
def _extend_expiry_of_valid_existing_positions(self):
# Extend this user's cart session to ensure all items in the cart expire at the same time
@@ -162,18 +150,6 @@ class CartManager:
err = error_messages['some_subevent_ended']
cp.addons.all().delete()
cp.delete()
if cp.subevent:
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if tlv:
term_last = make_aware(datetime.combine(
tlv.datetime(cp.subevent).date(),
time(hour=23, minute=59, second=59)
), self.event.timezone)
if term_last < self.now_dt:
err = error_messages['some_subevent_ended']
cp.addons.all().delete()
cp.delete()
return err
def _update_subevents_cache(self, se_ids: List[int]):
@@ -186,7 +162,7 @@ class CartManager:
self._items_cache.update({
i.pk: i
for i in self.event.items.select_related('category').prefetch_related(
'addons', 'bundles', 'addons__addon_category', 'quotas'
'addons', 'addons__addon_category', 'quotas'
).filter(
id__in=[i for i in item_ids if i and i not in self._items_cache]
)
@@ -238,23 +214,10 @@ class CartManager:
if op.subevent and op.subevent.presale_has_ended:
raise CartError(error_messages['ended'])
if op.subevent:
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if tlv:
term_last = make_aware(datetime.combine(
tlv.datetime(op.subevent).date(),
time(hour=23, minute=59, second=59)
), self.event.timezone)
if term_last < self.now_dt:
raise CartError(error_messages['ended'])
if isinstance(op, self.AddOperation):
if op.item.category and op.item.category.is_addon and not (op.addon_to and op.addon_to != 'FAKE'):
if op.item.category and op.item.category.is_addon and not op.addon_to:
raise CartError(error_messages['addon_only'])
if op.item.require_bundling and not op.addon_to == 'FAKE':
raise CartError(error_messages['bundled_only'])
if op.item.max_per_order or op.item.min_per_order:
new_total = (
len([1 for p in self.positions if p.item_id == op.item.pk]) +
@@ -283,13 +246,12 @@ class CartManager:
def _get_price(self, item: Item, variation: Optional[ItemVariation],
voucher: Optional[Voucher], custom_price: Optional[Decimal],
subevent: Optional[SubEvent], cp_is_net: bool=None, force_custom_price=False,
bundled_sum=Decimal('0.00')):
subevent: Optional[SubEvent], cp_is_net: bool=None):
try:
return get_price(
item, variation, voucher, custom_price, subevent,
custom_price_is_net=cp_is_net if cp_is_net is not None else self.event.settings.display_net_prices,
invoice_address=self.invoice_address, force_custom_price=force_custom_price, bundled_sum=bundled_sum
invoice_address=self.invoice_address
)
except ValueError as e:
if str(e) == 'price_too_high':
@@ -299,56 +261,22 @@ class CartManager:
def extend_expired_positions(self):
expired = self.positions.filter(expires__lte=self.now_dt).select_related(
'item', 'variation', 'voucher', 'addon_to', 'addon_to__item'
).prefetch_related(
'item__quotas',
'variation__quotas',
'addons'
).order_by('-is_bundled')
'item', 'variation', 'voucher'
).prefetch_related('item__quotas', 'variation__quotas')
err = None
changed_prices = {}
for cp in expired:
removed_positions = {op.position.pk for op in self._operations if isinstance(op, self.RemoveOperation)}
if cp.pk in removed_positions or (cp.addon_to_id and cp.addon_to_id in removed_positions):
continue
if cp.is_bundled:
try:
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
price = bundle.designated_price or 0
except ItemBundle.DoesNotExist:
price = cp.price
changed_prices[cp.pk] = price
if not cp.includes_tax:
price = self._get_price(cp.item, cp.variation, cp.voucher, price, cp.subevent,
force_custom_price=True, cp_is_net=False)
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
else:
price = self._get_price(cp.item, cp.variation, cp.voucher, price, cp.subevent,
force_custom_price=True)
if not cp.includes_tax:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
cp_is_net=True)
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
else:
bundled_sum = Decimal('0.00')
if not cp.addon_to_id:
for bundledp in cp.addons.all():
if bundledp.is_bundled:
bundledprice = changed_prices.get(bundledp.pk, bundledp.price)
bundled_sum += bundledprice
if not cp.includes_tax:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
cp_is_net=True, bundled_sum=bundled_sum)
price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='')
else:
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent,
bundled_sum=bundled_sum)
price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent)
quotas = list(cp.quotas)
if not quotas:
self._operations.append(self.RemoveOperation(position=cp))
err = error_messages['unavailable']
continue
err = error_messages['unavailable']
if not cp.voucher or (not cp.voucher.allow_ignore_quota and not cp.voucher.block_quota):
for quota in quotas:
@@ -413,48 +341,10 @@ class CartManager:
else:
quotas = []
# Fetch bundled items
bundled = []
bundled_sum = Decimal('0.00')
db_bundles = list(item.bundles.all())
self._update_items_cache([b.bundled_item_id for b in db_bundles], [b.bundled_variation_id for b in db_bundles])
for bundle in db_bundles:
if bundle.bundled_item_id not in self._items_cache or (
bundle.bundled_variation_id and bundle.bundled_variation_id not in self._variations_cache
):
raise CartError(error_messages['not_for_sale'])
bitem = self._items_cache[bundle.bundled_item_id]
bvar = self._variations_cache[bundle.bundled_variation_id] if bundle.bundled_variation_id else None
bundle_quotas = list(bitem.quotas.filter(subevent=subevent)
if bvar is None else bvar.quotas.filter(subevent=subevent))
if not bundle_quotas:
raise CartError(error_messages['unavailable'])
if not voucher or not voucher.allow_ignore_quota:
for quota in bundle_quotas:
quota_diff[quota] += bundle.count * i['count']
else:
bundle_quotas = []
if bundle.designated_price:
bprice = self._get_price(bitem, bvar, None, bundle.designated_price, subevent, force_custom_price=True,
cp_is_net=False)
else:
bprice = TAXED_ZERO
bundled_sum += bundle.designated_price * bundle.count
bop = self.AddOperation(
count=bundle.count, item=bitem, variation=bvar, price=bprice,
voucher=None, quotas=bundle_quotas, addon_to='FAKE', subevent=subevent,
includes_tax=bool(bprice.rate), bundled=[]
)
self._check_item_constraints(bop)
bundled.append(bop)
price = self._get_price(item, variation, voucher, i.get('price'), subevent, bundled_sum=bundled_sum)
price = self._get_price(item, variation, voucher, i.get('price'), subevent)
op = self.AddOperation(
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled
addon_to=False, subevent=subevent, includes_tax=bool(price.rate)
)
self._check_item_constraints(op)
operations.append(op)
@@ -513,7 +403,6 @@ class CartManager:
current_addons[cp] = {
(a.item_id, a.variation_id): a
for a in cp.addons.all()
if not a.is_bundled
}
# Create operations, perform various checks
@@ -560,7 +449,7 @@ class CartManager:
op = self.AddOperation(
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[]
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate)
)
self._check_item_constraints(op)
operations.append(op)
@@ -634,7 +523,7 @@ class CartManager:
Q(voucher=voucher) & Q(event=self.event) &
Q(expires__gte=self.now_dt)
).exclude(pk__in=[
op.position.id for op in self._operations if isinstance(op, self.ExtendOperation)
op.position.voucher_id for op in self._operations if isinstance(op, self.ExtendOperation)
])
cart_count = redeemed_in_carts.count()
v_avail = voucher.max_usages - voucher.redeemed - cart_count
@@ -720,29 +609,11 @@ class CartManager:
available_count = min(quota_available_count, voucher_available_count)
if isinstance(op, self.AddOperation):
for b in op.bundled:
b_quota_available_count = min(available_count * b.count, min(quotas_ok[q] for q in b.quotas))
if b_quota_available_count < b.count:
err = err or error_messages['unavailable']
available_count = 0
elif b_quota_available_count < available_count * b.count:
err = err or error_messages['in_part']
available_count = b_quota_available_count // b.count
for q in b.quotas:
quotas_ok[q] -= available_count * b.count
# TODO: is this correct?
for q in op.quotas:
quotas_ok[q] -= available_count
if op.voucher:
vouchers_ok[op.voucher] -= available_count
if any(qa < 0 for qa in quotas_ok.values()):
# Safeguard, shouldn't happen
err = err or error_messages['unavailable']
available_count = 0
if isinstance(op, self.AddOperation):
for k in range(available_count):
cp = CartPosition(
@@ -775,27 +646,12 @@ class CartManager:
except ValidationError:
pass
if op.bundled:
cp.save() # Needs to be in the database already so we have a PK that we can reference
for b in op.bundled:
for j in range(b.count):
new_cart_positions.append(CartPosition(
event=self.event, item=b.item, variation=b.variation,
price=b.price.gross, expires=self._expiry, cart_id=self.cart_id,
voucher=None, addon_to=cp,
subevent=b.subevent, includes_tax=b.includes_tax, is_bundled=True
))
new_cart_positions.append(cp)
elif isinstance(op, self.ExtendOperation):
if available_count == 1:
op.position.expires = self._expiry
op.position.price = op.price.gross
try:
op.position.save(force_update=True)
except DatabaseError:
# Best effort... The position might have been deleted in the meantime!
pass
op.position.save()
elif available_count == 0:
op.position.addons.all().delete()
op.position.delete()
@@ -803,43 +659,26 @@ class CartManager:
raise AssertionError("ExtendOperation cannot affect more than one item")
for p in new_cart_positions:
if getattr(p, '_answers', None):
if not p.pk: # We stored some to the database already before
p.save()
if p._answers:
p.save()
_save_answers(p, {}, p._answers)
CartPosition.objects.bulk_create([p for p in new_cart_positions if not getattr(p, '_answers', None) and not p.pk])
CartPosition.objects.bulk_create([p for p in new_cart_positions if not p._answers])
return err
def _require_locking(self):
if self._voucher_use_diff:
# If any vouchers are used, we lock to make sure we don't redeem them to often
return True
if self._quota_diff and any(q.size is not None for q in self._quota_diff):
# If any quotas are affected that are not unlimited, we lock
return True
return False
def commit(self):
self._check_presale_dates()
self._check_max_cart_size()
self._calculate_expiry()
err = self._delete_out_of_timeframe()
err = self.extend_expired_positions() or err
lockfn = NoLockManager
if self._require_locking():
lockfn = self.event.lock
with lockfn() as now_dt:
with transaction.atomic():
self.now_dt = now_dt
self._extend_expiry_of_valid_existing_positions()
err = self._perform_operations() or err
if err:
raise CartError(err)
# with self.event.lock() as now_dt:
with transaction.atomic():
self.now_dt = now()
self._extend_expiry_of_valid_existing_positions()
err = self._delete_out_of_timeframe()
err = self.extend_expired_positions() or err
err = self._perform_operations() or err
if err:
raise CartError(err)
def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress):
@@ -908,7 +747,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
"""
Adds a list of items to a user's cart.
:param event: The event ID in question
:param items: A list of dicts with the keys item, variation, count, custom_price, voucher
:param items: A list of dicts with the keys item, variation, number, custom_price, voucher
:param cart_id: Session ID of a guest
:raises CartError: On any error that occured
"""

View File

@@ -120,7 +120,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
positions = list(
invoice.order.positions.select_related('addon_to', 'item', 'tax_rule', 'subevent', 'variation').annotate(
addon_c=Count('addons')
).order_by('positionid', 'id')
)
)
reverse_charge = False

View File

@@ -13,18 +13,6 @@ logger = logging.getLogger('pretix.base.locking')
LOCK_TIMEOUT = 120
class NoLockManager:
def __init__(self):
pass
def __enter__(self):
return now()
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None:
return False
class LockManager:
def __init__(self, event):
self.event = event

View File

@@ -15,7 +15,6 @@ from pretix.base.email import ClassicMailRenderer
from pretix.base.i18n import language
from pretix.base.models import Event, Invoice, InvoiceAddress, Order
from pretix.base.services.invoices import invoice_pdf_task
from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.services.tickets import get_tickets_for_order
from pretix.base.signals import email_filter
from pretix.celery_app import app
@@ -178,7 +177,7 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
chain(*task_chain).apply_async()
@app.task(base=TransactionAwareTask, bind=True)
@app.task(bind=True)
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
event: int=None, headers: dict=None, bcc: List[str]=None, invoices: List[int]=None,
order: int=None, attach_tickets=False) -> bool:
@@ -213,30 +212,15 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
order = None
else:
if attach_tickets:
args = []
attach_size = 0
for name, ct in get_tickets_for_order(order):
content = ct.file.read()
args.append((name, content, ct.type))
attach_size += len(content)
if attach_size < 4 * 1024 * 1024:
# Do not attach more than 4MB, it will bounce way to often.
for a in args:
try:
email.attach(*a)
except:
pass
else:
order.log_action(
'pretix.event.order.email.attachments.skipped',
data={
'subject': 'Attachments skipped',
'message': 'Attachment have not been send because {} bytes are likely too large to arrive.'.format(attach_size),
'recipient': '',
'invoices': [],
}
)
try:
email.attach(
name,
ct.file.read(),
ct.type
)
except:
pass
email = email_filter.send_chained(event, 'message', message=email, order=order)

View File

@@ -1,7 +1,7 @@
import json
import logging
from collections import Counter, namedtuple
from datetime import datetime, time, timedelta
from datetime import datetime, timedelta
from decimal import Decimal
from typing import List, Optional
@@ -9,12 +9,12 @@ import pytz
from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
from django.db import transaction
from django.db.models import Exists, F, Max, OuterRef, Q, Sum
from django.db.models import F, Max, Q, Sum
from django.db.models.functions import Greatest
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now
from django.utils.timezone import now
from django.utils.translation import ugettext as _
from pretix.api.models import OAuthApplication
@@ -26,27 +26,22 @@ from pretix.base.models import (
OrderPosition, Quota, User, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemBundle
from pretix.base.models.orders import (
InvoiceAddress, OrderFee, OrderRefund, generate_position_secret,
generate_secret,
CachedCombinedTicket, CachedTicket, InvoiceAddress, OrderFee, OrderRefund,
generate_position_secret, generate_secret,
)
from pretix.base.models.organizer import TeamAPIToken
from pretix.base.models.tax import TaxedPrice
from pretix.base.payment import BasePaymentProvider, PaymentException
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services import tickets
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_qualified,
)
from pretix.base.services.locking import LockTimeoutException, NoLockManager
from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.mail import SendMailException
from pretix.base.services.pricing import get_price
from pretix.base.services.tasks import ProfiledTask
from pretix.base.signals import (
allow_ticket_download, order_approved, order_canceled, order_changed,
order_denied, order_expired, order_fee_calculation, order_placed,
periodic_task,
allow_ticket_download, order_fee_calculation, order_placed, periodic_task,
)
from pretix.celery_app import app
from pretix.helpers.models import modelcopy
@@ -138,58 +133,55 @@ def mark_order_refunded(order, user=None, auth=None, api_token=None):
)
@transaction.atomic
def mark_order_expired(order, user=None, auth=None):
"""
Mark this order as expired. This sets the payment status and returns the order object.
:param order: The order to change
:param user: The user that performed the change
"""
with transaction.atomic():
if isinstance(order, int):
order = Order.objects.get(pk=order)
if isinstance(user, int):
user = User.objects.get(pk=user)
with order.event.lock():
order.status = Order.STATUS_EXPIRED
order.save(update_fields=['status'])
if isinstance(order, int):
order = Order.objects.get(pk=order)
if isinstance(user, int):
user = User.objects.get(pk=user)
with order.event.lock():
order.status = Order.STATUS_EXPIRED
order.save(update_fields=['status'])
order.log_action('pretix.event.order.expired', user=user, auth=auth)
i = order.invoices.filter(is_cancellation=False).last()
if i:
generate_cancellation(i)
order.log_action('pretix.event.order.expired', user=user, auth=auth)
i = order.invoices.filter(is_cancellation=False).last()
if i:
generate_cancellation(i)
order_expired.send(order.event, order=order)
return order
def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False):
@transaction.atomic
def approve_order(order, user=None, send_mail: bool=True, auth=None):
"""
Mark this order as approved
:param order: The order to change
:param user: The user that performed the change
"""
with transaction.atomic():
if not order.require_approval or not order.status == Order.STATUS_PENDING:
raise OrderError(_('This order is not pending approval.'))
if not order.require_approval or not order.status == Order.STATUS_PENDING:
raise OrderError(_('This order is not pending approval.'))
order.require_approval = False
order.set_expires(now(), order.event.subevents.filter(id__in=[p.subevent_id for p in order.positions.all()]))
order.save(update_fields=['require_approval', 'expires'])
order.require_approval = False
order.set_expires(now(), order.event.subevents.filter(id__in=[p.subevent_id for p in order.positions.all()]))
order.save(update_fields=['require_approval', 'expires'])
order.log_action('pretix.event.order.approved', user=user, auth=auth)
if order.total == Decimal('0.00'):
p = order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider='free',
amount=0,
fee=None
)
try:
p.confirm(send_mail=False, count_waitinglist=False, user=user, auth=auth, ignore_date=True, force=force)
except Quota.QuotaExceededException:
raise OrderError(error_messages['unavailable'])
order_approved.send(order.event, order=order)
order.log_action('pretix.event.order.approved', user=user, auth=auth)
if order.total == Decimal('0.00'):
p = order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider='free',
amount=0,
fee=None
)
try:
p.confirm(send_mail=False, count_waitinglist=False, user=user, auth=auth)
except Quota.QuotaExceededException:
raise OrderError(error_messages['unavailable'])
invoice = order.invoices.last() # Might be generated by plugin already
if order.event.settings.get('invoice_generate') == 'True' and invoice_qualified(order):
@@ -241,32 +233,30 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
return order.pk
@transaction.atomic
def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
"""
Mark this order as canceled
:param order: The order to change
:param user: The user that performed the change
"""
with transaction.atomic():
if not order.require_approval or not order.status == Order.STATUS_PENDING:
raise OrderError(_('This order is not pending approval.'))
if not order.require_approval or not order.status == Order.STATUS_PENDING:
raise OrderError(_('This order is not pending approval.'))
with order.event.lock():
order.status = Order.STATUS_CANCELED
order.save(update_fields=['status'])
with order.event.lock():
order.status = Order.STATUS_CANCELED
order.save(update_fields=['status'])
order.log_action('pretix.event.order.denied', user=user, auth=auth, data={
'comment': comment
})
i = order.invoices.filter(is_cancellation=False).last()
if i:
generate_cancellation(i)
order.log_action('pretix.event.order.denied', user=user, auth=auth, data={
'comment': comment
})
i = order.invoices.filter(is_cancellation=False).last()
if i:
generate_cancellation(i)
for position in order.positions.all():
if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
order_denied.send(order.event, order=order)
for position in order.positions.all():
if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
if send_mail:
try:
@@ -303,6 +293,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
return order.pk
@transaction.atomic
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None,
cancellation_fee=None):
"""
@@ -310,87 +301,85 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
:param order: The order to change
:param user: The user that performed the change
"""
with transaction.atomic():
if isinstance(order, int):
order = Order.objects.get(pk=order)
if isinstance(user, int):
user = User.objects.get(pk=user)
if isinstance(api_token, int):
api_token = TeamAPIToken.objects.get(pk=api_token)
if isinstance(device, int):
device = Device.objects.get(pk=device)
if isinstance(oauth_application, int):
oauth_application = OAuthApplication.objects.get(pk=oauth_application)
if isinstance(cancellation_fee, str):
cancellation_fee = Decimal(cancellation_fee)
if isinstance(order, int):
order = Order.objects.get(pk=order)
if isinstance(user, int):
user = User.objects.get(pk=user)
if isinstance(api_token, int):
api_token = TeamAPIToken.objects.get(pk=api_token)
if isinstance(device, int):
device = Device.objects.get(pk=device)
if isinstance(oauth_application, int):
oauth_application = OAuthApplication.objects.get(pk=oauth_application)
if isinstance(cancellation_fee, str):
cancellation_fee = Decimal(cancellation_fee)
if not order.cancel_allowed():
raise OrderError(_('You cannot cancel this order.'))
i = order.invoices.filter(is_cancellation=False).last()
if i:
generate_cancellation(i)
if cancellation_fee:
with order.event.lock():
for position in order.positions.all():
if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
position.canceled = True
position.save(update_fields=['canceled'])
for fee in order.fees.all():
fee.canceled = True
fee.save(update_fields=['canceled'])
f = OrderFee(
fee_type=OrderFee.FEE_TYPE_CANCELLATION,
value=cancellation_fee,
tax_rule=order.event.settings.tax_rate_default,
order=order,
)
f._calculate_tax()
f.save()
if order.payment_refund_sum < cancellation_fee:
raise OrderError(_('The cancellation fee cannot be higher than the payment credit of this order.'))
order.status = Order.STATUS_PAID
order.total = f.value
order.save(update_fields=['status', 'total'])
if i:
generate_invoice(order)
else:
with order.event.lock():
order.status = Order.STATUS_CANCELED
order.save(update_fields=['status'])
if not order.cancel_allowed():
raise OrderError(_('You cannot cancel this order.'))
i = order.invoices.filter(is_cancellation=False).last()
if i:
generate_cancellation(i)
if cancellation_fee:
with order.event.lock():
for position in order.positions.all():
if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
position.canceled = True
position.save(update_fields=['canceled'])
for fee in order.fees.all():
fee.canceled = True
fee.save(update_fields=['canceled'])
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device,
data={'cancellation_fee': cancellation_fee})
f = OrderFee(
fee_type=OrderFee.FEE_TYPE_CANCELLATION,
value=cancellation_fee,
tax_rule=order.event.settings.tax_rate_default,
order=order,
)
f._calculate_tax()
f.save()
if send_mail:
email_template = order.event.settings.mail_text_order_canceled
email_context = {
'event': order.event.name,
'code': order.code,
'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret
})
}
with language(order.locale):
email_subject = _('Order canceled: %(code)s') % {'code': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_canceled', user
)
except SendMailException:
logger.exception('Order canceled email could not be sent')
if order.payment_refund_sum < cancellation_fee:
raise OrderError(_('The cancellation fee cannot be higher than the payment credit of this order.'))
order.status = Order.STATUS_PAID
order.total = f.value
order.save(update_fields=['status', 'total'])
if i:
generate_invoice(order)
else:
with order.event.lock():
order.status = Order.STATUS_CANCELED
order.save(update_fields=['status'])
for position in order.positions.all():
if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device,
data={'cancellation_fee': cancellation_fee})
if send_mail:
email_template = order.event.settings.mail_text_order_canceled
email_context = {
'event': order.event.name,
'code': order.code,
'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret
})
}
with language(order.locale):
email_subject = _('Order canceled: %(code)s') % {'code': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_canceled', user
)
except SendMailException:
logger.exception('Order canceled email could not be sent')
order_canceled.send(order.event, order=order)
return order.pk
@@ -404,16 +393,6 @@ def _check_date(event: Event, now_dt: datetime):
if event.presale_has_ended:
raise OrderError(error_messages['ended'])
if not event.has_subevents:
tlv = event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if tlv:
term_last = make_aware(datetime.combine(
tlv.datetime(event).date(),
time(hour=23, minute=59, second=59)
), event.timezone)
if term_last < now_dt:
raise OrderError(error_messages['ended'])
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition], address: InvoiceAddress=None):
err = None
@@ -421,27 +400,10 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
_check_date(event, now_dt)
products_seen = Counter()
changed_prices = {}
deleted_positions = set()
def delete(cp):
# Delete a cart position, including parents and children, if applicable
if cp.is_bundled:
delete(cp.addon_to)
else:
for p in cp.addons.all():
deleted_positions.add(p.pk)
p.delete()
deleted_positions.add(cp.pk)
cp.delete()
for i, cp in enumerate(sorted(positions, key=lambda s: -int(s.is_bundled))):
if cp.pk in deleted_positions:
continue
for i, cp in enumerate(positions):
if not cp.item.is_available() or (cp.variation and not cp.variation.active):
err = err or error_messages['unavailable']
delete(cp)
cp.delete()
continue
quotas = list(cp.quotas)
@@ -450,7 +412,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
err = error_messages['max_items_per_product']
errargs = {'max': cp.item.max_per_order,
'product': cp.item.name}
delete(cp)
cp.delete() # Sorry!
break
if cp.voucher:
@@ -460,39 +422,27 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
v_avail = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count()
if v_avail < 1:
err = err or error_messages['voucher_redeemed']
delete(cp)
cp.delete() # Sorry!
continue
if cp.subevent and cp.subevent.presale_start and now_dt < cp.subevent.presale_start:
err = err or error_messages['some_subevent_not_started']
delete(cp)
cp.delete()
break
if cp.subevent:
tlv = event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if tlv:
term_last = make_aware(datetime.combine(
tlv.datetime(cp.subevent).date(),
time(hour=23, minute=59, second=59)
), event.timezone)
if term_last < now_dt:
err = err or error_messages['some_subevent_ended']
delete(cp)
break
if cp.subevent and cp.subevent.presale_has_ended:
err = err or error_messages['some_subevent_ended']
delete(cp)
cp.delete()
break
if cp.item.require_voucher and cp.voucher is None:
delete(cp)
cp.delete()
err = err or error_messages['voucher_required']
break
if cp.item.hide_without_voucher and (cp.voucher is None or cp.voucher.item is None
or cp.voucher.item.pk != cp.item.pk):
delete(cp)
cp.delete()
err = error_messages['voucher_required']
break
@@ -500,37 +450,22 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
# Other checks are not necessary
continue
if cp.is_bundled:
try:
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
bprice = bundle.designated_price or 0
except ItemBundle.DoesNotExist:
bprice = cp.price
price = get_price(cp.item, cp.variation, cp.voucher, bprice, cp.subevent, custom_price_is_net=False,
invoice_address=address, force_custom_price=True)
changed_prices[cp.pk] = bprice
else:
bundled_sum = 0
if not cp.addon_to_id:
for bundledp in cp.addons.all():
if bundledp.is_bundled:
bundled_sum += changed_prices.get(bundledp.pk, bundledp.price)
price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False,
addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum)
price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False,
addon_to=cp.addon_to, invoice_address=address)
if price is False or len(quotas) == 0:
err = err or error_messages['unavailable']
delete(cp)
cp.delete()
continue
if cp.voucher:
if cp.voucher.valid_until and cp.voucher.valid_until < now_dt:
err = err or error_messages['voucher_expired']
delete(cp)
cp.delete()
continue
if price.gross != cp.price and not (cp.item.free_price and cp.price > price.gross):
positions[i] = cp
cp.price = price.gross
cp.includes_tax = bool(price.rate)
cp.save()
@@ -554,12 +489,12 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
break
if quota_ok:
positions[i] = cp
cp.expires = now_dt + timedelta(
minutes=event.settings.get('reservation_time', as_type=int))
cp.save()
else:
# Sorry, can't let you keep that!
delete(cp)
cp.delete() # Sorry!
if err:
raise OrderError(err, errargs)
@@ -589,7 +524,6 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
meta_info: dict=None, sales_channel: str='web'):
fees, pf = _get_fees(positions, payment_provider, address, meta_info, event)
total = sum([c.price for c in positions]) + sum([c.value for c in fees])
p = None
with transaction.atomic():
order = Order(
@@ -623,7 +557,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
fee.save()
if payment_provider and not order.require_approval:
p = order.payments.create(
order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider=payment_provider.identifier,
amount=total,
@@ -639,7 +573,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
order.log_action('pretix.event.order.consent', data={'msg': msg})
order_placed.send(event, order=order)
return order, p
return order
def _perform_order(event: str, payment_provider: str, position_ids: List[str],
@@ -663,32 +597,16 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
except InvoiceAddress.DoesNotExist:
pass
positions = CartPosition.objects.filter(id__in=position_ids, event=event)
lockfn = NoLockManager
locked = False
if positions.filter(Q(voucher__isnull=False) | Q(expires__lt=now() + timedelta(minutes=2))).exists():
# Performance optimization: If no voucher is used and no cart position is dangerously close to its expiry date,
# creating this order shouldn't be prone to any race conditions and we don't need to lock the event.
locked = True
lockfn = event.lock
with lockfn() as now_dt:
positions = list(positions.select_related('item', 'variation', 'subevent', 'addon_to').prefetch_related('addons'))
if len(positions) == 0:
raise OrderError(error_messages['empty'])
if len(position_ids) != len(positions):
raise OrderError(error_messages['internal'])
_check_positions(event, now_dt, positions, address=addr)
order, payment = _create_order(event, email, positions, now_dt, pprov,
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel)
free_order_flow = payment and payment_provider == 'free' and order.total == Decimal('0.00') and not order.require_approval
if free_order_flow:
try:
payment.confirm(send_mail=False, lock=not locked)
except Quota.QuotaExceededException:
pass
# with event.lock() as now_dt:
now_dt = now()
positions = list(CartPosition.objects.filter(id__in=position_ids).select_related('item', 'variation', 'subevent'))
if len(positions) == 0:
raise OrderError(error_messages['empty'])
if len(position_ids) != len(positions):
raise OrderError(error_messages['internal'])
_check_positions(event, now_dt, positions, address=addr)
order = _create_order(event, email, positions, now_dt, pprov,
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel)
invoice = order.invoices.last() # Might be generated by plugin already
if event.settings.get('invoice_generate') == 'True' and invoice_qualified(order):
@@ -703,7 +621,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
if order.require_approval:
email_template = event.settings.mail_text_order_placed_require_approval
log_entry = 'pretix.event.order.email.order_placed_require_approval'
elif free_order_flow:
elif payment_provider == 'free':
email_template = event.settings.mail_text_order_free
log_entry = 'pretix.event.order.email.order_free'
else:
@@ -770,8 +688,7 @@ def send_expiry_warnings(sender, **kwargs):
today = now().replace(hour=0, minute=0, second=0)
for o in Order.objects.filter(
expires__gte=today, expiry_reminder_sent=False, status=Order.STATUS_PENDING,
datetime__lte=now() - timedelta(hours=2), require_approval=False
expires__gte=today, expiry_reminder_sent=False, status=Order.STATUS_PENDING, datetime__lte=now() - timedelta(hours=2)
).only('pk'):
with transaction.atomic():
o = Order.objects.select_related('event').select_for_update().get(pk=o.pk)
@@ -879,8 +796,8 @@ class OrderChangeManager:
'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'),
'subevent_required': _('You need to choose a subevent for the new position.'),
}
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation'))
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent'))
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation', 'price'))
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent', 'price'))
PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
CancelOperation = namedtuple('CancelOperation', ('position',))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent'))
@@ -891,7 +808,6 @@ class OrderChangeManager:
self.order = order
self.user = user
self.auth = auth
self.event = order.event
self.split_order = None
self._committed = False
self._totaldiff = 0
@@ -900,18 +816,33 @@ class OrderChangeManager:
self.notify = notify
self._invoice_dirty = False
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]):
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation], keep_price=False):
if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
raise OrderError(self.error_messages['product_without_variation'])
if keep_price:
price = TaxedPrice(gross=position.price, net=position.price - position.tax_value,
tax=position.tax_value, rate=position.tax_rate,
name=position.tax_rule.name if position.tax_rule else None)
else:
price = get_price(item, variation, voucher=position.voucher, subevent=position.subevent,
invoice_address=self._invoice_address)
if price is None: # NOQA
raise OrderError(self.error_messages['product_invalid'])
new_quotas = (variation.quotas.filter(subevent=position.subevent)
if variation else item.quotas.filter(subevent=position.subevent))
if not new_quotas:
raise OrderError(self.error_messages['quota_missing'])
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'):
self._invoice_dirty = True
self._totaldiff += price.gross - position.price
self._quotadiff.update(new_quotas)
self._quotadiff.subtract(position.quotas)
self._operations.append(self.ItemOperation(position, item, variation))
self._operations.append(self.ItemOperation(position, item, variation, price))
def change_subevent(self, position: OrderPosition, subevent: SubEvent):
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent,
@@ -925,15 +856,19 @@ class OrderChangeManager:
if not new_quotas:
raise OrderError(self.error_messages['quota_missing'])
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'):
self._invoice_dirty = True
self._totaldiff += price.gross - position.price
self._quotadiff.update(new_quotas)
self._quotadiff.subtract(position.quotas)
self._operations.append(self.SubeventOperation(position, subevent))
self._operations.append(self.SubeventOperation(position, subevent, price))
def regenerate_secret(self, position: OrderPosition):
self._operations.append(self.RegenerateSecretOperation(position))
def change_price(self, position: OrderPosition, price: Decimal):
price = position.item.tax(price, base_price_is='gross')
price = position.item.tax(price)
self._totaldiff += price.gross - position.price
@@ -1090,11 +1025,14 @@ class OrderChangeManager:
'new_variation': op.variation.pk if op.variation else None,
'old_price': op.position.price,
'addon_to': op.position.addon_to_id,
'new_price': op.position.price
'new_price': op.price.gross
})
op.position.item = op.item
op.position.variation = op.variation
op.position._calculate_tax()
op.position.price = op.price.gross
op.position.tax_rate = op.price.rate
op.position.tax_value = op.price.tax
op.position.tax_rule = op.item.tax_rule
op.position.save()
elif isinstance(op, self.SubeventOperation):
self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={
@@ -1103,9 +1041,13 @@ class OrderChangeManager:
'old_subevent': op.position.subevent.pk,
'new_subevent': op.subevent.pk,
'old_price': op.position.price,
'new_price': op.position.price
'new_price': op.price.gross
})
op.position.subevent = op.subevent
op.position.price = op.price.gross
op.position.tax_rate = op.price.rate
op.position.tax_value = op.price.tax
op.position.tax_rule = op.position.item.tax_rule
op.position.save()
elif isinstance(op, self.PriceOperation):
self.order.log_action('pretix.event.order.changed.price', user=self.user, auth=self.auth, data={
@@ -1116,7 +1058,9 @@ class OrderChangeManager:
'new_price': op.price.gross
})
op.position.price = op.price.gross
op.position._calculate_tax()
op.position.tax_rate = op.price.rate
op.position.tax_value = op.price.tax
op.position.tax_rule = op.position.item.tax_rule
op.position.save()
elif isinstance(op, self.CancelOperation):
for opa in op.position.addons.all():
@@ -1166,8 +1110,8 @@ class OrderChangeManager:
elif isinstance(op, self.RegenerateSecretOperation):
op.position.secret = generate_position_secret()
op.position.save()
tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk,
'order': self.order.pk})
CachedTicket.objects.filter(order_position__order=self.order).delete()
CachedCombinedTicket.objects.filter(order=self.order).delete()
self.order.log_action('pretix.event.order.changed.secret', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
@@ -1397,14 +1341,12 @@ class OrderChangeManager:
if self.split_order:
self._notify_user(self.split_order)
order_changed.send(self.order.event, order=self.order)
def _clear_tickets_cache(self):
tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk,
'order': self.order.pk})
CachedTicket.objects.filter(order_position__order=self.order).delete()
CachedCombinedTicket.objects.filter(order=self.order).delete()
if self.split_order:
tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk,
'order': self.split_order.pk})
CachedTicket.objects.filter(order_position__order=self.split_order).delete()
CachedCombinedTicket.objects.filter(order=self.split_order).delete()
def _get_payment_provider(self):
lp = self.order.payments.last()
@@ -1490,52 +1432,3 @@ def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_tok
self.retry()
except (MaxRetriesExceededError, LockTimeoutException):
raise OrderError(error_messages['busy'])
def change_payment_provider(order: Order, payment_provider, amount=None, new_payment=None):
e = OrderPayment.objects.filter(fee=OuterRef('pk'), state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED,
OrderPayment.PAYMENT_STATE_REFUNDED))
open_fees = list(
order.fees.annotate(has_p=Exists(e)).filter(
Q(fee_type=OrderFee.FEE_TYPE_PAYMENT) & ~Q(has_p=True)
)
)
if open_fees:
fee = open_fees[0]
if len(open_fees) > 1:
for f in open_fees[1:]:
f.delete()
else:
fee = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.00'), order=order)
old_fee = fee.value
new_fee = payment_provider.calculate_fee(
order.pending_sum - old_fee if amount is None else amount
)
with transaction.atomic():
if new_fee:
fee.value = new_fee
fee.internal_type = payment_provider.identifier
fee._calculate_tax()
fee.save()
else:
if fee.pk:
fee.delete()
fee = None
open_payment = None
if new_payment:
lp = order.payments.exclude(pk=new_payment.pk).last()
else:
lp = order.payments.last()
if lp and lp.state not in (OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED):
open_payment = lp
if open_payment and open_payment.state in (OrderPayment.PAYMENT_STATE_PENDING,
OrderPayment.PAYMENT_STATE_CREATED):
open_payment.state = OrderPayment.PAYMENT_STATE_CANCELED
open_payment.save(update_fields=['state'])
order.total = (order.positions.aggregate(sum=Sum('price'))['sum'] or 0) + (order.fees.aggregate(sum=Sum('value'))['sum'] or 0)
order.save(update_fields=['total'])
return old_fee, new_fee, fee

View File

@@ -11,8 +11,7 @@ from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
def get_price(item: Item, variation: ItemVariation = None,
voucher: Voucher = None, custom_price: Decimal = None,
subevent: SubEvent = None, custom_price_is_net: bool = False,
addon_to: AbstractPosition = None, invoice_address: InvoiceAddress = None,
force_custom_price: bool = False, bundled_sum: Decimal = Decimal('0.00')) -> TaxedPrice:
addon_to: AbstractPosition = None, invoice_address: InvoiceAddress = None) -> TaxedPrice:
if addon_to:
try:
iao = addon_to.item.addons.get(addon_category_id=item.category_id)
@@ -45,11 +44,6 @@ def get_price(item: Item, variation: ItemVariation = None,
)
price = tax_rule.tax(price)
if force_custom_price and custom_price is not None and custom_price != "":
if custom_price_is_net:
price = tax_rule.tax(custom_price, base_price_is='net')
else:
price = tax_rule.tax(custom_price, base_price_is='gross')
if item.free_price and custom_price is not None and custom_price != "":
if not isinstance(custom_price, Decimal):
custom_price = Decimal(str(custom_price).replace(",", "."))
@@ -60,11 +54,6 @@ def get_price(item: Item, variation: ItemVariation = None,
else:
price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross')
if bundled_sum:
price = price - TaxedPrice(net=bundled_sum, gross=bundled_sum, rate=0, tax=0, name='')
if price.gross < Decimal('0.00'):
return TAXED_ZERO
if invoice_address and not tax_rule.tax_applicable(invoice_address):
price.tax = Decimal('0.00')
price.rate = Decimal('0.00')

View File

@@ -31,6 +31,6 @@ def refresh_quota_caches():
Q(cached_availability_time__isnull=True) |
Q(cached_availability_time__lt=F('last_activity')) |
Q(cached_availability_time__lt=now() - timedelta(hours=2), last_activity__gt=now() - timedelta(days=7))
).select_related('subevent')
)
for q in quotas:
q.availability()

View File

@@ -123,10 +123,8 @@ def get_tickets_for_order(order):
order=order, provider=p.identifier, file__isnull=False
).last()
if not ct or not ct.file:
retval = generate_order(order.pk, p.identifier)
if not retval:
continue
ct = CachedCombinedTicket.objects.get(pk=retval)
retval = generate.apply(args=('order', order.pk, p.identifier))
ct = CachedCombinedTicket.objects.get(pk=retval.get())
tickets.append((
"{}-{}-{}{}".format(
order.event.slug.upper(), order.code, ct.provider, ct.extension,
@@ -142,10 +140,8 @@ def get_tickets_for_order(order):
order_position=pos, provider=p.identifier, file__isnull=False
).last()
if not ct or not ct.file:
retval = generate_orderposition(pos.pk, p.identifier)
if not retval:
continue
ct = CachedCombinedTicket.objects.get(pk=retval)
retval = generate.apply(args=('orderposition', pos.pk, p.identifier))
ct = CachedTicket.objects.get(pk=retval.get())
tickets.append((
"{}-{}-{}-{}{}".format(
order.event.slug.upper(), order.code, pos.positionid, ct.provider, ct.extension,
@@ -156,26 +152,3 @@ def get_tickets_for_order(order):
logger.exception('Failed to generate ticket.')
return tickets
@app.task(base=ProfiledTask)
def invalidate_cache(event: int, item: int=None, provider: str=None, order: int=None, **kwargs):
event = Event.objects.get(id=event)
qs = CachedTicket.objects.filter(order_position__order__event=event)
qsc = CachedCombinedTicket.objects.filter(order__event=event)
if item:
qs = qs.filter(order_position__item_id=item)
if provider:
qs = qs.filter(provider=provider)
qsc = qsc.filter(provider=provider)
if order:
qs = qs.filter(order_position__order_id=order)
qsc = qsc.filter(order_id=order)
for ct in qs:
ct.delete()
for ct in qsc:
ct.delete()

View File

@@ -31,13 +31,12 @@ def run_update_check(sender, **kwargs):
@app.task
def update_check():
gs = GlobalSettingsObject()
if not gs.settings.update_check_perform:
return
if not gs.settings.update_check_id:
gs.settings.set('update_check_id', uuid.uuid4().hex)
if not gs.settings.update_check_perform:
return
if 'runserver' in sys.argv:
gs.settings.set('update_check_last', now())
gs.settings.set('update_check_result', {

View File

@@ -49,10 +49,6 @@ DEFAULTS = {
'default': 'True',
'type': bool,
},
'invoice_address_not_asked_free': {
'default': 'False',
'type': bool,
},
'invoice_name_required': {
'default': 'False',
'type': bool,
@@ -77,10 +73,6 @@ DEFAULTS = {
'default': 'False',
'type': bool,
},
'invoice_address_explanation_text': {
'default': '',
'type': LazyI18nString
},
'invoice_include_free': {
'default': 'True',
'type': bool,
@@ -101,14 +93,6 @@ DEFAULTS = {
'default': '30',
'type': int
},
'redirect_to_checkout_directly': {
'default': 'False',
'type': bool
},
'presale_has_ended_text': {
'default': '',
'type': LazyI18nString
},
'payment_explanation': {
'default': '',
'type': LazyI18nString
@@ -402,7 +386,7 @@ Your {event} team"""))
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
we did not yet receive a full payment for your order for {event}.
we did not yet receive a payment for your order for {event}.
Please keep in mind that we only guarantee your order if we receive
your payment before {expire_date}.

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